Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
adeace0f7b | ||
|
|
a8a545690f | ||
|
|
3748513996 | ||
|
|
18c2a4122f | ||
|
|
04664c8d42 | ||
|
|
628bbe14a8 | ||
|
|
abae7c9d67 | ||
|
|
eeee00f6cd | ||
|
|
6971ea5f9c | ||
|
|
cee38f93c8 | ||
|
|
fa5a6b8176 | ||
|
|
97f8940b15 | ||
|
|
556d23ade5 | ||
|
|
313290b22c | ||
|
|
d339db4799 | ||
|
|
03dff2ce77 | ||
|
|
c2c44f8d29 | ||
|
|
13130c7f27 | ||
|
|
30b986bb33 | ||
|
|
0196b0a445 | ||
|
|
8edd06fb67 | ||
|
|
4fbd0bb5c5 | ||
|
|
c2563661d0 | ||
|
|
f358cfb7c7 | ||
|
|
ef11d3e0ed | ||
|
|
f44e2ccf52 | ||
|
|
7e75c5b9b9 | ||
|
|
15ae11f966 | ||
|
|
226439ef36 | ||
|
|
81aca93b81 | ||
|
|
777c3b0079 | ||
|
|
ab85e4df72 | ||
|
|
cf8185498a | ||
|
|
bf34b7df4e | ||
|
|
028c5a34e6 | ||
|
|
c0ea0b0f5a | ||
|
|
4c38055d38 | ||
|
|
87fcbc5bfb | ||
|
|
9f86c8e40d | ||
|
|
3330da0e59 | ||
|
|
64492bec43 | ||
|
|
2402a84c71 | ||
|
|
294d06f22b | ||
|
|
d6e788dc89 | ||
|
|
070cd07199 | ||
|
|
4ded0ccd4f | ||
|
|
6253a2bca9 | ||
|
|
bd1efd8026 | ||
|
|
4940e0d8ed | ||
|
|
8d6cc65530 | ||
|
|
b8adefbb88 | ||
|
|
2c56a84bb7 | ||
|
|
613e22d6ca | ||
|
|
73e76fc72f | ||
|
|
1e5e2581c8 | ||
|
|
b25c4e33cb | ||
|
|
17b28f43f8 | ||
|
|
e3cde9be25 | ||
|
|
449703a6f0 | ||
|
|
358d5c72a6 | ||
|
|
abd2ced4b4 | ||
|
|
49e5aaea6b | ||
|
|
d1f431d121 | ||
|
|
fd759d6845 | ||
|
|
5be5531722 | ||
|
|
2a4fccde70 | ||
|
|
d6f018e581 | ||
|
|
e0c1cb6581 | ||
|
|
f80a00e211 | ||
|
|
4aee413680 | ||
|
|
2a91e2e7ad | ||
|
|
dba821a70e | ||
|
|
836dd73446 | ||
|
|
8f88427d4e | ||
|
|
817585a4ce | ||
|
|
0386f54449 | ||
|
|
4761677676 | ||
|
|
f61d053010 | ||
|
|
8440d02198 | ||
|
|
ebcdf847d2 | ||
|
|
5c553aae19 | ||
|
|
0af8ca5f78 | ||
|
|
52ba178c4e | ||
|
|
166016a741 | ||
|
|
20786e690d | ||
|
|
c19417a706 | ||
|
|
b768e0affb | ||
|
|
05ff10df1b | ||
|
|
a0a6227368 | ||
|
|
918e65d8d3 | ||
|
|
6cab62d140 | ||
|
|
9526268f1f | ||
|
|
016ec1877a | ||
|
|
772e75f04d | ||
|
|
10d7190ea4 | ||
|
|
cc8703577d | ||
|
|
4533755704 | ||
|
|
da4a08dfe1 | ||
|
|
48ddbf0412 | ||
|
|
39def1c20b | ||
|
|
e8a31b5d37 | ||
|
|
5a25dd08f6 | ||
|
|
b34219ce86 | ||
|
|
05548b0fa6 | ||
|
|
e08e7be24c | ||
|
|
1be600155b | ||
|
|
be757b1f2f | ||
|
|
d592e1e535 | ||
|
|
e381e9f52e | ||
|
|
3b951efd13 | ||
|
|
33161c5bad | ||
|
|
8b6236c796 | ||
|
|
a59d80d7f3 | ||
|
|
a5c76591ac | ||
|
|
c2a975af9e | ||
|
|
ab48cd5b55 | ||
|
|
ed4909a28d | ||
|
|
afc517b3a7 | ||
|
|
de9f6b6711 | ||
|
|
646cb8caeb | ||
|
|
d533ddde88 | ||
|
|
9664cb653a | ||
|
|
1574d44cc3 | ||
|
|
6981fc06ca | ||
|
|
12798c00bc | ||
|
|
ebb6be816b | ||
|
|
ae4e4f67c2 | ||
|
|
013aafde17 | ||
|
|
050b1ad78e | ||
|
|
21a76f5e7c | ||
|
|
f9d7610679 | ||
|
|
aeba15eb16 | ||
|
|
b81f64a253 | ||
|
|
a0d04cba50 | ||
|
|
43bdbcf89c | ||
|
|
6947a3ffa7 | ||
|
|
62d525dae7 | ||
|
|
f8bc74b27f | ||
|
|
044370f13c | ||
|
|
a2c45492cb | ||
|
|
9a67ca7c03 | ||
|
|
40cd8a24af | ||
|
|
28948975db | ||
|
|
1ce1db9689 | ||
|
|
86cdeed6ef | ||
|
|
d6ec0afbd6 | ||
|
|
3ea33e1dee | ||
|
|
3e6e3c08d5 | ||
|
|
6155a2ec2b | ||
|
|
4e0787ec77 | ||
|
|
8ecc32da6d | ||
|
|
eb41da78f2 | ||
|
|
a508969331 | ||
|
|
99febda256 | ||
|
|
543d78e040 | ||
|
|
4ef96c7930 | ||
|
|
7a7c99f752 | ||
|
|
6b1f17d6a8 | ||
|
|
cf191ef874 | ||
|
|
f6d3d013fd | ||
|
|
e6ad3c414f | ||
|
|
8162719e36 | ||
|
|
f5bc02d598 | ||
|
|
139dfb76e4 | ||
|
|
f4769c4046 | ||
|
|
fea3cc741f | ||
|
|
4e764350a4 | ||
|
|
c63811367e | ||
|
|
4cc8b3198b | ||
|
|
5c6ac06656 | ||
|
|
9b6b94d919 | ||
|
|
8f4af5ece4 | ||
|
|
2d9248889f | ||
|
|
804f37dcfa | ||
|
|
bf98713a55 | ||
|
|
2f2ba44c7c | ||
|
|
62292500ba | ||
|
|
a7f1e89ce9 | ||
|
|
20c7e2841c | ||
|
|
c79f5c5a7a | ||
|
|
d3771ed31f | ||
|
|
230eec1baf | ||
|
|
0bae393e07 | ||
|
|
434cf40b36 | ||
|
|
7417a1c950 | ||
|
|
799ccbe300 | ||
|
|
43044232bb | ||
|
|
4567db8836 | ||
|
|
e286e59431 | ||
|
|
65e89a4248 | ||
|
|
db63086c38 | ||
|
|
2abdf28eba | ||
|
|
2476f04a26 | ||
|
|
e458f1eab4 | ||
|
|
b15b2649f8 | ||
|
|
4fce7ba09a | ||
|
|
1a1369f115 | ||
|
|
9434ffabdc | ||
|
|
6aeb428796 | ||
|
|
8cabf32ebe | ||
|
|
85a98fcfd6 | ||
|
|
aa1f81a30a | ||
|
|
1c50c73aa4 | ||
|
|
6cc28c7edd | ||
|
|
1c2cf768f8 | ||
|
|
481e718d86 | ||
|
|
29f74afbf6 | ||
|
|
02111ab5b8 | ||
|
|
a2f7157bc3 | ||
|
|
e6591abfbc | ||
|
|
50bda1ddee | ||
|
|
31abad86d5 | ||
|
|
af7e90c87a | ||
|
|
2e4bdf122e | ||
|
|
83d7fa26fe | ||
|
|
efc4d89fdf | ||
|
|
a27206c543 | ||
|
|
faca2c9a2d | ||
|
|
0cbd137b4f | ||
|
|
cdf41d3e5e | ||
|
|
aa034f8669 | ||
|
|
aee5a50b9f | ||
|
|
b8926f00d7 | ||
|
|
839386f0b4 | ||
|
|
cf2e4bdd9c | ||
|
|
e7a455f22e |
No files matched your search
@@ -0,0 +1 @@
|
||||
remote connectivity test - 2026-05-13
|
||||
+78
-56
@@ -1,56 +1,78 @@
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
frontend-web/node_modules/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
frontend-web/vite-dev.log
|
||||
frontend-web/vite-dev.err.log
|
||||
|
||||
# Frontend local packages/archives/docs (non-source)
|
||||
frontend-web/pnpm-lock.yaml
|
||||
frontend-web/README.md
|
||||
frontend-web/doc.md
|
||||
frontend-web/src.zip
|
||||
frontend-web/src9.zip
|
||||
frontend-web/src-v1.zip
|
||||
frontend-web/src-*.rar
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend-web/dist-scheduled-check/
|
||||
|
||||
# Offline backend bundles and local deployment artifacts
|
||||
offline-backend-20260505.zip
|
||||
offline-backend-20260505/output/
|
||||
offline-backend-20260505/bundle/.env
|
||||
offline-backend-20260505/bundle/.env.example
|
||||
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260505/bundle/SHA256SUMS
|
||||
offline-backend-20260505/bundle/config.yaml
|
||||
offline-backend-20260505/bundle/config.yaml.example
|
||||
offline-backend-20260505/bundle/data/
|
||||
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260505/bundle/extensions_config.json
|
||||
offline-backend-20260505/bundle/frontend-vite.zip
|
||||
offline-backend-20260505/bundle/skills/
|
||||
offline-backend-20260505/bundle/start_backend.0509.sh
|
||||
offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml
|
||||
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/start_backend.sh
|
||||
offline-backend-20260512/stop_backend.sh
|
||||
offline-backend-20260512/check_backend.sh
|
||||
# Workspace/editor
|
||||
.vscode/
|
||||
frontend-web/.vscode/
|
||||
|
||||
# Claude Code local-only settings (per-user permissions, not shared)
|
||||
.claude/settings.local.json
|
||||
|
||||
# Local runtime logs and pid files
|
||||
.runtime/
|
||||
|
||||
# Frontend local/runtime artifacts
|
||||
frontend-web/.env
|
||||
frontend-web/node_modules/
|
||||
frontend-web/.pnpm-store/
|
||||
frontend-web/tsconfig.tsbuildinfo
|
||||
frontend-web/vite-dev.log
|
||||
frontend-web/vite-dev.err.log
|
||||
|
||||
# Frontend local packages/archives/docs (non-source)
|
||||
frontend-web/pnpm-lock.yaml
|
||||
frontend-web/README.md
|
||||
frontend-web/doc.md
|
||||
frontend-web/src.zip
|
||||
frontend-web/src9.zip
|
||||
frontend-web/src-v1.zip
|
||||
frontend-web/src-*.rar
|
||||
|
||||
# Frontend build artifacts
|
||||
frontend-web/dist-scheduled-check/
|
||||
|
||||
# Offline backend bundles and local deployment artifacts
|
||||
offline-backend-20260505.zip
|
||||
offline-backend-20260505/output/
|
||||
offline-backend-20260505/bundle/.env
|
||||
offline-backend-20260505/bundle/.env.example
|
||||
offline-backend-20260505/bundle/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260505/bundle/SHA256SUMS
|
||||
offline-backend-20260505/bundle/config.yaml
|
||||
offline-backend-20260505/bundle/config.yaml.example
|
||||
offline-backend-20260505/bundle/data/
|
||||
offline-backend-20260505/bundle/deerflow-backend-offline-image-20260505.tar
|
||||
offline-backend-20260505/bundle/extensions_config.json
|
||||
offline-backend-20260505/bundle/frontend-vite.zip
|
||||
offline-backend-20260505/bundle/skills/
|
||||
offline-backend-20260505/bundle/start_backend.0509.sh
|
||||
offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
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/*
|
||||
Generated
-10
@@ -1,10 +0,0 @@
|
||||
# 默认忽略的文件
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# 基于编辑器的 HTTP 客户端请求
|
||||
/httpRequests/
|
||||
# 已忽略包含查询文件的默认文件夹
|
||||
/queries/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?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>
|
||||
Generated
-8
@@ -1,8 +0,0 @@
|
||||
<?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>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="PrettierConfiguration">
|
||||
<option name="myConfigurationMode" value="AUTOMATIC" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
-6
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -4,669 +4,92 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
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
|
||||
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
|
||||
|
||||
This is a monorepo with frontend and backend components running in parallel.
|
||||
- `offline-backend-20260512/backend/` — Python backend (FastAPI Gateway + LangGraph agent runtime)
|
||||
- `frontend-web/` — Vite + React 19 frontend (TypeScript, pnpm)
|
||||
|
||||
---
|
||||
Managed together via shell scripts in `scripts/`.
|
||||
|
||||
## Architecture
|
||||
## Commands
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────┐
|
||||
│ 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 │ │ │
|
||||
│ │ └────────────────┘ │ │
|
||||
│ └──────────────────────┘ │
|
||||
└───────────────────────────┘
|
||||
```
|
||||
|
||||
**Request Routing**:
|
||||
- `/api/langgraph/*` → Gateway (LangGraph runtime)
|
||||
- `/api/*` (other) → Gateway (FastAPI REST endpoints)
|
||||
- `/` → Frontend (React web interface)
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
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)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Setup & Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+ and pnpm
|
||||
- Python 3.12+
|
||||
- uv package manager
|
||||
- API keys for LLM providers (OpenAI, Anthropic, etc.)
|
||||
|
||||
### Quick Start (Full Application)
|
||||
### Full Application (from repo root)
|
||||
|
||||
```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
|
||||
./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
|
||||
```
|
||||
|
||||
Access at: **http://localhost:2026**
|
||||
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
|
||||
|
||||
---
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Full Application (from backend directory)
|
||||
### Backend Only (from `offline-backend-20260512/backend/`)
|
||||
|
||||
```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
|
||||
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
|
||||
```
|
||||
|
||||
### Frontend Only (from frontend-web directory)
|
||||
|
||||
Run a single test file:
|
||||
```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
|
||||
### Frontend Only (from `frontend-web/`)
|
||||
|
||||
```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
|
||||
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
|
||||
|
||||
## Port Mapping
|
||||
| Service | Port | Notes |
|
||||
|---------|------|-------|
|
||||
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
|
||||
| Frontend (Vite) | 5174 | React SPA |
|
||||
|
||||
| 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) |
|
||||
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
|
||||
|
||||
---
|
||||
|
||||
## 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
|
||||
- `config.example.yaml` - Backend configuration template
|
||||
- `extensions_config.json` - MCP servers and skills
|
||||
- `offline-backend-20260512/backend/langgraph.json` - LangGraph Studio config
|
||||
|
||||
### Testing
|
||||
- `offline-backend-20260512/backend/tests/` - Test suite
|
||||
- Regression tests: `test_docker_sandbox_mode_detection.py`, `test_provisioner_kubeconfig.py`, `test_harness_boundary.py`
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### 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
|
||||
## Architecture
|
||||
|
||||
### 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
|
||||
|
||||
---
|
||||
The backend (`offline-backend-20260512/backend/`) is split into two layers:
|
||||
|
||||
## Contributing
|
||||
- **`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).
|
||||
|
||||
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
|
||||
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.
|
||||
|
||||
### CI/CD
|
||||
**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.
|
||||
|
||||
GitHub Actions runs:
|
||||
- Backend unit tests (including harness boundary test)
|
||||
- Linting checks
|
||||
- Type checking
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
All must pass before merging to `master`.
|
||||
### Frontend
|
||||
|
||||
---
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
## Support
|
||||
- **Routing**: React Router v7; top-level routes in `src/pages/PageRoutes.tsx`, workspace sub-routes in `src/pages/WorkspaceRoutes.tsx`
|
||||
- **Pages**: `ChatPage` (thread chat), `AgentChatPage` (custom agent chat), `AgentsPage`/`NewAgentPage` (agent management), `ScheduledTasksPage`/`ScheduledTaskRunDetailPage` (scheduled tasks)
|
||||
- **State**: TanStack Query for server state, context providers (`SubtasksProvider`, `ArtifactsProvider`, `PromptInputProvider`) scoped to `WorkspaceRoutes`
|
||||
- **LLM streaming**: `@langchain/langgraph-sdk` streams messages from the backend via SSE
|
||||
- **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)
|
||||
|
||||
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
|
||||
### Configuration
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## License & Attribution
|
||||
## Detailed Backend Reference
|
||||
|
||||
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/`
|
||||
The backend has its own comprehensive documentation at:
|
||||
- `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
|
||||
@@ -0,0 +1,474 @@
|
||||
# New Chat 接口流式返回格式说明
|
||||
|
||||
## 接口概览
|
||||
|
||||
- **路径**:`POST /new_chat`
|
||||
- **传输协议**:Server-Sent Events (SSE)
|
||||
- **Content-Type**:`text/event-stream`
|
||||
- **协议基础**:Vercel AI SDK Data Stream Protocol
|
||||
|
||||
---
|
||||
|
||||
## 请求格式
|
||||
|
||||
```json
|
||||
{
|
||||
"chat_id": 123,
|
||||
"search_space_id": 456,
|
||||
"user_query": "用户问题",
|
||||
"pic_query": [{"image_url": "/tmp/xxx.png"}],
|
||||
"model_name": "gpt-4o",
|
||||
"enable_citations": true,
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|------|------|------|------|
|
||||
| `chat_id` | int | ✅ | 会话 ID,同时作为 LangGraph 的 thread_id |
|
||||
| `search_space_id` | int | ✅ | 搜索空间 ID |
|
||||
| `user_query` | string | ✅ | 用户问题 |
|
||||
| `pic_query` | list\|null | ❌ | 图片路径列表,格式 `[{"image_url": "..."}]` |
|
||||
| `model_name` | string\|null | ❌ | 指定模型名称(插件场景使用) |
|
||||
| `enable_citations` | bool | ❌ | 是否启用引用(默认 true) |
|
||||
| `messages` | list\|null | ❌ | 历史消息列表 |
|
||||
| `attachments` | list\|null | ❌ | 上传附件(已解析为 Markdown) |
|
||||
| `mentioned_document_ids` | list\|null | ❌ | 前端 @ 引用的文档 ID 列表 |
|
||||
|
||||
---
|
||||
|
||||
## 响应 Headers
|
||||
|
||||
```
|
||||
Content-Type: text/event-stream
|
||||
Cache-Control: no-cache
|
||||
Connection: keep-alive
|
||||
X-Accel-Buffering: no
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## SSE 数据帧格式
|
||||
|
||||
每条消息均为标准 SSE 格式:
|
||||
|
||||
```
|
||||
data: {JSON 对象}\n\n
|
||||
```
|
||||
|
||||
流结束时发送:
|
||||
|
||||
```
|
||||
data: [DONE]\n\n
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 事件类型详解
|
||||
|
||||
### 1. 消息生命周期
|
||||
|
||||
#### `start` — 消息开始
|
||||
|
||||
```json
|
||||
{"type": "start", "messageId": "msg_abc123"}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `messageId` | 本次消息的唯一 ID |
|
||||
|
||||
---
|
||||
|
||||
#### `finish` — 消息结束
|
||||
|
||||
```json
|
||||
{"type": "finish"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `[DONE]` — 流终止标记
|
||||
|
||||
```
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
> 这是流中最后一帧,为纯字符串,不是 JSON。
|
||||
|
||||
---
|
||||
|
||||
### 2. 步骤控制(多轮工具调用)
|
||||
|
||||
#### `start-step` — 步骤开始
|
||||
|
||||
```json
|
||||
{"type": "start-step"}
|
||||
```
|
||||
|
||||
#### `finish-step` — 步骤结束
|
||||
|
||||
```json
|
||||
{"type": "finish-step"}
|
||||
```
|
||||
|
||||
> 每次 LLM 调用算一个 step。工具调用期间会有多个 step 交替出现。
|
||||
|
||||
---
|
||||
|
||||
### 3. 文本块(start / delta / end 模式)
|
||||
|
||||
#### `text-start` — 文本块开始
|
||||
|
||||
```json
|
||||
{"type": "text-start", "id": "text_abc123"}
|
||||
```
|
||||
|
||||
#### `text-delta` — 文本增量内容
|
||||
|
||||
```json
|
||||
{"type": "text-delta", "id": "text_abc123", "delta": "Hello, "}
|
||||
```
|
||||
|
||||
#### `text-end` — 文本块结束
|
||||
|
||||
```json
|
||||
{"type": "text-end", "id": "text_abc123"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. 推理块(Reasoning,适用于 DeepSeek R1 等思维链模型)
|
||||
|
||||
#### `reasoning-start` — 推理块开始
|
||||
|
||||
```json
|
||||
{"type": "reasoning-start", "id": "reasoning_abc123"}
|
||||
```
|
||||
|
||||
#### `reasoning-delta` — 推理增量内容
|
||||
|
||||
```json
|
||||
{"type": "reasoning-delta", "id": "reasoning_abc123", "delta": "让我先思考..."}
|
||||
```
|
||||
|
||||
#### `reasoning-end` — 推理块结束
|
||||
|
||||
```json
|
||||
{"type": "reasoning-end", "id": "reasoning_abc123"}
|
||||
```
|
||||
|
||||
> 触发时机:
|
||||
> - 模型返回 `additional_kwargs.reasoning_content`(DeepSeek R1 原生)
|
||||
> - 模型输出中包含 `<think>...</think>` 标签(自动剥离)
|
||||
|
||||
---
|
||||
|
||||
### 5. 工具调用
|
||||
|
||||
#### `tool-input-start` — 工具调用开始
|
||||
|
||||
```json
|
||||
{"type": "tool-input-start", "toolCallId": "call_abc123", "toolName": "search_knowledge_base"}
|
||||
```
|
||||
|
||||
#### `tool-input-delta` — 工具输入流式内容(增量)
|
||||
|
||||
```json
|
||||
{"type": "tool-input-delta", "toolCallId": "call_abc123", "inputTextDelta": "San Fra"}
|
||||
```
|
||||
|
||||
#### `tool-input-available` — 工具输入完整参数
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool-input-available",
|
||||
"toolCallId": "call_abc123",
|
||||
"toolName": "search_knowledge_base",
|
||||
"input": {"query": "机器学习入门"}
|
||||
}
|
||||
```
|
||||
|
||||
#### `tool-output-available` — 工具执行结果
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool-output-available",
|
||||
"toolCallId": "call_abc123",
|
||||
"output": {"status": "completed", "result_length": 1234}
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:`search_knowledge_base` 只返回摘要(不含全文),`write_todos`、`link_preview`、`display_image`、`scrape_webpage`、`generate_podcast` 会返回完整 output 供前端渲染组件。
|
||||
|
||||
---
|
||||
|
||||
### 6. 来源引用
|
||||
|
||||
#### `source-url` — URL 来源
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "source-url",
|
||||
"sourceId": "https://example.com",
|
||||
"url": "https://example.com",
|
||||
"title": "示例页面"
|
||||
}
|
||||
```
|
||||
|
||||
#### `source-document` — 文档来源
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "source-document",
|
||||
"sourceId": "doc_123",
|
||||
"mediaType": "file",
|
||||
"title": "报告.pdf",
|
||||
"description": "第三季度报告"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 7. 文件
|
||||
|
||||
```json
|
||||
{"type": "file", "url": "https://example.com/image.png", "mediaType": "image/png"}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 8. 自定义数据(`data-*` 类型)
|
||||
|
||||
#### `data-terminal-info` — 终端信息提示
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data-terminal-info",
|
||||
"data": {
|
||||
"text": "知识库搜索已完成",
|
||||
"type": "success"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`type` 枚举值:`info` | `error` | `success` | `warning`
|
||||
|
||||
---
|
||||
|
||||
#### `data-further-questions` — 推荐追问问题
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data-further-questions",
|
||||
"data": {
|
||||
"questions": ["问题1", "问题2", "问题3"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### `data-thinking-step` — 思考步骤(思维链 UI 展示)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "data-thinking-step",
|
||||
"data": {
|
||||
"id": "thinking-1",
|
||||
"title": "正在搜索知识库",
|
||||
"status": "in_progress",
|
||||
"items": ["Query: 机器学习入门"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `id` | 步骤唯一 ID(格式 `thinking-N`) |
|
||||
| `title` | 步骤标题 |
|
||||
| `status` | `pending` \| `in_progress` \| `completed` |
|
||||
| `items` | 步骤详情列表(可选) |
|
||||
|
||||
> 同一个 `id` 的步骤事件会被重复发送以更新状态(先 `in_progress` 后 `completed`)。
|
||||
|
||||
**常见步骤标题对照:**
|
||||
|
||||
| 工具 / 场景 | 标题 |
|
||||
|------------|------|
|
||||
| 初始分析 | 正在理解您的请求 / 正在分析您的内容 |
|
||||
| `search_knowledge_base` | 查询个人知识库中 |
|
||||
| `search_web` | 网查搜索中 |
|
||||
| `link_preview` | 正在获取链接预览 |
|
||||
| `display_image` | 正在分析图片 |
|
||||
| `scrape_webpage` | 正在抓取网页 |
|
||||
| `write_todos`(首次) | 正在创建计划 |
|
||||
| `write_todos`(更新) | 正在更新进度 |
|
||||
| `generate_podcast` | 正在生成播客 |
|
||||
|
||||
---
|
||||
|
||||
### 9. 错误
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "error",
|
||||
"errorText": "API rate limit exceeded",
|
||||
"errorType": "llm",
|
||||
"errorCode": "rate_limit"
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| `errorText` | 错误描述文本 |
|
||||
| `errorType` | 错误类型(见下表) |
|
||||
| `errorCode` | 具体错误代码(可选) |
|
||||
|
||||
**errorType 枚举:**
|
||||
|
||||
| 值 | 含义 |
|
||||
|----|------|
|
||||
| `llm` | LLM / API 错误(模型不存在、限流、密钥错误等) |
|
||||
| `network` | 网络连通性错误 |
|
||||
| `timeout` | 请求超时 |
|
||||
| `authentication` | 鉴权 / 权限错误 |
|
||||
| `rate_limit` | 速率限制 |
|
||||
| `validation` | 输入验证错误 |
|
||||
| `internal` | 内部服务器错误 |
|
||||
| `unknown` | 未知错误 |
|
||||
|
||||
---
|
||||
|
||||
### 10. 中断(Human-in-the-loop)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "interrupt",
|
||||
"messageId": "msg_abc123",
|
||||
"interrupt": {
|
||||
"action_requests": [...],
|
||||
"review_configs": [...]
|
||||
},
|
||||
"payload": {"expertAgentId": 42}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流示例(普通问答)
|
||||
|
||||
```
|
||||
data: {"type":"start","messageId":"msg_abc123"}
|
||||
|
||||
data: {"type":"start-step"}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"in_progress","items":["Processing: 什么是向量数据库?"]}}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"completed","items":["Processing: 什么是向量数据库?"]}}
|
||||
|
||||
data: {"type":"text-start","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"text-delta","id":"text_xyz789","delta":"向量数据库是"}
|
||||
|
||||
data: {"type":"text-delta","id":"text_xyz789","delta":"一种专为存储和检索"}
|
||||
|
||||
data: {"type":"text-delta","id":"text_xyz789","delta":"高维向量而设计的数据库..."}
|
||||
|
||||
data: {"type":"text-end","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"finish-step"}
|
||||
|
||||
data: {"type":"finish"}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流示例(含工具调用)
|
||||
|
||||
```
|
||||
data: {"type":"start","messageId":"msg_abc123"}
|
||||
|
||||
data: {"type":"start-step"}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"in_progress","items":["Processing: 搜索最新 AI 论文"]}}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-1","title":"正在理解您的请求","status":"completed","items":["Processing: 搜索最新 AI 论文"]}}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-2","title":"查询个人知识库中","status":"in_progress","items":["Query: 最新 AI 论文"]}}
|
||||
|
||||
data: {"type":"tool-input-start","toolCallId":"call_xxx","toolName":"search_knowledge_base"}
|
||||
|
||||
data: {"type":"tool-input-available","toolCallId":"call_xxx","toolName":"search_knowledge_base","input":{"query":"最新 AI 论文"}}
|
||||
|
||||
data: {"type":"data-thinking-step","data":{"id":"thinking-2","title":"Searching knowledge base","status":"completed","items":["Query: 最新 AI 论文","Found relevant information (2048 chars)"]}}
|
||||
|
||||
data: {"type":"tool-output-available","toolCallId":"call_xxx","output":{"status":"completed","result_length":2048}}
|
||||
|
||||
data: {"type":"data-terminal-info","data":{"text":"知识库搜索已完成","type":"success"}}
|
||||
|
||||
data: {"type":"text-start","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"text-delta","id":"text_xyz789","delta":"根据知识库中的内容..."}
|
||||
|
||||
data: {"type":"text-end","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"finish-step"}
|
||||
|
||||
data: {"type":"finish"}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 完整流示例(含推理过程)
|
||||
|
||||
```
|
||||
data: {"type":"start","messageId":"msg_abc123"}
|
||||
|
||||
data: {"type":"start-step"}
|
||||
|
||||
data: {"type":"reasoning-start","id":"reasoning_r01"}
|
||||
|
||||
data: {"type":"reasoning-delta","id":"reasoning_r01","delta":"让我先分析这个问题..."}
|
||||
|
||||
data: {"type":"reasoning-delta","id":"reasoning_r01","delta":"需要考虑以下几个方面..."}
|
||||
|
||||
data: {"type":"reasoning-end","id":"reasoning_r01"}
|
||||
|
||||
data: {"type":"text-start","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"text-delta","id":"text_xyz789","delta":"综合分析后,结论是..."}
|
||||
|
||||
data: {"type":"text-end","id":"text_xyz789"}
|
||||
|
||||
data: {"type":"finish-step"}
|
||||
|
||||
data: {"type":"finish"}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 标准消息流顺序
|
||||
|
||||
```
|
||||
start
|
||||
└─ start-step
|
||||
├─ [data-thinking-step × N] 思考步骤(可多次)
|
||||
├─ [reasoning-start/delta/end] 推理过程(可选)
|
||||
├─ [tool-input-start] 工具调用开始(可多次)
|
||||
├─ [tool-input-available] 工具参数确认
|
||||
├─ [tool-output-available] 工具结果
|
||||
├─ [data-terminal-info] 终端提示(可多次)
|
||||
├─ [source-url / source-document] 来源引用(可选)
|
||||
├─ text-start
|
||||
│ └─ text-delta × N 流式文本
|
||||
└─ text-end
|
||||
finish-step
|
||||
finish
|
||||
[DONE]
|
||||
```
|
||||
@@ -0,0 +1,9 @@
|
||||
# GEMINI_API_KEY: Required for Gemini AI API calls.
|
||||
# AI Studio automatically injects this at runtime from user secrets.
|
||||
# Users configure this via the Secrets panel in the AI Studio UI.
|
||||
GEMINI_API_KEY="MY_GEMINI_API_KEY"
|
||||
|
||||
# APP_URL: The URL where this applet is hosted.
|
||||
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
|
||||
# Used for self-referential links, OAuth callbacks, and API endpoints.
|
||||
APP_URL="MY_APP_URL"
|
||||
@@ -0,0 +1,20 @@
|
||||
<div align="center">
|
||||
<img width="1200" height="475" alt="GHBanner" src="https://ai.google.dev/static/site-assets/images/share-ais-513315318.png" />
|
||||
</div>
|
||||
|
||||
# Run and deploy your AI Studio app
|
||||
|
||||
This contains everything you need to run your app locally.
|
||||
|
||||
View your app in AI Studio: https://ai.studio/apps/75261c35-a558-4ef8-9501-e8b7f9c98bfb
|
||||
|
||||
## Run Locally
|
||||
|
||||
**Prerequisites:** Node.js
|
||||
|
||||
|
||||
1. Install dependencies:
|
||||
`npm install`
|
||||
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
|
||||
3. Run the app:
|
||||
`npm run dev`
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "Gemini Style Assistant",
|
||||
"description": "A clean, modern AI assistant interface inspired by Google Gemini with centered search, suggestion pills, and a minimalist sidebar.",
|
||||
"requestFramePermissions": [],
|
||||
"majorCapabilities": ["MAJOR_CAPABILITY_SERVER_SIDE_GEMINI_API"]
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "react-example",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite --port=3000 --host=0.0.0.0",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"clean": "rm -rf dist server.js",
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^1.29.0",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"lucide-react": "^0.546.0",
|
||||
"react": "^19.0.1",
|
||||
"react-dom": "^19.0.1",
|
||||
"vite": "^6.2.3",
|
||||
"express": "^4.21.2",
|
||||
"dotenv": "^17.2.3",
|
||||
"motion": "^12.23.24"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.14.0",
|
||||
"autoprefixer": "^10.4.21",
|
||||
"esbuild": "^0.25.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.3",
|
||||
"@types/express": "^4.17.21"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* @license
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'motion/react';
|
||||
import {
|
||||
MessageSquare,
|
||||
Bot,
|
||||
Wrench,
|
||||
Brain,
|
||||
ListChecks,
|
||||
Settings,
|
||||
Menu,
|
||||
Mic,
|
||||
Plus,
|
||||
Sparkles,
|
||||
ArrowRight,
|
||||
User,
|
||||
History,
|
||||
LayoutGrid,
|
||||
Languages,
|
||||
Code2,
|
||||
Music,
|
||||
Database,
|
||||
Search
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function App() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
|
||||
const navItems = [
|
||||
{ icon: MessageSquare, label: '对话', active: true },
|
||||
{ icon: Bot, label: '智能体' },
|
||||
{ icon: Wrench, label: '技能' },
|
||||
{ icon: Brain, label: '记忆' },
|
||||
{ icon: ListChecks, label: '任务管理' },
|
||||
];
|
||||
|
||||
const recentChats = [
|
||||
'CM助手初次问候交谈',
|
||||
'Untitled',
|
||||
'Untitled',
|
||||
'Untitled',
|
||||
'帮我写一个500字的文案',
|
||||
'帮我生成一个写论文的实例',
|
||||
];
|
||||
|
||||
const suggestions = [
|
||||
{ icon: Code2, label: 'Build an Android app', color: 'text-green-600', bg: 'bg-green-50' },
|
||||
{ icon: Languages, label: 'Convert text to speech', color: 'text-blue-600', bg: 'bg-blue-50' },
|
||||
{ icon: Music, label: 'Generate music', color: 'text-purple-600', bg: 'bg-purple-50' },
|
||||
{ icon: Database, label: 'Add database and auth', color: 'text-cyan-600', bg: 'bg-cyan-50' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full bg-white font-sans text-gray-900 overflow-hidden">
|
||||
{/* Sidebar */}
|
||||
<motion.aside
|
||||
initial={false}
|
||||
animate={{ width: sidebarOpen ? 280 : 0, opacity: sidebarOpen ? 1 : 0 }}
|
||||
className="relative bg-gray-50 border-r border-gray-100 flex flex-col h-full"
|
||||
>
|
||||
<div className="p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 px-2 py-1.5 bg-red-50 text-red-700 rounded-lg cursor-pointer">
|
||||
<Plus size={18} />
|
||||
<span className="font-medium text-sm">新对话</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="flex-1 overflow-y-auto px-2 space-y-1">
|
||||
<div className="space-y-1 pb-4">
|
||||
{navItems.map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-xl cursor-pointer transition-colors ${
|
||||
item.active ? 'bg-white shadow-sm font-medium' : 'hover:bg-gray-100 text-gray-600'
|
||||
}`}
|
||||
>
|
||||
<item.icon size={18} />
|
||||
<span className="text-sm">{item.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="pt-4 border-t border-gray-200">
|
||||
<h3 className="px-3 mb-2 text-xs font-semibold text-gray-400 uppercase tracking-wider">
|
||||
最近对话
|
||||
</h3>
|
||||
{recentChats.map((chat, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="px-3 py-1.5 text-sm text-gray-600 hover:bg-gray-100 rounded-lg cursor-pointer truncate"
|
||||
>
|
||||
{chat}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div className="p-4 border-t border-gray-200">
|
||||
<div className="flex items-center gap-3 px-3 py-2 text-gray-600 hover:bg-gray-100 rounded-xl cursor-pointer">
|
||||
<Settings size={18} />
|
||||
<span className="text-sm">设置和更多</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.aside>
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="flex-1 flex flex-col relative h-full overflow-y-auto">
|
||||
{/* Top bar */}
|
||||
<header className="absolute top-0 w-full p-4 flex items-center justify-between z-10">
|
||||
<button
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-lg text-gray-500 transition-colors">
|
||||
<Settings size={20} />
|
||||
</button>
|
||||
<div className="w-8 h-8 rounded-full bg-gradient-to-tr from-blue-400 to-purple-400 flex items-center justify-center text-white shadow-sm ring-2 ring-white cursor-pointer overflow-hidden">
|
||||
<User size={16} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Center Canvas */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center px-4 max-w-4xl mx-auto w-full pt-16">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-center mb-12 relative"
|
||||
>
|
||||
<div className="absolute -top-16 left-1/2 -translate-x-1/2 opacity-10 blur-2xl pointer-events-none">
|
||||
<Sparkles size={120} className="text-blue-500 animate-pulse" />
|
||||
</div>
|
||||
<h1 className="text-5xl font-display font-medium tracking-tight bg-clip-text text-transparent bg-gradient-to-r from-gray-900 to-gray-500 mb-4 px-4">
|
||||
Build your ideas with Gemini
|
||||
</h1>
|
||||
<Sparkles className="absolute -right-8 top-0 text-blue-200 opacity-50" size={48} />
|
||||
</motion.div>
|
||||
|
||||
{/* Search/Input Area */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ delay: 0.1 }}
|
||||
className="w-full max-w-2xl relative group"
|
||||
>
|
||||
<div className="gemini-gradient-border gemini-shadow rounded-3xl bg-white p-2">
|
||||
<div className="flex flex-col p-4 space-y-4">
|
||||
<textarea
|
||||
placeholder="Describe an app and let Gemini do the rest"
|
||||
className="w-full bg-transparent resize-none focus:outline-none text-lg text-gray-700 min-h-[80px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
|
||||
<Mic size={20} />
|
||||
</button>
|
||||
<button className="p-2 hover:bg-gray-100 rounded-full text-gray-400 transition-colors">
|
||||
<Plus size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="flex items-center gap-2 px-5 py-2.5 bg-gray-50 hover:bg-gray-100 text-gray-700 rounded-full border border-gray-200 transition-all font-medium text-sm group/btn shadow-sm">
|
||||
<Sparkles size={16} className="text-blue-500" />
|
||||
I'm feeling lucky
|
||||
<ArrowRight size={14} className="opacity-0 -translate-x-2 group-hover/btn:opacity-100 group-hover/btn:translate-x-0 transition-all" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Suggestion Chips */}
|
||||
<div className="mt-8 flex flex-wrap justify-center gap-3">
|
||||
{suggestions.map((suggestion, idx) => (
|
||||
<motion.button
|
||||
key={suggestion.label}
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.2 + idx * 0.05 }}
|
||||
whileHover={{ scale: 1.02 }}
|
||||
whileTap={{ scale: 0.98 }}
|
||||
className={`flex items-center gap-2 px-4 py-2.5 rounded-2xl border border-gray-100 bg-white hover:bg-gray-50 shadow-sm transition-all text-sm font-medium group hover:border-gray-200 text-gray-700`}
|
||||
>
|
||||
<suggestion.icon className={`w-4 h-4 ${suggestion.color}`} />
|
||||
{suggestion.label}
|
||||
</motion.button>
|
||||
))}
|
||||
<motion.button
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.4 }}
|
||||
className="flex items-center justify-center p-2 rounded-full border border-gray-100 bg-white hover:bg-gray-50 text-gray-400 shadow-sm transition-all"
|
||||
>
|
||||
<ArrowRight size={16} />
|
||||
</motion.button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer info/links */}
|
||||
<footer className="w-full max-w-4xl mx-auto px-4 py-12 mt-auto">
|
||||
<div className="rounded-3xl border border-gray-100 bg-gray-50/50 p-8 flex items-center justify-between group hover:bg-gray-50 transition-colors">
|
||||
<div>
|
||||
<h2 className="text-2xl font-display font-medium text-gray-800 mb-1">
|
||||
Discover and remix app ideas
|
||||
</h2>
|
||||
<p className="text-gray-500 text-sm italic">
|
||||
(Gallery view pending implementation)
|
||||
</p>
|
||||
</div>
|
||||
<button className="flex items-center gap-2 px-6 py-2.5 bg-white border border-gray-200 rounded-xl hover:shadow-md transition-all font-medium text-gray-700">
|
||||
Browse the app gallery
|
||||
<ArrowRight size={18} />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{/* Floating Right Bar (from target image) */}
|
||||
<div className="absolute right-6 top-1/2 -translate-y-1/2 flex flex-col gap-4">
|
||||
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
|
||||
<LayoutGrid size={22} />
|
||||
</button>
|
||||
<button className="p-2.5 bg-white shadow-lg border border-gray-100 rounded-xl hover:scale-110 transition-all text-gray-400">
|
||||
<Languages size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: "Outfit", "Inter", sans-serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply bg-white text-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.gemini-shadow {
|
||||
box-shadow: 0 0 20px rgba(66, 133, 244, 0.08),
|
||||
0 0 40px rgba(219, 68, 55, 0.04),
|
||||
0 0 60px rgba(244, 180, 0, 0.04),
|
||||
0 0 80px rgba(15, 157, 88, 0.04);
|
||||
}
|
||||
|
||||
.gemini-gradient-border {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 1.5rem; /* rounded-3xl */
|
||||
z-index: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.gemini-gradient-border::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
width: 150%;
|
||||
height: 150%;
|
||||
top: -25%;
|
||||
left: -25%;
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
transparent 0deg,
|
||||
transparent 90deg,
|
||||
#4285F4 120deg,
|
||||
#DB4437 150deg,
|
||||
#F4B400 180deg,
|
||||
#0F9D58 210deg,
|
||||
transparent 240deg
|
||||
);
|
||||
z-index: -2;
|
||||
animation: rotate 3s linear infinite;
|
||||
filter: blur(15px);
|
||||
}
|
||||
|
||||
.gemini-gradient-border::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: 1.5px; /* The "thickness" of the glow border */
|
||||
background: white;
|
||||
border-radius: calc(1.5rem - 1px);
|
||||
z-index: -1;
|
||||
}
|
||||
|
||||
@keyframes rotate {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Outfit:wght@400;500;600&display=swap');
|
||||
@@ -0,0 +1,10 @@
|
||||
import {StrictMode} from 'react';
|
||||
import {createRoot} from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import './index.css';
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"experimentalDecorators": true,
|
||||
"useDefineForClassFields": false,
|
||||
"module": "ESNext",
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"allowJs": true,
|
||||
"jsx": "react-jsx",
|
||||
"paths": {
|
||||
"@/*": [
|
||||
"./*"
|
||||
]
|
||||
},
|
||||
"allowImportingTsExtensions": true,
|
||||
"noEmit": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'path';
|
||||
import {defineConfig} from 'vite';
|
||||
|
||||
export default defineConfig(() => {
|
||||
return {
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, '.'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
// HMR is disabled in AI Studio via DISABLE_HMR env var.
|
||||
// Do not modifyâfile watching is disabled to prevent flickering during agent edits.
|
||||
hmr: process.env.DISABLE_HMR !== 'true',
|
||||
// Disable file watching when DISABLE_HMR is true to save CPU during agent edits.
|
||||
watch: process.env.DISABLE_HMR === 'true' ? null : {},
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
# 本地开发:直连 Gateway(与 offline-backend-20260512/backend 默认端口一致)
|
||||
# 修改端口时请同步改这里,并重启 `npm run dev`
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001/
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
@@ -1,7 +1,6 @@
|
||||
# 复制为 .env 或 .env.development 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
#
|
||||
# Gateway 根地址(REST:`/api/models`、`/api/threads/...` 等)
|
||||
# 复制为 .env 后按需修改(Vite 仅暴露以 VITE_ 开头的变量)
|
||||
# 直连本机 Gateway 时使用;若浏览器报 CORS,请在网关环境设置 CORS_ORIGINS,包含前端源站,例如:
|
||||
# CORS_ORIGINS=http://127.0.0.1:5173,http://localhost:5173
|
||||
|
||||
VITE_BACKEND_BASE_URL=http://127.0.0.1:8001
|
||||
#
|
||||
# LangGraph SDK 的 apiUrl:须指向带 `/api` 的前缀,以便请求落在 `/api/threads`、`/api/assistants` 等路由上
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
@@ -0,0 +1,916 @@
|
||||
# AI 写作 — 后端开发文档
|
||||
|
||||
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
|
||||
>
|
||||
> **配套文档**:
|
||||
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
|
||||
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
|
||||
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位与技术架构
|
||||
|
||||
### 产品定位
|
||||
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
|
||||
|
||||
### 技术栈
|
||||
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
|
||||
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
|
||||
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
|
||||
- **业务数据**:MySQL `ai_writing_sessions` 表
|
||||
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
|
||||
|
||||
### 与主 Agent 的关系
|
||||
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── routers/
|
||||
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
|
||||
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
|
||||
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
|
||||
│
|
||||
└── packages/harness/deerflow/
|
||||
├── agents/ai_writing/
|
||||
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
|
||||
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
|
||||
│ ├── state.py # AIWritingState TypedDict(121 行)
|
||||
│ │
|
||||
│ ├── nodes/ # ── Graph 节点 ──
|
||||
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
|
||||
│ │ ├── intent_parser.py # 节点 1:意图解析
|
||||
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
|
||||
│ │ ├── writer_outline.py # 节点 3:大纲规划
|
||||
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
|
||||
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
|
||||
│ │
|
||||
│ ├── prompts/ # ── Prompt 模板 ──
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── researcher_prompts.py
|
||||
│ │ ├── writer_prompts.py
|
||||
│ │ └── editor_prompts.py
|
||||
│ │
|
||||
│ └── search/ # ── 素材检索 provider ──
|
||||
│ ├── base.py # SearchResult / SearchProvider 接口
|
||||
│ ├── duckduckgo_search.py
|
||||
│ ├── internet_search.py # 互联网检索(默认)
|
||||
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
|
||||
│ └── demo_search.py # 离线 demo 数据
|
||||
│
|
||||
└── persistence/ai_writing_sessions/
|
||||
├── __init__.py # AIWritingSessionRepository 工厂
|
||||
├── model.py # ORM:AIWritingSessionRow
|
||||
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Graph 拓扑(最重要)
|
||||
|
||||
### StateGraph 节点 + 边
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ researcher │ ← (re_search 回到这里)
|
||||
└────────┬────────┘
|
||||
↓ (after_researcher: ⼟材 ready)
|
||||
┌──────────────────────┐
|
||||
│ pause_material │ ⚙️ PAUSE-1:素材确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_search
|
||||
↓ └→ researcher
|
||||
┌─────────────────┐
|
||||
│ writer_outline │ ← (re_outline 回到这里)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_outline
|
||||
↓ └→ writer_outline
|
||||
┌─────────────────┐
|
||||
│ writer_draft │ ← (素材补充后回到这里)
|
||||
└─┬───────────────┘
|
||||
↓ (after_writer_draft)
|
||||
┌──────┴────────────────┐
|
||||
↓ ↓
|
||||
┌──────────────────┐ ┌────────────────────┐
|
||||
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
|
||||
│ help(素材不足)│ │ │
|
||||
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
|
||||
│ supplement │ del/ │ │ │ to_editor
|
||||
│↓ │ loose │ │ user_revise
|
||||
research writer_ │ final ↓
|
||||
draft │ ize writer_draft
|
||||
│↓
|
||||
done
|
||||
│
|
||||
(to_editor)│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ editor │ 3 维度并行流式审核
|
||||
└────────┬────────┘
|
||||
↓ (after_review)
|
||||
┌──────────────────────┐
|
||||
│ pause_review │ ⚙️ PAUSE-4:审核确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ revise
|
||||
│↓ └→ writer_draft
|
||||
done
|
||||
```
|
||||
|
||||
### 节点定义位置一览
|
||||
|
||||
| 节点名 | 实现文件 | 类型 | 流式? |
|
||||
|---|---|---|---|
|
||||
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
|
||||
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
|
||||
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
|
||||
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
|
||||
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
|
||||
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
|
||||
### 条件路由函数(graph.py)
|
||||
|
||||
| 函数 | 在哪个节点之后 | 路由依据 |
|
||||
|---|---|---|
|
||||
| `after_researcher` | researcher | 是否找到素材 |
|
||||
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
|
||||
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
|
||||
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
|
||||
| `after_section_help_pause` | pause_section_help | status |
|
||||
| `after_draft_pause` | pause_draft | done / revising / reviewing |
|
||||
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
|
||||
| `after_review_pause` | pause_review | done / writing_draft |
|
||||
|
||||
---
|
||||
|
||||
## 四、State 定义(state.py)
|
||||
|
||||
```python
|
||||
class AIWritingState(TypedDict):
|
||||
# —— 写作配置(启动时填充)——
|
||||
user_intent: str
|
||||
article_type: str
|
||||
target_audience: str
|
||||
word_count_target: int
|
||||
keyword_count: int # 1-10,默认 6
|
||||
writing_mode: str # "strict" | "loose"
|
||||
|
||||
# —— Agent 产出 ——
|
||||
material_package: Optional[MaterialPackage] # 素材收集专家
|
||||
current_outline: Optional[Outline] # 作家
|
||||
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
|
||||
current_draft: Optional[DraftArticle] # 最新草稿
|
||||
review_result: Optional[ReviewResult] # 编辑
|
||||
|
||||
# —— 用户干预 ——
|
||||
last_user_intervention: Optional[UserInterventionPayload]
|
||||
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
|
||||
|
||||
# —— 流程控制 ——
|
||||
revision_count: int
|
||||
max_revisions: int # 默认 3
|
||||
status: WritingStatus
|
||||
|
||||
# —— SSE 推送队列 ——
|
||||
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
|
||||
```
|
||||
|
||||
### State 字段的特殊语义
|
||||
|
||||
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
|
||||
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
|
||||
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
|
||||
|
||||
---
|
||||
|
||||
## 五、路由层(app/gateway/routers/ai_writing.py)
|
||||
|
||||
### 14 个接口一览
|
||||
|
||||
| HTTP | 路径 | 用途 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
|
||||
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
|
||||
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
|
||||
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
|
||||
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
|
||||
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
|
||||
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
|
||||
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
|
||||
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
|
||||
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
|
||||
|
||||
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
|
||||
|
||||
```python
|
||||
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 动态文章类型列表
|
||||
_session_error_flags: dict[str, bool]
|
||||
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
|
||||
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
|
||||
```
|
||||
|
||||
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
|
||||
|
||||
### `_stream_graph_segment`(graph 推进函数)
|
||||
|
||||
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
|
||||
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
|
||||
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
|
||||
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
|
||||
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
|
||||
5. 任何异常 → 推 `error` 事件 → 关闭 queue
|
||||
6. finally 段:更新 DB 最终状态、清理所有模块字典
|
||||
|
||||
### SSE 生成器 `_sse_generator`
|
||||
|
||||
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
|
||||
|
||||
**关键逻辑**:
|
||||
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
|
||||
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
|
||||
- 收到 None 哨兵 → 关闭流
|
||||
|
||||
### `_recover_session`(跨 worker 恢复)
|
||||
|
||||
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
|
||||
1. 从 checkpointer 读出 state snapshot
|
||||
2. 重建 `_event_queues[sid]` 队列
|
||||
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
|
||||
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
|
||||
|
||||
返回三种状态:
|
||||
- `"interrupted"` — 图停在干预点,已重建队列
|
||||
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
|
||||
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
|
||||
|
||||
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
|
||||
|
||||
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
|
||||
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
|
||||
|
||||
---
|
||||
|
||||
## 六、Graph 与持久化(graph.py)
|
||||
|
||||
### Lazy 单例 + 自动降级
|
||||
|
||||
```python
|
||||
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
|
||||
_async_checkpointer_singleton = None # checkpointer 单例
|
||||
|
||||
async def get_ai_writing_graph():
|
||||
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
|
||||
global AI_WRITING_GRAPH
|
||||
if AI_WRITING_GRAPH is not None:
|
||||
return AI_WRITING_GRAPH
|
||||
checkpointer = await _get_async_checkpointer()
|
||||
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
|
||||
return AI_WRITING_GRAPH
|
||||
```
|
||||
|
||||
### Checkpointer 优先级(_get_async_checkpointer)
|
||||
|
||||
```
|
||||
1. config.yaml 的 checkpointer 段
|
||||
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
|
||||
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
|
||||
└── type: memory → InMemorySaver
|
||||
|
||||
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
|
||||
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
|
||||
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
|
||||
```
|
||||
|
||||
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
|
||||
|
||||
### Shutdown
|
||||
|
||||
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
|
||||
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
|
||||
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
|
||||
|
||||
调用顺序(详见 `app/gateway/app.py` lifespan):
|
||||
1. 停清理任务
|
||||
2. `wait_for_inflight_graph_tasks(30s)`
|
||||
3. `shutdown_ai_writing_graph()`
|
||||
|
||||
---
|
||||
|
||||
## 七、节点详解(nodes/)
|
||||
|
||||
### `nodes/_utils.py`(公共工具)
|
||||
|
||||
| 函数 / 类 | 用途 |
|
||||
|---|---|
|
||||
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
|
||||
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
|
||||
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
|
||||
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
|
||||
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
|
||||
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
|
||||
| `count_words(text)` | 中文 + 英文混合计字数 |
|
||||
|
||||
### `nodes/intent_parser.py` — 意图解析
|
||||
|
||||
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
|
||||
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
|
||||
- 输出:更新 state 的对应字段,推 `step_done` 事件
|
||||
|
||||
### `nodes/researcher.py` — 素材检索
|
||||
|
||||
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
|
||||
- 用 `search/*` 的 provider 并行检索
|
||||
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
|
||||
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
|
||||
|
||||
### `nodes/writer_outline.py` — 大纲规划
|
||||
|
||||
- 输入:`user_intent` + `material_package`
|
||||
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
|
||||
- 解析最终 JSON → `current_outline`,推 `outline_ready`
|
||||
|
||||
### `nodes/writer_draft.py` — 草稿生成(最复杂)
|
||||
|
||||
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
|
||||
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
|
||||
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
|
||||
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
|
||||
|
||||
### `nodes/editor.py` — 编辑审核(3 维度并行)
|
||||
|
||||
- 3 个维度(**fact / logic / language**)并行调 LLM
|
||||
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
|
||||
- 三个都完成后聚合成 `review_result`,推 `review_ready`
|
||||
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
|
||||
|
||||
---
|
||||
|
||||
## 八、持久化(persistence/ai_writing_sessions/)
|
||||
|
||||
### `model.py` — ORM
|
||||
|
||||
```python
|
||||
class AIWritingSessionRow(Base):
|
||||
__tablename__ = "ai_writing_sessions"
|
||||
id: Mapped[str] # = session_id(uuid)
|
||||
user_id: Mapped[str | None] # NULL = no-auth 模式
|
||||
title: Mapped[str] # 从 user_intent 截取前 50 字
|
||||
user_intent: Mapped[str | None]
|
||||
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
|
||||
draft_title: Mapped[str | None]
|
||||
draft_markdown: Mapped[str | None]
|
||||
completed_interventions: Mapped[str | None] # JSON 字符串
|
||||
review_result: Mapped[str | None] # JSON 字符串
|
||||
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
|
||||
created_at / updated_at: BeijingDateTime
|
||||
```
|
||||
|
||||
### `sql.py` — Repository
|
||||
|
||||
| 方法 | 用途 |
|
||||
|---|---|
|
||||
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
|
||||
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
|
||||
| `list(user_id, limit)` | 列出当前用户的会话 |
|
||||
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
|
||||
| `delete(session_id, user_id)` | 删除(带权限校验) |
|
||||
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
|
||||
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
|
||||
|
||||
### Alembic 迁移
|
||||
|
||||
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
|
||||
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
|
||||
|
||||
---
|
||||
|
||||
## 九、定期清理(ai_writing_cleanup.py)
|
||||
|
||||
### 设计目标
|
||||
- 删超过 `retention_days`(默认 7)的旧会话
|
||||
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
|
||||
- **绝对不删正在写的会话**(活跃保护)
|
||||
|
||||
### 关键函数
|
||||
|
||||
```python
|
||||
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
|
||||
```
|
||||
|
||||
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
|
||||
|
||||
执行流程:
|
||||
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
|
||||
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
|
||||
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
|
||||
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
|
||||
5. 循环直到本批 < batch_size
|
||||
|
||||
### Cron 调度
|
||||
|
||||
`cleanup_scheduler_loop()` 常驻后台任务:
|
||||
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
|
||||
- `asyncio.sleep` 到下次触发时间
|
||||
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
|
||||
|
||||
### Admin 手动触发
|
||||
|
||||
`POST /api/ai-writing/admin/cleanup`:
|
||||
- 鉴权:`system_role == "admin"`
|
||||
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
|
||||
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
|
||||
|
||||
---
|
||||
|
||||
## 十、配置(config.yaml)
|
||||
|
||||
```yaml
|
||||
# Checkpointer:graph 状态持久化
|
||||
checkpointer:
|
||||
type: sqlite # sqlite | postgres | memory
|
||||
connection_string: .deer-flow/data/checkpoints.db
|
||||
|
||||
# AI 写作子系统
|
||||
ai_writing:
|
||||
# ── 清理调度 ─────────────────────────────────────────────
|
||||
cleanup:
|
||||
enabled: true
|
||||
retention_days: 7
|
||||
cron: "0 3 * * *" # 每天凌晨 3 点
|
||||
delete_only_finished: false
|
||||
batch_size: 500
|
||||
|
||||
# ── 素材收集专家(researcher)专用配置 ──────────────────
|
||||
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
|
||||
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
|
||||
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
|
||||
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
|
||||
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
|
||||
|
||||
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
|
||||
# 取值范围 [1, 100],非法值或缺省回退 20。
|
||||
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
|
||||
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
|
||||
max_materials: 20
|
||||
|
||||
# ── 素材检索 provider ───────────────────────────────────
|
||||
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
|
||||
search_provider: internet
|
||||
|
||||
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
|
||||
ddg_region: "cn-zh"
|
||||
|
||||
# intranet 模式:对接内网知识库 ES 检索接口
|
||||
# 接口契约(POST):
|
||||
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
|
||||
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
|
||||
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
|
||||
```
|
||||
|
||||
### 配置项与代码的对应关系
|
||||
|
||||
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|
||||
|---|---|---|---|
|
||||
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
|
||||
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
|
||||
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
|
||||
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
|
||||
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
|
||||
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
|
||||
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
|
||||
|
||||
### 配置如何传到节点
|
||||
|
||||
```
|
||||
config.yaml
|
||||
└─ ai_writing: { ... }
|
||||
│
|
||||
▼ _ai_writing_config() 取出整个 dict
|
||||
routers/ai_writing.py:_make_config()
|
||||
│ 注入到 config.configurable.ai_writing_config
|
||||
▼
|
||||
nodes/researcher.py
|
||||
cfg = config.get("configurable", {})
|
||||
ai_writing_config = cfg.get("ai_writing_config", {})
|
||||
keyword_model = ai_writing_config.get("keyword_model")
|
||||
rank_model = ai_writing_config.get("rank_model")
|
||||
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
|
||||
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
|
||||
```
|
||||
|
||||
### 切换内网检索的最小步骤
|
||||
|
||||
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
|
||||
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
|
||||
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
|
||||
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
|
||||
|
||||
### 备注
|
||||
|
||||
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
|
||||
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
|
||||
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
|
||||
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
|
||||
---
|
||||
|
||||
## 十一、SSE 事件协议(后端 → 前端契约)
|
||||
|
||||
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
|
||||
|
||||
### 推出事件的位置
|
||||
|
||||
| 事件 | 推出位置 |
|
||||
|---|---|
|
||||
| `connected` | `_sse_generator` 入口 |
|
||||
| `step_start` / `step_done` | 节点内部主动 emit |
|
||||
| `research_progress` | `researcher_node` 内的 `_emit` |
|
||||
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
|
||||
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
|
||||
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
|
||||
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
|
||||
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
|
||||
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
|
||||
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
|
||||
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
|
||||
| `resumed` | pause 节点 return 时塞进 `progress_events` |
|
||||
| `notice` | 节点内手动推(非致命告警) |
|
||||
| `done` | `_stream_graph_segment` 正常结束 |
|
||||
| `error` | `_stream_graph_segment` except 分支 |
|
||||
|
||||
### 节点如何流式推 chunk
|
||||
|
||||
通过 `config.configurable.sse_queue`:
|
||||
|
||||
```python
|
||||
async def writer_outline_node(state, config):
|
||||
sse_queue = config.get("configurable", {}).get("sse_queue")
|
||||
sse_session_id = config.get("configurable", {}).get("sse_session_id")
|
||||
|
||||
async for chunk in llm.astream(prompt):
|
||||
if sse_queue:
|
||||
await sse_queue.put(("outline_chunk", {
|
||||
"session_id": sse_session_id,
|
||||
"text": chunk.content,
|
||||
"is_thinking": False,
|
||||
}))
|
||||
|
||||
return {"current_outline": parsed, ...}
|
||||
```
|
||||
|
||||
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
|
||||
|
||||
---
|
||||
|
||||
## 十二、扩展指南:常见开发任务
|
||||
|
||||
### 任务 1:新增一个 Graph 节点
|
||||
|
||||
**例**:在 `editor` 之后加一个「术语统一」节点。
|
||||
|
||||
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
|
||||
```python
|
||||
async def terminology_node(state, config):
|
||||
# ... 读 current_draft 改术语
|
||||
return {
|
||||
"current_draft": new_draft,
|
||||
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
|
||||
}
|
||||
```
|
||||
|
||||
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
|
||||
```python
|
||||
from .nodes.terminology import terminology_node
|
||||
g.add_node("terminology", terminology_node)
|
||||
# 改 editor 后的边
|
||||
g.add_edge("editor", "terminology")
|
||||
g.add_edge("terminology", "pause_review")
|
||||
```
|
||||
|
||||
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
|
||||
|
||||
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
|
||||
|
||||
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
|
||||
|
||||
### 任务 2:新增一个用户干预点
|
||||
|
||||
1. **写 pause 节点**:在 `graph.py` 顶部加:
|
||||
```python
|
||||
async def pause_xxx(state):
|
||||
user_input = interrupt({
|
||||
"pause_point": "xxx_confirm",
|
||||
"message": "...",
|
||||
"context_field": state["..."],
|
||||
})
|
||||
action = user_input.get("action", "confirm")
|
||||
if action == "confirm":
|
||||
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
|
||||
# ... 其他分支
|
||||
```
|
||||
|
||||
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
|
||||
```python
|
||||
class ResumeRequest(BaseModel):
|
||||
# ...
|
||||
new_field: Optional[str] = None
|
||||
```
|
||||
|
||||
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
|
||||
```python
|
||||
intervention = {
|
||||
# ...
|
||||
"newField": body.new_field,
|
||||
}
|
||||
```
|
||||
|
||||
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
|
||||
|
||||
5. **前端配合**:见前端文档
|
||||
|
||||
### 任务 3:新增一种素材检索 provider
|
||||
|
||||
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
|
||||
```python
|
||||
class MyProvider(SearchProvider):
|
||||
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
|
||||
...
|
||||
```
|
||||
|
||||
2. 配置 `config.yaml`:
|
||||
```yaml
|
||||
ai_writing:
|
||||
search:
|
||||
provider: my_pkg.search:MyProvider
|
||||
# provider 特定的配置...
|
||||
```
|
||||
|
||||
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
|
||||
|
||||
### 任务 4:修改 LLM Prompt
|
||||
|
||||
所有 prompt 集中在 `agents/ai_writing/prompts/`:
|
||||
- `researcher_prompts.py` — 检索专家
|
||||
- `writer_prompts.py` — 大纲 + 草稿
|
||||
- `editor_prompts.py` — 审核
|
||||
|
||||
改 prompt 后**强烈建议**:
|
||||
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
|
||||
2. 跑现有测试确认拓扑兼容
|
||||
|
||||
### 任务 5:扩展持久化字段
|
||||
|
||||
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
|
||||
|
||||
1. `model.py` 加列:
|
||||
```python
|
||||
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
```
|
||||
|
||||
2. **必须**新建 alembic 迁移:
|
||||
```bash
|
||||
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
|
||||
```
|
||||
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
|
||||
|
||||
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
|
||||
|
||||
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
|
||||
|
||||
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
|
||||
|
||||
---
|
||||
|
||||
## 十三、容错策略(关键设计原则)
|
||||
|
||||
### 「写作流程能完整继续」契约
|
||||
|
||||
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
|
||||
|
||||
### 落实位置
|
||||
|
||||
| 失败点 | 兜底位置 | 行为 |
|
||||
|---|---|---|
|
||||
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
|
||||
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
|
||||
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
|
||||
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
|
||||
| DB `update(review)` 失败 | 同上 | 同上 |
|
||||
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
|
||||
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
|
||||
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
|
||||
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
|
||||
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
|
||||
|
||||
### 容器优雅关闭
|
||||
|
||||
lifespan shutdown 顺序:
|
||||
1. 停 cleanup scheduler
|
||||
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
|
||||
3. 超时未结束的 task `cancel()` + 2s ack 等待
|
||||
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
|
||||
|
||||
---
|
||||
|
||||
## 十四、测试覆盖
|
||||
|
||||
| 测试文件 | 数量 | 覆盖范围 |
|
||||
|---|---|---|
|
||||
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
|
||||
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
|
||||
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
|
||||
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
|
||||
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
|
||||
|
||||
跑全部:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
|
||||
```
|
||||
|
||||
期望 **51 个测试全过**。
|
||||
|
||||
---
|
||||
|
||||
## 十五、关键调试 / 排查命令
|
||||
|
||||
### 看 graph 当前状态
|
||||
|
||||
```python
|
||||
# 进 python shell
|
||||
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
|
||||
graph = await get_ai_writing_graph()
|
||||
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
|
||||
print(snap.values, snap.next, snap.tasks)
|
||||
```
|
||||
|
||||
### 直接读 sqlite checkpointer
|
||||
|
||||
```bash
|
||||
sqlite3 /app/.deer-flow/data/checkpoints.db
|
||||
.tables # 看表(checkpoints / checkpoint_writes 等)
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
|
||||
WHERE thread_id = '<session_id>' LIMIT 5;
|
||||
```
|
||||
|
||||
### 查 MySQL 业务行
|
||||
|
||||
```sql
|
||||
SELECT id, status, draft_title, updated_at,
|
||||
JSON_LENGTH(transcript) AS transcript_size
|
||||
FROM ai_writing_sessions
|
||||
WHERE id = '<session_id>';
|
||||
```
|
||||
|
||||
### 查内存中的活跃会话(调试时手动加)
|
||||
|
||||
```python
|
||||
# 临时在路由加一个 admin 接口
|
||||
@router.get("/admin/inflight")
|
||||
async def admin_inflight():
|
||||
return {
|
||||
"event_queues": list(_event_queues.keys()),
|
||||
"inflight_tasks": len(_inflight_graph_tasks),
|
||||
"pending_pause": list(_session_pending_pause.keys()),
|
||||
}
|
||||
```
|
||||
|
||||
### 日志关键字
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
|
||||
```
|
||||
|
||||
期望看到(启动时):
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
|
||||
|
||||
警告:
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
|
||||
|
||||
---
|
||||
|
||||
## 十六、性能与限制
|
||||
|
||||
### 单 session 资源占用
|
||||
- 内存:~50KB(state + progress_events 累计)
|
||||
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
|
||||
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
|
||||
|
||||
### 并发上限
|
||||
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
|
||||
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
|
||||
|
||||
### 长会话风险
|
||||
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
|
||||
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
|
||||
|
||||
### 优化方向
|
||||
- progress_events 节流(聚合 chunk 事件)
|
||||
- transcript 增量保存(diff 而非全量覆盖)
|
||||
- 节点级 LLM 调用加 retry(tenacity)
|
||||
|
||||
---
|
||||
|
||||
## 十七、与主 Agent 的关系
|
||||
|
||||
| 维度 | 主 Agent | AI 写作 |
|
||||
|---|---|---|
|
||||
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
|
||||
| Middleware | 18 层中间件 | 无(直接节点链) |
|
||||
| State | `ThreadState` | `AIWritingState` |
|
||||
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
|
||||
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
|
||||
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
|
||||
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
|
||||
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
|
||||
|
||||
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
|
||||
|
||||
---
|
||||
|
||||
## 十八、未来 RFC 方向
|
||||
|
||||
| 主题 | 描述 |
|
||||
|---|---|
|
||||
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
|
||||
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
|
||||
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
|
||||
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
|
||||
| 多模态素材 | 支持图片素材输入和图文混合输出 |
|
||||
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:相关文件速查
|
||||
|
||||
| 文件 | 行数 | 用途 |
|
||||
|---|---|---|
|
||||
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
|
||||
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
|
||||
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
|
||||
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
|
||||
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
|
||||
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
|
||||
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
|
||||
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
|
||||
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
|
||||
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
|
||||
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
|
||||
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见踩坑
|
||||
|
||||
| 现象 | 根因 | 解决 |
|
||||
|---|---|---|
|
||||
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
|
||||
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
|
||||
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
|
||||
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
|
||||
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
|
||||
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
|
||||
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
|
||||
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
|
||||
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
|
||||
|
||||
---
|
||||
|
||||
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
|
||||
@@ -0,0 +1,726 @@
|
||||
# AI 写作助手后端接口与 Prompt 协议文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 AI 写作助手第二阶段升级中的后端开发、接口设计、提示词设计和联调约束。
|
||||
|
||||
本轮后端目标不是重做 DeerFlow 的整体架构,而是在保留现有 Python + LangGraph + 线程体系的基础上,新增一套稳定的“选区改写型能力”,服务前端的 Tiptap 写作编辑器。
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮后端职责
|
||||
|
||||
后端负责:
|
||||
|
||||
- 接收当前线程信息
|
||||
- 接收用户选区文本
|
||||
- 接收改写动作
|
||||
- 选取必要上下文
|
||||
- 调用模型生成候选改写结果
|
||||
- 返回纯净候选文本
|
||||
|
||||
后端本轮不负责:
|
||||
|
||||
- 直接编辑 Tiptap JSON
|
||||
- 直接操作前端编辑器选区
|
||||
- 直接替换前端文档内容
|
||||
- 做复杂富文本 patch
|
||||
- 做 tracked changes
|
||||
|
||||
---
|
||||
|
||||
## 3. 设计原则
|
||||
|
||||
### 3.1 纯文本协议优先
|
||||
|
||||
首版只让后端处理:
|
||||
|
||||
- 选区文本
|
||||
- 前后文文本
|
||||
- 动作类型
|
||||
- 风格参数
|
||||
|
||||
不让后端直接理解完整富文本 JSON。
|
||||
|
||||
### 3.2 前端负责应用,后端负责生成
|
||||
|
||||
- 前端负责选区获取
|
||||
- 前端负责候选结果展示
|
||||
- 前端负责替换 / 插入
|
||||
- 后端只负责“给出候选文本”
|
||||
|
||||
### 3.3 结果必须可直接落文档
|
||||
|
||||
后端输出必须尽量是可直接放回编辑器的正文,不应带解释性前缀,例如:
|
||||
|
||||
- “当然可以,以下是润色版本:”
|
||||
- “我帮你改写如下:”
|
||||
- “下面是优化后的内容:”
|
||||
|
||||
这类内容必须禁止。
|
||||
|
||||
---
|
||||
|
||||
## 4. 推荐接口设计
|
||||
|
||||
## 4.1 主接口
|
||||
|
||||
推荐新增统一接口:
|
||||
|
||||
```text
|
||||
POST /api/writing/rewrite
|
||||
```
|
||||
|
||||
由 `action` 决定具体重写类型。
|
||||
|
||||
## 4.2 为什么推荐统一接口
|
||||
|
||||
因为前端交互是一套统一浮层和统一预览面板,拆成多个接口会导致:
|
||||
|
||||
- 前端判断分支增多
|
||||
- 埋点与异常处理分散
|
||||
- 后续新增动作不方便扩展
|
||||
|
||||
统一接口更适合本轮需求。
|
||||
|
||||
---
|
||||
|
||||
## 5. 请求体定义
|
||||
|
||||
建议请求体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"threadId": "string",
|
||||
"documentId": "string",
|
||||
"selection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"action": "polish",
|
||||
"tone": "professional",
|
||||
"surroundingContext": {
|
||||
"before": "选区前文",
|
||||
"after": "选区后文"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 字段说明
|
||||
|
||||
### 6.1 `threadId`
|
||||
|
||||
用途:
|
||||
|
||||
- 关联当前 DeerFlow 线程
|
||||
- 必要时取线程上下文或会话元数据
|
||||
|
||||
### 6.2 `documentId`
|
||||
|
||||
用途:
|
||||
|
||||
- 标识当前右侧文档
|
||||
- 首版可选
|
||||
- 如果当前系统还没有独立文档 ID,可先允许为空
|
||||
|
||||
### 6.3 `selection.from` / `selection.to`
|
||||
|
||||
用途:
|
||||
|
||||
- 帮助前后端联调排查
|
||||
- 首版后端通常不需要真正使用偏移量
|
||||
- 建议保留,便于日志定位
|
||||
|
||||
### 6.4 `selection.text`
|
||||
|
||||
用途:
|
||||
|
||||
- 这是本轮最核心输入
|
||||
- 后端实际重写的主文本
|
||||
|
||||
### 6.5 `action`
|
||||
|
||||
允许值:
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone`
|
||||
|
||||
### 6.6 `tone`
|
||||
|
||||
仅当 `action = tone` 时使用。
|
||||
|
||||
建议允许值:
|
||||
|
||||
- `professional`
|
||||
- `casual`
|
||||
- `readable`
|
||||
- `academic`
|
||||
- `expressive`
|
||||
|
||||
### 6.7 `surroundingContext.before`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区前文
|
||||
- 帮助模型理解语气、主语、叙事连续性
|
||||
|
||||
建议:
|
||||
|
||||
- 控制长度
|
||||
- 不要塞太多无关上下文
|
||||
|
||||
### 6.8 `surroundingContext.after`
|
||||
|
||||
用途:
|
||||
|
||||
- 提供选区后文
|
||||
- 帮助模型在扩写、改语气时与后续内容衔接
|
||||
|
||||
---
|
||||
|
||||
## 7. 返回体定义
|
||||
|
||||
建议返回体如下:
|
||||
|
||||
```json
|
||||
{
|
||||
"output": "AI 改写后的候选文本",
|
||||
"originalSelection": {
|
||||
"from": 120,
|
||||
"to": 260,
|
||||
"text": "待改写文本"
|
||||
},
|
||||
"metadata": {
|
||||
"action": "polish",
|
||||
"tone": null,
|
||||
"model": "your-model-name"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. 返回字段要求
|
||||
|
||||
## 8.1 `output`
|
||||
|
||||
必须:
|
||||
|
||||
- 只包含候选正文
|
||||
- 尽量不夹带解释语
|
||||
- 能直接被前端替换或插入
|
||||
|
||||
## 8.2 `originalSelection`
|
||||
|
||||
作用:
|
||||
|
||||
- 前端可以做校验
|
||||
- 联调时方便确认是针对哪段文本返回的
|
||||
|
||||
## 8.3 `metadata`
|
||||
|
||||
作用:
|
||||
|
||||
- 便于埋点
|
||||
- 便于日志记录
|
||||
- 便于问题追踪
|
||||
|
||||
---
|
||||
|
||||
## 9. 错误响应规范
|
||||
|
||||
建议统一错误结构:
|
||||
|
||||
```json
|
||||
{
|
||||
"error": {
|
||||
"code": "WRITING_REWRITE_FAILED",
|
||||
"message": "rewrite failed"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
建议错误码:
|
||||
|
||||
- `WRITING_REWRITE_FAILED`
|
||||
- `WRITING_INVALID_ACTION`
|
||||
- `WRITING_INVALID_TONE`
|
||||
- `WRITING_EMPTY_SELECTION`
|
||||
- `WRITING_MODEL_UNAVAILABLE`
|
||||
- `WRITING_CONTEXT_TOO_LARGE`
|
||||
|
||||
---
|
||||
|
||||
## 10. 动作语义定义
|
||||
|
||||
## 10.1 `polish`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持原意
|
||||
- 改善表达
|
||||
- 提升通顺度、自然度和可读性
|
||||
|
||||
不应:
|
||||
|
||||
- 引入新事实
|
||||
- 改写成完全不同内容
|
||||
|
||||
## 10.2 `expand`
|
||||
|
||||
目标:
|
||||
|
||||
- 保持主旨
|
||||
- 扩充细节
|
||||
- 增强画面感、解释性或完整度
|
||||
|
||||
不应:
|
||||
|
||||
- 无限制发散
|
||||
- 添加明显脱离主题的新内容
|
||||
|
||||
## 10.3 `shorten`
|
||||
|
||||
目标:
|
||||
|
||||
- 压缩篇幅
|
||||
- 保留关键信息
|
||||
- 减少重复表达
|
||||
|
||||
不应:
|
||||
|
||||
- 丢失关键论点
|
||||
- 压缩到语义残缺
|
||||
|
||||
## 10.4 `tone`
|
||||
|
||||
目标:
|
||||
|
||||
- 不改变原意
|
||||
- 调整语言风格
|
||||
|
||||
不应:
|
||||
|
||||
- 改写事实
|
||||
- 改写逻辑结构到完全不同
|
||||
|
||||
---
|
||||
|
||||
## 11. Prompt 设计原则
|
||||
|
||||
## 11.1 通用系统约束
|
||||
|
||||
所有改写类 prompt 都应包含以下约束:
|
||||
|
||||
- 直接输出最终可用正文
|
||||
- 不输出标题,除非原文本身就是标题改写
|
||||
- 不输出解释
|
||||
- 不输出分析过程
|
||||
- 不输出“以下是结果”之类前缀
|
||||
- 保持与上下文语气一致
|
||||
|
||||
## 11.2 上下文使用原则
|
||||
|
||||
前后文只用于理解,不要求模型重写前后文。
|
||||
|
||||
明确告诉模型:
|
||||
|
||||
- 只改写选中文本
|
||||
- 前后文仅用于参考衔接
|
||||
|
||||
## 11.3 长度控制原则
|
||||
|
||||
- `polish`:长度与原文接近
|
||||
- `expand`:长度可明显增加,但不要失控
|
||||
- `shorten`:长度明显缩短,但要保留关键信息
|
||||
- `tone`:长度尽量与原文接近
|
||||
|
||||
---
|
||||
|
||||
## 12. Prompt 模板建议
|
||||
|
||||
## 12.1 润色模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意的前提下,对“选中文本”进行润色,使表达更自然、更流畅、更清晰。
|
||||
|
||||
要求:
|
||||
1. 保留原文事实和核心意思。
|
||||
2. 不要添加原文没有的新事实。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出润色后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.2 扩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不偏离主题的前提下,对“选中文本”进行扩写,增加必要细节、画面感或补充说明,使内容更充实。
|
||||
|
||||
要求:
|
||||
1. 保持原文主旨不变。
|
||||
2. 增加内容时要自然,不要跑题。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出扩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.3 缩写模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在保留关键信息和原意的前提下,对“选中文本”进行缩写,使其更精炼。
|
||||
|
||||
要求:
|
||||
1. 不要丢掉核心信息。
|
||||
2. 去掉重复、冗余和拖沓表达。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出缩写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
## 12.4 改语气模板
|
||||
|
||||
```text
|
||||
你是一名中文写作编辑助手。
|
||||
|
||||
请在不改变原意和事实的前提下,把“选中文本”改写成更{tone_label}的表达方式。
|
||||
|
||||
要求:
|
||||
1. 保持原文信息不变。
|
||||
2. 只调整表达风格,不改变事实和逻辑。
|
||||
3. 不要输出解释,不要输出前缀,不要输出分析过程。
|
||||
4. 直接输出改写后的正文。
|
||||
|
||||
前文参考:
|
||||
{before}
|
||||
|
||||
选中文本:
|
||||
{selection}
|
||||
|
||||
后文参考:
|
||||
{after}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 13. Tone 映射建议
|
||||
|
||||
建议前端传标准值,后端映射中文标签:
|
||||
|
||||
- `professional` -> 专业、正式
|
||||
- `casual` -> 口语化、轻松自然
|
||||
- `readable` -> 更易读、更清晰
|
||||
- `academic` -> 学术、严谨
|
||||
- `expressive` -> 更有文采、更有表现力
|
||||
|
||||
---
|
||||
|
||||
## 14. 上下文截取策略
|
||||
|
||||
## 14.1 推荐策略
|
||||
|
||||
前端传:
|
||||
|
||||
- 选区前固定长度文本
|
||||
- 选区后固定长度文本
|
||||
|
||||
例如:
|
||||
|
||||
- 前文 200 到 500 字
|
||||
- 后文 200 到 500 字
|
||||
|
||||
### 原因
|
||||
|
||||
- 足够帮助模型理解文气和衔接
|
||||
- 不会让请求太重
|
||||
|
||||
## 14.2 不建议
|
||||
|
||||
- 把整篇长文全量塞进去
|
||||
- 把整个线程消息都塞进改写接口
|
||||
|
||||
如果后端确实要参考线程上下文,也建议只取最近和写作相关的少量消息摘要。
|
||||
|
||||
---
|
||||
|
||||
## 15. LangGraph 接入建议
|
||||
|
||||
## 15.1 推荐接入方式
|
||||
|
||||
在现有 LangGraph / gateway 架构中新增一个写作改写专用入口。
|
||||
|
||||
建议二选一:
|
||||
|
||||
### 方案 A
|
||||
|
||||
新增专用 router:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
内部调用现有模型层 / agent 层。
|
||||
|
||||
### 方案 B
|
||||
|
||||
新增专用 graph node 或 tool:
|
||||
|
||||
- `rewrite_selection`
|
||||
|
||||
再由 router 调用该能力。
|
||||
|
||||
## 15.2 更推荐方案
|
||||
|
||||
推荐:
|
||||
|
||||
- 对外暴露统一 router
|
||||
- 内部走已有模型封装或 LangGraph 节点
|
||||
|
||||
这样前端接口更稳定,后续后端内部重构不会影响前端。
|
||||
|
||||
---
|
||||
|
||||
## 16. 后端模块拆解
|
||||
|
||||
## 16.1 模块 A:接口层
|
||||
|
||||
负责:
|
||||
|
||||
- 请求校验
|
||||
- 错误响应
|
||||
- 参数标准化
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一 rewrite API
|
||||
- action / tone 校验
|
||||
- 空选区校验
|
||||
|
||||
## 16.2 模块 B:上下文拼装层
|
||||
|
||||
负责:
|
||||
|
||||
- 拼装 before / selection / after
|
||||
- 必要时提取线程相关信息
|
||||
|
||||
需要完成:
|
||||
|
||||
- 上下文长度控制
|
||||
- 清洗空白和异常字符
|
||||
|
||||
## 16.3 模块 C:Prompt 层
|
||||
|
||||
负责:
|
||||
|
||||
- 选择模板
|
||||
- 填充 action 和 tone
|
||||
|
||||
需要完成:
|
||||
|
||||
- 4 类动作模板
|
||||
- tone 映射
|
||||
|
||||
## 16.4 模块 D:模型调用层
|
||||
|
||||
负责:
|
||||
|
||||
- 走现有模型配置体系
|
||||
- 执行大模型请求
|
||||
|
||||
需要完成:
|
||||
|
||||
- 统一调用入口
|
||||
- 模型不可用错误处理
|
||||
|
||||
## 16.5 模块 E:结果清洗层
|
||||
|
||||
负责:
|
||||
|
||||
- 去掉解释性前缀
|
||||
- 去掉多余包裹
|
||||
- 标准化返回
|
||||
|
||||
需要完成:
|
||||
|
||||
- 输出清洗
|
||||
- metadata 填充
|
||||
|
||||
---
|
||||
|
||||
## 17. 返回结果清洗建议
|
||||
|
||||
即使 prompt 已经约束,模型仍可能输出:
|
||||
|
||||
- “以下是润色后的内容:”
|
||||
- “当然,这里是改写结果:”
|
||||
- “优化如下:”
|
||||
|
||||
建议后端加一层轻量清洗:
|
||||
|
||||
- 去掉常见说明性前缀
|
||||
- 去掉包裹性 markdown 代码块
|
||||
- 去掉多余首尾空白
|
||||
|
||||
注意:
|
||||
|
||||
- 清洗必须保守
|
||||
- 不要误删正文
|
||||
|
||||
---
|
||||
|
||||
## 18. 日志与观测建议
|
||||
|
||||
建议记录:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `selection_length`
|
||||
- `before_length`
|
||||
- `after_length`
|
||||
- `model_name`
|
||||
- `latency_ms`
|
||||
- `success / fail`
|
||||
|
||||
不要直接把整段用户正文原文打到普通日志里,避免敏感信息风险。
|
||||
|
||||
---
|
||||
|
||||
## 19. 后端测试清单
|
||||
|
||||
## 19.1 单元测试
|
||||
|
||||
- action 校验
|
||||
- tone 校验
|
||||
- 空选区校验
|
||||
- prompt 选择逻辑
|
||||
- 输出清洗逻辑
|
||||
|
||||
## 19.2 集成测试
|
||||
|
||||
- `polish`
|
||||
- `expand`
|
||||
- `shorten`
|
||||
- `tone-professional`
|
||||
- `tone-casual`
|
||||
- `tone-readable`
|
||||
- `tone-academic`
|
||||
- `tone-expressive`
|
||||
|
||||
## 19.3 边界测试
|
||||
|
||||
- 选区过短
|
||||
- 选区过长
|
||||
- 前后文为空
|
||||
- 多段文本带换行
|
||||
- 模型超时
|
||||
- 模型不可用
|
||||
|
||||
---
|
||||
|
||||
## 20. 联调约束
|
||||
|
||||
## 20.1 前后端必须统一
|
||||
|
||||
- `action` 枚举值
|
||||
- `tone` 枚举值
|
||||
- 错误码
|
||||
- 返回体字段名
|
||||
|
||||
## 20.2 联调重点
|
||||
|
||||
- 多段落文本中换行保留是否正确
|
||||
- AI 返回内容是否适合直接落文档
|
||||
- 超时和失败时前端是否能优雅重试
|
||||
|
||||
## 20.3 联调建议顺序
|
||||
|
||||
1. 先 mock 返回固定文本
|
||||
2. 再接真实模型
|
||||
3. 先验证单段落
|
||||
4. 再验证多段落
|
||||
5. 最后验证大段文本
|
||||
|
||||
---
|
||||
|
||||
## 21. 优先级建议
|
||||
|
||||
### P0
|
||||
|
||||
- 统一 rewrite API
|
||||
- 4 类动作支持
|
||||
- 输出纯净正文
|
||||
- 错误结构标准化
|
||||
|
||||
### P1
|
||||
|
||||
- 输出清洗增强
|
||||
- 埋点与日志
|
||||
- 上下文截断策略优化
|
||||
|
||||
### P2
|
||||
|
||||
- 更细粒度风格控制
|
||||
- 结果质量二次校验
|
||||
|
||||
---
|
||||
|
||||
## 22. 完成定义
|
||||
|
||||
后端满足以下条件即可视为本轮完成:
|
||||
|
||||
- 前端能通过统一接口发起改写
|
||||
- 4 类动作均可稳定返回候选文本
|
||||
- 返回文本基本可直接用于替换或插入
|
||||
- 多段落文本能稳定处理
|
||||
- 错误结构规范统一
|
||||
|
||||
---
|
||||
|
||||
## 23. 结论
|
||||
|
||||
本轮后端最重要的,不是做复杂文档结构编辑,而是把“选区改写”做成稳定、可预测、可直接接入前端编辑器的能力。
|
||||
|
||||
最稳妥的路线是:
|
||||
|
||||
- 统一接口
|
||||
- 统一动作枚举
|
||||
- 统一 prompt 模板
|
||||
- 统一错误结构
|
||||
- 统一输出约束
|
||||
|
||||
只要这层做稳,前端的 Tiptap 写作编辑器就能很顺畅地完成豆包式“选中即改、预览后应用”的核心体验。
|
||||
@@ -0,0 +1,703 @@
|
||||
# AI 写作助手前端开发任务清单
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把《AI 写作助手右侧编辑器升级需求文档》进一步拆成前端可直接执行的任务清单。
|
||||
|
||||
目标是让前端团队按模块并行推进,最终完成:
|
||||
|
||||
- 保留左侧聊天区
|
||||
- 右侧升级为 `Tiptap` 写作编辑器
|
||||
- 支持选区浮层
|
||||
- 支持润色 / 扩写 / 缩写 / 改语气
|
||||
- 支持替换 / 插入
|
||||
- 支持标题 / 列表 / 引用 / 代码块工具条
|
||||
- UI 参考豆包 + Novel + open-canvas
|
||||
|
||||
---
|
||||
|
||||
## 2. 本轮前端改造范围
|
||||
|
||||
### 2.1 保留不动
|
||||
|
||||
- 左侧聊天主链路
|
||||
- 现有线程消息流
|
||||
- 现有模型选择器
|
||||
- 现有输入框的 `writing_mode` 总入口
|
||||
|
||||
### 2.2 重点改造
|
||||
|
||||
- 右侧 Artifact 面板承载方式
|
||||
- 新增独立写作工作区
|
||||
- 富文本编辑器内核
|
||||
- 选区浮层
|
||||
- AI 改写结果预览
|
||||
- 文档级工具条
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐目录结构
|
||||
|
||||
建议新增独立模块目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas-writing/
|
||||
routes/
|
||||
WritingRoutes.tsx
|
||||
pages/
|
||||
WritingWorkspacePage.tsx
|
||||
components/
|
||||
WritingShell.tsx
|
||||
WritingHeader.tsx
|
||||
WritingToolbar.tsx
|
||||
WritingEditorPane.tsx
|
||||
WritingBubbleMenu.tsx
|
||||
WritingAiMenu.tsx
|
||||
WritingRewritePreview.tsx
|
||||
WritingStatusBar.tsx
|
||||
WritingEmptyState.tsx
|
||||
editor/
|
||||
TiptapEditor.tsx
|
||||
TiptapExtensions.ts
|
||||
commands.ts
|
||||
selection.ts
|
||||
context/
|
||||
WritingEditorContext.tsx
|
||||
hooks/
|
||||
useWritingEditor.ts
|
||||
useWritingSelection.ts
|
||||
useWritingRewrite.ts
|
||||
useWritingToolbar.ts
|
||||
api/
|
||||
rewrite.ts
|
||||
documents.ts
|
||||
types/
|
||||
document.ts
|
||||
rewrite.ts
|
||||
utils/
|
||||
apply-rewrite.ts
|
||||
markdown.ts
|
||||
editor-style.ts
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 现有代码落点
|
||||
|
||||
## 4.1 需要接入的现有文件
|
||||
|
||||
- `frontend-web/src/pages/PageRoutes.tsx`
|
||||
- 增加新写作路由入口
|
||||
|
||||
- `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
- 当前双栏布局参考来源
|
||||
- 后续决定是否复用布局容器,或在新页面中重建双栏
|
||||
|
||||
- `frontend-web/src/components/workspace/input-box.tsx`
|
||||
- 保留 `writing_mode`
|
||||
- 后续可在切换写作模式时跳转到新写作页面
|
||||
|
||||
- `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
- 仅保留给旧 Artifact 逻辑
|
||||
- 不要继续承载写作编辑器状态
|
||||
|
||||
## 4.2 不建议继续沿用的方式
|
||||
|
||||
- 不要把编辑器主状态存成 `string[] artifacts`
|
||||
- 不要把右侧主体验继续建在 `artifact-file-detail.tsx` 上
|
||||
- 不要让 AI 改写结果先回聊天,再由用户手动复制
|
||||
|
||||
---
|
||||
|
||||
## 5. 任务分阶段拆解
|
||||
|
||||
## 阶段 A:路由与页面壳
|
||||
|
||||
### 目标
|
||||
|
||||
先把新的写作工作区页面立起来,让后续编辑器与交互都有落点。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRoutes.tsx`
|
||||
- 新增 `WritingWorkspacePage.tsx`
|
||||
- 在 `PageRoutes.tsx` 中挂入 `page/writing/*`
|
||||
- 确认新页面进入方式
|
||||
- 确定是:
|
||||
- 通过左侧聊天上下文进入
|
||||
- 通过 `writing_mode` 入口进入
|
||||
- 或二者都支持
|
||||
|
||||
### 交付物
|
||||
|
||||
- 新写作页面能被路由访问
|
||||
- 页面有基础左右布局和空态
|
||||
|
||||
### 验收
|
||||
|
||||
- 打开 `page/writing` 能进入工作区
|
||||
- 页面不报错
|
||||
|
||||
---
|
||||
|
||||
## 阶段 B:编辑器底座
|
||||
|
||||
### 目标
|
||||
|
||||
把右侧正式替换成富文本编辑器。
|
||||
|
||||
### 任务
|
||||
|
||||
- 安装 Tiptap 依赖
|
||||
- 建立 `TiptapEditor.tsx`
|
||||
- 建立 `TiptapExtensions.ts`
|
||||
- 配置节点:
|
||||
- paragraph
|
||||
- heading
|
||||
- bulletList
|
||||
- orderedList
|
||||
- listItem
|
||||
- blockquote
|
||||
- codeBlock
|
||||
- hardBreak
|
||||
- 配置 marks:
|
||||
- bold
|
||||
- italic
|
||||
- underline
|
||||
- strike
|
||||
- code
|
||||
- 建立编辑器初始化逻辑
|
||||
- 建立编辑器销毁逻辑
|
||||
- 定义最小文档状态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 可编辑正文区域
|
||||
- 初始文档可加载
|
||||
- 内容可读可写
|
||||
|
||||
### 验收
|
||||
|
||||
- 可以直接输入正文
|
||||
- 可以换行、加标题、切换列表
|
||||
- 不出现内容错位和明显闪烁
|
||||
|
||||
---
|
||||
|
||||
## 阶段 C:主工具栏
|
||||
|
||||
### 目标
|
||||
|
||||
右侧具备正式写作工具栏。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingToolbar.tsx`
|
||||
- 新增 `useWritingToolbar.ts`
|
||||
- 实现按钮:
|
||||
- 正文
|
||||
- H1
|
||||
- H2
|
||||
- H3
|
||||
- 有序列表
|
||||
- 无序列表
|
||||
- 引用
|
||||
- 代码块
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 下划线
|
||||
- 删除线
|
||||
- 处理 active 状态
|
||||
- 处理不可用状态
|
||||
- 处理光标块状态联动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 主工具栏可用
|
||||
|
||||
### 验收
|
||||
|
||||
- 工具条按钮能正确反映当前块状态
|
||||
- 操作后编辑器内容正确变化
|
||||
|
||||
---
|
||||
|
||||
## 阶段 D:选区状态与浮层
|
||||
|
||||
### 目标
|
||||
|
||||
实现豆包式选区浮层基础能力。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `useWritingSelection.ts`
|
||||
- 新增 `selection.ts`
|
||||
- 新增 `WritingBubbleMenu.tsx`
|
||||
- 基于 Tiptap 监听 selection 变化
|
||||
- 过滤掉空选区
|
||||
- 处理多段连续选区
|
||||
- 浮层按钮:
|
||||
- AI 改写
|
||||
- 加粗
|
||||
- 斜体
|
||||
- 标题
|
||||
- 复制
|
||||
- 处理滚动定位
|
||||
- 处理窗口尺寸变化
|
||||
|
||||
### 交付物
|
||||
|
||||
- 选中内容后出现浮层
|
||||
|
||||
### 验收
|
||||
|
||||
- 单段可触发
|
||||
- 多段可触发
|
||||
- 浮层位置稳定
|
||||
|
||||
---
|
||||
|
||||
## 阶段 E:AI 改写菜单
|
||||
|
||||
### 目标
|
||||
|
||||
把选区浮层接到 AI 改写工作流。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingAiMenu.tsx`
|
||||
- 新增 `useWritingRewrite.ts`
|
||||
- 新增 `api/rewrite.ts`
|
||||
- 实现一级动作:
|
||||
- 润色
|
||||
- 扩写
|
||||
- 缩写
|
||||
- 改语气
|
||||
- 实现改语气二级菜单:
|
||||
- 更专业
|
||||
- 更口语化
|
||||
- 更易读
|
||||
- 更学术
|
||||
- 更有文采
|
||||
- 封装请求入参
|
||||
- 增加 loading 态
|
||||
- 增加 error 态
|
||||
- 增加 retry 入口
|
||||
|
||||
### 交付物
|
||||
|
||||
- AI 改写菜单和请求链路
|
||||
|
||||
### 验收
|
||||
|
||||
- 4 个动作都能发请求
|
||||
- 改语气的二级菜单可正常展开
|
||||
- 请求失败时有反馈
|
||||
|
||||
---
|
||||
|
||||
## 阶段 F:候选结果预览
|
||||
|
||||
### 目标
|
||||
|
||||
把 AI 结果变成“可预览、可决策、可回退”的候选内容。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingRewritePreview.tsx`
|
||||
- 新增 `apply-rewrite.ts`
|
||||
- 显示原文与候选内容
|
||||
- 提供按钮:
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
- 用 transaction 应用结果
|
||||
- 处理应用成功后的焦点与滚动
|
||||
|
||||
### 交付物
|
||||
|
||||
- 改写结果预览卡片或面板
|
||||
|
||||
### 验收
|
||||
|
||||
- 替换准确
|
||||
- 插入准确
|
||||
- 取消后文档保持原样
|
||||
|
||||
---
|
||||
|
||||
## 阶段 G:文档状态与容器体验
|
||||
|
||||
### 目标
|
||||
|
||||
补齐正式产品的壳层体验。
|
||||
|
||||
### 任务
|
||||
|
||||
- 新增 `WritingShell.tsx`
|
||||
- 新增 `WritingHeader.tsx`
|
||||
- 新增 `WritingStatusBar.tsx`
|
||||
- 新增 `WritingEmptyState.tsx`
|
||||
- 补充文档标题区域
|
||||
- 补充文档状态提示
|
||||
- 补充空态和加载态
|
||||
|
||||
### 交付物
|
||||
|
||||
- 页面完整壳层
|
||||
|
||||
### 验收
|
||||
|
||||
- 页面不是裸编辑器
|
||||
- 有完整的文档工作区感
|
||||
|
||||
---
|
||||
|
||||
## 阶段 H:视觉打磨
|
||||
|
||||
### 目标
|
||||
|
||||
让整体观感接近成熟产品。
|
||||
|
||||
### 任务
|
||||
|
||||
- 调整右侧阅读宽度
|
||||
- 调整字体大小和行高
|
||||
- 调整段距和标题层级
|
||||
- 调整工具栏阴影、边框、圆角
|
||||
- 调整浮层动效
|
||||
- 调整 AI 预览卡片质感
|
||||
- 优化深浅色模式兼容
|
||||
|
||||
### 交付物
|
||||
|
||||
- 完整视觉优化
|
||||
|
||||
### 验收
|
||||
|
||||
- 明显优于当前版本
|
||||
- 有豆包 / Novel 风格的编辑器质感
|
||||
|
||||
---
|
||||
|
||||
## 6. 组件级开发清单
|
||||
|
||||
## 6.1 `WritingWorkspacePage.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面级容器
|
||||
- 获取线程 / 文档上下文
|
||||
- 组织左右布局
|
||||
|
||||
必须完成:
|
||||
|
||||
- 页面初始化
|
||||
- 与左侧聊天协同
|
||||
- 右侧编辑区挂载
|
||||
|
||||
## 6.2 `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 写作工作区总壳层
|
||||
- 布局标题区、工具栏、正文区、状态区
|
||||
|
||||
必须完成:
|
||||
|
||||
- 自适应布局
|
||||
- 桌面端优先
|
||||
- 窄屏降级
|
||||
|
||||
## 6.3 `TiptapEditor.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 包装 Tiptap EditorContent
|
||||
- 负责 editor 实例初始化
|
||||
|
||||
必须完成:
|
||||
|
||||
- `onUpdate`
|
||||
- `onSelectionUpdate`
|
||||
- 内容同步
|
||||
|
||||
## 6.4 `WritingToolbar.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 顶部主工具栏
|
||||
|
||||
必须完成:
|
||||
|
||||
- 所有基础格式化命令
|
||||
- 当前状态高亮
|
||||
|
||||
## 6.5 `WritingBubbleMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 选区浮层
|
||||
|
||||
必须完成:
|
||||
|
||||
- 跟随选区
|
||||
- 基础快捷操作
|
||||
- AI 改写入口
|
||||
|
||||
## 6.6 `WritingAiMenu.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- AI 改写动作菜单
|
||||
|
||||
必须完成:
|
||||
|
||||
- 一级菜单
|
||||
- 改语气二级菜单
|
||||
- 动作触发
|
||||
|
||||
## 6.7 `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 展示候选内容
|
||||
|
||||
必须完成:
|
||||
|
||||
- 替换
|
||||
- 插入
|
||||
- 重试
|
||||
- 取消
|
||||
|
||||
---
|
||||
|
||||
## 7. Hook 级开发清单
|
||||
|
||||
## 7.1 `useWritingEditor.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- editor 生命周期
|
||||
- 初始内容装载
|
||||
- 内容变更同步
|
||||
|
||||
## 7.2 `useWritingSelection.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 当前选区抽取
|
||||
- 选区文本缓存
|
||||
- 多段落纯文本拼接
|
||||
|
||||
## 7.3 `useWritingRewrite.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- 改写请求
|
||||
- loading / success / error
|
||||
- 结果缓存
|
||||
- 重试
|
||||
|
||||
## 7.4 `useWritingToolbar.ts`
|
||||
|
||||
负责:
|
||||
|
||||
- toolbar active 状态
|
||||
- editor command 映射
|
||||
|
||||
---
|
||||
|
||||
## 8. API 对接清单
|
||||
|
||||
前端需向后端约定以下接口能力:
|
||||
|
||||
- `POST /api/writing/rewrite`
|
||||
|
||||
前端请求内容至少包括:
|
||||
|
||||
- `threadId`
|
||||
- `documentId`
|
||||
- `selection.from`
|
||||
- `selection.to`
|
||||
- `selection.text`
|
||||
- `action`
|
||||
- `tone`
|
||||
- `surroundingContext.before`
|
||||
- `surroundingContext.after`
|
||||
|
||||
前端需处理的返回字段:
|
||||
|
||||
- `output`
|
||||
- `originalSelection`
|
||||
- `metadata`
|
||||
|
||||
---
|
||||
|
||||
## 9. 状态管理建议
|
||||
|
||||
建议新增独立 Context:
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
建议持有:
|
||||
|
||||
- 当前文档内容
|
||||
- 当前 editor 实例
|
||||
- 当前选区
|
||||
- 当前改写请求状态
|
||||
- 当前候选结果
|
||||
- 当前 UI 面板状态
|
||||
|
||||
不建议继续复用旧 Artifact Context 的原因:
|
||||
|
||||
- 旧 Context 关注文件列表和文件路径
|
||||
- 新编辑器关注选区、结构、候选结果、应用动作
|
||||
- 关注点完全不同
|
||||
|
||||
---
|
||||
|
||||
## 10. 前端联调检查点
|
||||
|
||||
## 10.1 与后端联调前
|
||||
|
||||
- 先用 mock 数据打通浮层和预览
|
||||
- 先验证替换 / 插入逻辑
|
||||
|
||||
## 10.2 与后端联调时
|
||||
|
||||
- 检查多段落选区拼接结果
|
||||
- 检查 AI 返回内容是否带说明性前缀
|
||||
- 检查替换后位置是否准确
|
||||
|
||||
## 10.3 联调后
|
||||
|
||||
- 验证大段文本性能
|
||||
- 验证撤销 / 重做
|
||||
- 验证快速连续触发是否会状态错乱
|
||||
|
||||
---
|
||||
|
||||
## 11. 前端测试清单
|
||||
|
||||
## 11.1 功能测试
|
||||
|
||||
- 输入正文
|
||||
- 插入标题
|
||||
- 切换列表
|
||||
- 插入引用
|
||||
- 插入代码块
|
||||
- 加粗 / 斜体 / 下划线 / 删除线
|
||||
- 选区浮层显示
|
||||
- 选区浮层隐藏
|
||||
- AI 改写菜单打开
|
||||
- 改语气二级菜单打开
|
||||
- 改写结果替换
|
||||
- 改写结果插入
|
||||
- 改写结果取消
|
||||
|
||||
## 11.2 场景测试
|
||||
|
||||
- 单段短文本润色
|
||||
- 多段文本润色
|
||||
- 多段文本扩写
|
||||
- 多段文本缩写
|
||||
- 多段文本改语气
|
||||
- 连续两次改写同一段
|
||||
- 快速切换线程后重新进入
|
||||
|
||||
## 11.3 回归测试
|
||||
|
||||
- 左侧聊天是否正常
|
||||
- 模型切换是否正常
|
||||
- `writing_mode` 是否正常
|
||||
- 原有 artifact 模块是否仍可使用
|
||||
|
||||
---
|
||||
|
||||
## 12. 任务优先级
|
||||
|
||||
### P0
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
- 选区浮层
|
||||
- 改写菜单
|
||||
- 替换 / 插入
|
||||
|
||||
### P1
|
||||
|
||||
- 页面头部与状态
|
||||
- 优雅 loading / error / retry
|
||||
- 多段落稳定性
|
||||
- UI 打磨
|
||||
|
||||
### P2
|
||||
|
||||
- 空态优化
|
||||
- 动效优化
|
||||
- 快捷键支持
|
||||
|
||||
---
|
||||
|
||||
## 13. 建议分工
|
||||
|
||||
### 前端 1 号
|
||||
|
||||
负责:
|
||||
|
||||
- 路由与页面壳
|
||||
- Tiptap 接入
|
||||
- 主工具栏
|
||||
|
||||
### 前端 2 号
|
||||
|
||||
负责:
|
||||
|
||||
- 选区状态
|
||||
- BubbleMenu
|
||||
- AI 菜单
|
||||
|
||||
### 前端 3 号
|
||||
|
||||
负责:
|
||||
|
||||
- 候选结果预览
|
||||
- 替换 / 插入
|
||||
- 状态栏与页面体验
|
||||
|
||||
### 联调负责人
|
||||
|
||||
负责:
|
||||
|
||||
- 前后端请求协议
|
||||
- 错误态规范
|
||||
- 多段落测试和回归
|
||||
|
||||
---
|
||||
|
||||
## 14. 前端完成定义
|
||||
|
||||
满足以下条件可视为前端完成:
|
||||
|
||||
- 右侧能作为正式编辑器独立使用
|
||||
- 选区后可直接发起 AI 改写
|
||||
- 改写后可替换或插入
|
||||
- 视觉上明显优于当前版本
|
||||
- 左侧聊天和现有系统不被破坏
|
||||
|
||||
---
|
||||
|
||||
## 15. 下一步建议
|
||||
|
||||
前端团队可以按以下顺序开工:
|
||||
|
||||
1. 先起页面和路由
|
||||
2. 再接 Tiptap 与主工具栏
|
||||
3. 再做选区浮层
|
||||
4. 再接 AI 菜单和结果预览
|
||||
5. 最后做视觉打磨
|
||||
|
||||
这个顺序风险最小,也最容易在中途验收。
|
||||
@@ -0,0 +1,457 @@
|
||||
# AI 写作助手源码与实现映射表
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于把以下几类信息对应起来:
|
||||
|
||||
- 豆包 AI 写作助手里要借鉴的交互
|
||||
- Novel / open-canvas / Tiptap 中可参考的实现思路
|
||||
- DeerFlow 当前项目里已经存在的落点
|
||||
- 本次新建模块应该落到哪里
|
||||
|
||||
这份文档不是源码复制文档,而是开发时的“对照表”和“导航图”。
|
||||
|
||||
---
|
||||
|
||||
## 2. 使用方式
|
||||
|
||||
开发时建议按以下顺序看:
|
||||
|
||||
1. 先看“目标能力”
|
||||
2. 再看“参考来源”
|
||||
3. 再看“DeerFlow 当前落点”
|
||||
4. 最后看“本次目标文件”
|
||||
|
||||
这样可以避免出现两种常见问题:
|
||||
|
||||
- 直接照搬外部项目结构,结果和你们现有架构冲突
|
||||
- 只看自己项目,不知道成熟产品交互应该怎么落
|
||||
|
||||
---
|
||||
|
||||
## 3. 总体映射
|
||||
|
||||
| 目标能力 | 产品参考 | 外部参考实现 | DeerFlow 当前落点 | 本次目标文件 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 写作工作区路由 | open-canvas 独立页面 | open-canvas 路由组织思路 | `frontend-web/src/pages/PageRoutes.tsx` | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` |
|
||||
| 左右双栏工作区 | 豆包写作页 / open-canvas | open-canvas 页面壳思路 | `frontend-web/src/components/workspace/chats/chat-box.tsx` | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`、`WritingShell.tsx` |
|
||||
| 富文本正文编辑 | Novel | Tiptap 官方编辑器模式 | 当前无正式富文本正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` |
|
||||
| 文档工具栏 | 豆包 / Novel | Tiptap Toolbar 组合思路 | 当前右侧无正式工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` |
|
||||
| 选区浮层 | 豆包 | Tiptap BubbleMenu | 当前右侧无选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` |
|
||||
| AI 改写菜单 | 豆包 | 自定义菜单 + BubbleMenu 挂接 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` |
|
||||
| 候选结果预览 | 豆包 / open-canvas | open-canvas 候选应用思路 | 当前无 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` |
|
||||
| 替换 / 插入 | 豆包 | Tiptap transaction 应用 | 当前无 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` |
|
||||
| 写作模式总入口 | 当前 DeerFlow | 现有 `writing_mode` 开关 | `frontend-web/src/components/workspace/input-box.tsx` | 保留现有入口并接新页面 |
|
||||
| 线程与模型协同 | 当前 DeerFlow | 现有线程 / 模型体系 | 现有 `core/threads`、`core/models` | 继续复用,新增 `api/rewrite.ts` |
|
||||
|
||||
---
|
||||
|
||||
## 4. 豆包功能到 DeerFlow 的映射
|
||||
|
||||
## 4.1 滑词 / 选区浮层
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 选中文本后出现轻量浮层
|
||||
- 浮层里有:
|
||||
- AI 改写
|
||||
- 文本样式
|
||||
- 复制
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 右侧还没有正式的选区级工具层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 的 `BubbleMenu`
|
||||
- 再叠加自定义菜单逻辑
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/selection.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.2 多段落改写
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 不是只改单句
|
||||
- 能对连续几段一起润色、扩写、缩写、改语气
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 现有右侧编辑区没有正式的多段落选区改写模型
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 前端提取连续选区纯文本
|
||||
- 前端附带前后文
|
||||
- 后端返回候选纯文本
|
||||
- 前端决定替换或插入
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingSelection.ts`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingRewrite.ts`
|
||||
- `frontend-web/src/open-canvas-writing/api/rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.3 替换 / 插入
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 改写结果不是直接覆盖原文
|
||||
- 用户可选择:
|
||||
- 替换
|
||||
- 插入
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前没有候选文本应用层
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 建立候选结果预览区
|
||||
- 基于 Tiptap transaction 应用文本
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts`
|
||||
|
||||
---
|
||||
|
||||
## 4.4 标题 / 列表 / 引用 / 代码块工具条
|
||||
|
||||
### 豆包里的体验
|
||||
|
||||
- 右侧本身就是正式文档编辑器
|
||||
- 可以快速切换文档结构
|
||||
|
||||
### DeerFlow 当前状态
|
||||
|
||||
- 当前右侧偏预览,不是正式写作编辑器
|
||||
|
||||
### 本次实现方式
|
||||
|
||||
- 使用 Tiptap 节点和命令
|
||||
- 在主工具栏中集中承载
|
||||
|
||||
### 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/hooks/useWritingToolbar.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
|
||||
---
|
||||
|
||||
## 5. Novel 参考映射
|
||||
|
||||
## 5.1 最值得借鉴的不是“代码整搬”,而是这三件事
|
||||
|
||||
- 编辑器主容器的产品感
|
||||
- 工具栏和空态的组织方式
|
||||
- 正文阅读宽度、排版、留白
|
||||
|
||||
## 5.2 对应到 DeerFlow 的实现位置
|
||||
|
||||
- 页面壳:`WritingShell.tsx`
|
||||
- 顶部文档区:`WritingHeader.tsx`
|
||||
- 编辑容器:`TiptapEditor.tsx`
|
||||
- 工具栏:`WritingToolbar.tsx`
|
||||
- 空态:`WritingEmptyState.tsx`
|
||||
|
||||
## 5.3 不建议直接照搬的部分
|
||||
|
||||
- Next.js 页面结构
|
||||
- 依赖其服务端 AI 调用方式
|
||||
- 与 Vercel AI SDK 深绑定的部分
|
||||
|
||||
---
|
||||
|
||||
## 6. open-canvas 参考映射
|
||||
|
||||
## 6.1 主要借鉴对象
|
||||
|
||||
open-canvas 这次最值得借鉴的不是页面外观,而是:
|
||||
|
||||
- AI 工作流组织方式
|
||||
- 改写结果应用节奏
|
||||
- 编辑器和聊天协作的模式
|
||||
|
||||
## 6.2 借鉴重点
|
||||
|
||||
### 借鉴 1:独立工作区思想
|
||||
|
||||
不要把写作体验塞进一个零散的弹窗或文件详情页,要有独立工作区。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx`
|
||||
|
||||
### 借鉴 2:画布状态独立维护
|
||||
|
||||
不要让写作状态完全依附聊天消息渲染结果,要独立维护文档和候选状态。
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx`
|
||||
|
||||
### 借鉴 3:AI 输出先预览,再应用
|
||||
|
||||
对应 DeerFlow:
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 7. Tiptap 参考映射
|
||||
|
||||
## 7.1 Tiptap 在本项目中的职责
|
||||
|
||||
- 作为右侧正式富文本编辑器内核
|
||||
- 提供结构节点与 marks
|
||||
- 提供 BubbleMenu 能力
|
||||
- 提供 transaction 应用能力
|
||||
|
||||
## 7.2 建议直接参考的实现点
|
||||
|
||||
- `EditorContent` 的封装模式
|
||||
- `BubbleMenu` 的接入方式
|
||||
- heading / list / blockquote / codeBlock 扩展配置
|
||||
- `editor.chain().focus()` 命令调用模式
|
||||
- selection 变化监听
|
||||
|
||||
## 7.3 DeerFlow 本次落点
|
||||
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx`
|
||||
- `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts`
|
||||
- `frontend-web/src/open-canvas-writing/editor/commands.ts`
|
||||
- `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8. DeerFlow 现有文件到新模块的映射
|
||||
|
||||
## 8.1 `frontend-web/src/pages/PageRoutes.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 汇总页面路由
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 挂入新的写作助手路由
|
||||
|
||||
### 后续动作
|
||||
|
||||
- 引入 `WritingRoutes`
|
||||
- 增加 `page/writing/*`
|
||||
|
||||
---
|
||||
|
||||
## 8.2 `frontend-web/src/components/workspace/chats/chat-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 左聊天 + 右 Artifact 双栏布局
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 提供双栏工作区组织思路
|
||||
- 可复用布局比例和 panel 交互经验
|
||||
|
||||
### 不建议直接复用的部分
|
||||
|
||||
- 不要继续让右侧核心能力挂在 ArtifactFileDetail 上
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 8.3 `frontend-web/src/components/workspace/artifacts/context.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 管理文件型 artifact 列表、选中项和开关状态
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 仅作为旧 Artifact 模块保留
|
||||
|
||||
### 本次目标替代
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
|
||||
### 原因
|
||||
|
||||
- 写作编辑器需要的不是文件路径状态
|
||||
- 而是文档状态、选区状态、候选结果状态、工具层状态
|
||||
|
||||
---
|
||||
|
||||
## 8.4 `frontend-web/src/components/workspace/input-box.tsx`
|
||||
|
||||
### 当前职责
|
||||
|
||||
- 聊天输入区
|
||||
- 已有 `writing_mode` 入口
|
||||
|
||||
### 本次作用
|
||||
|
||||
- 保留为用户进入写作模式的现有入口
|
||||
|
||||
### 本次改造建议
|
||||
|
||||
- 写作模式打开时,跳转或唤起新的写作工作区
|
||||
- 不再只停留在“生成 Markdown 文件”这个层面
|
||||
|
||||
---
|
||||
|
||||
## 9. 新模块间的依赖关系
|
||||
|
||||
## 9.1 页面层
|
||||
|
||||
- `WritingRoutes.tsx`
|
||||
- `WritingWorkspacePage.tsx`
|
||||
- `WritingShell.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 页面路由
|
||||
- 双栏或工作区布局
|
||||
- 上下文装配
|
||||
|
||||
## 9.2 编辑器层
|
||||
|
||||
- `TiptapEditor.tsx`
|
||||
- `TiptapExtensions.ts`
|
||||
- `commands.ts`
|
||||
- `selection.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 编辑器内核
|
||||
- 节点和 marks
|
||||
- 基础命令
|
||||
- 选区抽取
|
||||
|
||||
## 9.3 交互层
|
||||
|
||||
- `WritingToolbar.tsx`
|
||||
- `WritingBubbleMenu.tsx`
|
||||
- `WritingAiMenu.tsx`
|
||||
- `WritingRewritePreview.tsx`
|
||||
|
||||
职责:
|
||||
|
||||
- 工具栏
|
||||
- 浮层
|
||||
- AI 菜单
|
||||
- 候选内容应用
|
||||
|
||||
## 9.4 状态层
|
||||
|
||||
- `WritingEditorContext.tsx`
|
||||
- `useWritingEditor.ts`
|
||||
- `useWritingSelection.ts`
|
||||
- `useWritingRewrite.ts`
|
||||
- `useWritingToolbar.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 主状态
|
||||
- 选区状态
|
||||
- 改写状态
|
||||
- 工具栏状态
|
||||
|
||||
## 9.5 API 层
|
||||
|
||||
- `api/rewrite.ts`
|
||||
- `api/documents.ts`
|
||||
|
||||
职责:
|
||||
|
||||
- 对接后端写作改写接口
|
||||
- 对接文档加载与保存接口
|
||||
|
||||
---
|
||||
|
||||
## 10. 能力到文件的精确映射
|
||||
|
||||
| 能力 | 新文件 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 新写作页面 | `frontend-web/src/open-canvas-writing/pages/WritingWorkspacePage.tsx` | 承载整个新模式 |
|
||||
| 写作路由 | `frontend-web/src/open-canvas-writing/routes/WritingRoutes.tsx` | 统一路由入口 |
|
||||
| 页面外壳 | `frontend-web/src/open-canvas-writing/components/WritingShell.tsx` | 页面布局与容器 |
|
||||
| 文档头部 | `frontend-web/src/open-canvas-writing/components/WritingHeader.tsx` | 标题、状态 |
|
||||
| 主工具栏 | `frontend-web/src/open-canvas-writing/components/WritingToolbar.tsx` | 文档结构与格式工具 |
|
||||
| 正文编辑器 | `frontend-web/src/open-canvas-writing/editor/TiptapEditor.tsx` | Tiptap 核心封装 |
|
||||
| 扩展配置 | `frontend-web/src/open-canvas-writing/editor/TiptapExtensions.ts` | 节点和 marks |
|
||||
| 选区浮层 | `frontend-web/src/open-canvas-writing/components/WritingBubbleMenu.tsx` | 选区出现的悬浮条 |
|
||||
| AI 菜单 | `frontend-web/src/open-canvas-writing/components/WritingAiMenu.tsx` | 润色、扩写等动作 |
|
||||
| 改写预览 | `frontend-web/src/open-canvas-writing/components/WritingRewritePreview.tsx` | 候选内容展示与应用 |
|
||||
| 替换 / 插入 | `frontend-web/src/open-canvas-writing/utils/apply-rewrite.ts` | 应用候选结果 |
|
||||
| 编辑器状态 | `frontend-web/src/open-canvas-writing/context/WritingEditorContext.tsx` | 独立上下文 |
|
||||
| 改写请求 | `frontend-web/src/open-canvas-writing/api/rewrite.ts` | 调后端接口 |
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发时的参考顺序
|
||||
|
||||
建议开发顺序如下:
|
||||
|
||||
1. 先看 `PageRoutes.tsx`,确定路由接法
|
||||
2. 再看 `chat-box.tsx`,理解当前左右布局
|
||||
3. 然后起 `WritingWorkspacePage.tsx` 和 `WritingShell.tsx`
|
||||
4. 再接 `TiptapEditor.tsx`
|
||||
5. 再做 `WritingToolbar.tsx`
|
||||
6. 再做 `WritingBubbleMenu.tsx`
|
||||
7. 再做 `WritingAiMenu.tsx`
|
||||
8. 最后做 `WritingRewritePreview.tsx`
|
||||
|
||||
这样可以让每一层都有稳定承载,不会出现“先做了 AI 菜单,但页面还没落点”的混乱情况。
|
||||
|
||||
---
|
||||
|
||||
## 12. 明确哪些是“参考”,哪些是“目标”
|
||||
|
||||
### 12.1 参考
|
||||
|
||||
- 豆包:交互体验参考
|
||||
- Novel:页面和编辑器质感参考
|
||||
- open-canvas:AI 工作流参考
|
||||
- Tiptap:编辑器技术实现参考
|
||||
|
||||
### 12.2 目标
|
||||
|
||||
目标不是复刻任何一个外部项目,而是:
|
||||
|
||||
- 在 DeerFlow 现有架构中
|
||||
- 用 React + Vite + 现有线程体系
|
||||
- 做出一个更成熟的 AI 写作编辑器模式
|
||||
|
||||
---
|
||||
|
||||
## 13. 结论
|
||||
|
||||
这份映射表的核心作用,是避免团队在开发时迷路。
|
||||
|
||||
真正推荐的姿势是:
|
||||
|
||||
- 用豆包定义目标体验
|
||||
- 用 Novel 定义页面质感
|
||||
- 用 open-canvas 定义 AI 工作流
|
||||
- 用 Tiptap 作为编辑器内核
|
||||
- 用 DeerFlow 现有路由、线程、模型体系做宿主
|
||||
|
||||
这样推进,既不会脱离你们现有工程,也能尽量快地把右侧编辑区拉到更接近成熟产品的水平。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,645 @@
|
||||
# AI 写作后端新路线设计草案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
|
||||
|
||||
- 不影响现有同事正在开发的功能
|
||||
- 不强耦合现有 DeerFlow 通用对话主链路
|
||||
- 可独立部署、独立调试、独立回滚
|
||||
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐建设方式
|
||||
|
||||
优先建议:
|
||||
|
||||
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
如果当前后端没有 `apps/` 结构,也可以采用:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
ai-writing-orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术路线建议
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 编排框架:`LangGraph`
|
||||
- 模型调用:接你们内网模型网关
|
||||
- 检索层:接内网知识库 / 文档库 / 搜索服务
|
||||
- 状态存储:先用数据库或 Redis + DB,后续再演进
|
||||
- 事件输出:SSE 或流式 HTTP
|
||||
|
||||
推荐原因:
|
||||
|
||||
- `LangGraph` 适合状态机和多节点回退
|
||||
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
|
||||
- 后续加人工确认节点和 checkpoint 会更自然
|
||||
|
||||
---
|
||||
|
||||
## 4. 分层设计
|
||||
|
||||
建议按四层拆分。
|
||||
|
||||
## 4.1 API 层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收前端请求
|
||||
- 鉴权
|
||||
- 参数校验
|
||||
- 返回 workflow 信息
|
||||
- 提供事件流出口
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
api/
|
||||
routes/
|
||||
workflows.py
|
||||
workflow_events.py
|
||||
schemas/
|
||||
workflow_request.py
|
||||
workflow_response.py
|
||||
```
|
||||
|
||||
## 4.2 Application 层
|
||||
|
||||
职责:
|
||||
|
||||
- 组织用例
|
||||
- 调度 workflow service
|
||||
- 管理人工确认动作
|
||||
- 处理重试和终止逻辑
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
application/
|
||||
services/
|
||||
workflow_service.py
|
||||
workflow_event_service.py
|
||||
human_action_service.py
|
||||
use_cases/
|
||||
create_workflow.py
|
||||
start_workflow.py
|
||||
confirm_outline.py
|
||||
rewrite_section.py
|
||||
approve_final.py
|
||||
```
|
||||
|
||||
## 4.3 Domain 层
|
||||
|
||||
职责:
|
||||
|
||||
- 定义工作流状态机
|
||||
- 定义 Agent 输入输出协议
|
||||
- 定义 artifact 与评分模型
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 4.4 Infrastructure 层
|
||||
|
||||
职责:
|
||||
|
||||
- 访问模型网关
|
||||
- 访问检索服务
|
||||
- 访问数据库
|
||||
- 发送事件
|
||||
- 缓存与日志
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
llm/
|
||||
model_client.py
|
||||
retrieval/
|
||||
intranet_search_client.py
|
||||
knowledge_base_client.py
|
||||
persistence/
|
||||
workflow_repository.py
|
||||
artifact_repository.py
|
||||
streaming/
|
||||
sse_publisher.py
|
||||
logging/
|
||||
workflow_logger.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作流最小闭环
|
||||
|
||||
第一版建议只做如下状态机:
|
||||
|
||||
1. `collect_materials`
|
||||
2. `generate_outline`
|
||||
3. `await_outline_confirmation`
|
||||
4. `write_draft`
|
||||
5. `review_draft`
|
||||
6. `rewrite_if_needed`
|
||||
7. `complete`
|
||||
|
||||
终止规则:
|
||||
|
||||
- 编辑评分达标则完成
|
||||
- 否则进入重写
|
||||
- 最多重写 2 次
|
||||
- 超过次数则标记 `human_intervention_required`
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent 模块设计
|
||||
|
||||
## 6.1 Researcher
|
||||
|
||||
输入:
|
||||
|
||||
- 主题
|
||||
- 用户要求
|
||||
- 检索范围
|
||||
- 可用资料源
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
程序职责:
|
||||
|
||||
- 先做检索
|
||||
- 再把检索结果裁剪后送给模型
|
||||
- 输出必须结构化
|
||||
|
||||
## 6.2 Outliner
|
||||
|
||||
输入:
|
||||
|
||||
- 用户要求
|
||||
- 研究结果
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
约束:
|
||||
|
||||
- 提纲优先,不写正文
|
||||
|
||||
## 6.3 Writer
|
||||
|
||||
输入:
|
||||
|
||||
- 已确认提纲
|
||||
- 素材卡片
|
||||
- 风格要求
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
建议:
|
||||
|
||||
- 按章节分段生成,不要一次长文直出
|
||||
|
||||
## 6.4 Editor
|
||||
|
||||
输入:
|
||||
|
||||
- 初稿
|
||||
- 提纲
|
||||
- 素材
|
||||
- 用户要求
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
规则:
|
||||
|
||||
- 先查事实,再查结构,再查文风
|
||||
|
||||
## 6.5 Supervisor
|
||||
|
||||
输入:
|
||||
|
||||
- 各阶段结果
|
||||
- 重试计数
|
||||
- 评分结果
|
||||
|
||||
输出:
|
||||
|
||||
- 下一跳节点
|
||||
- 是否需要人工确认
|
||||
- 是否结束
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt 组织建议
|
||||
|
||||
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 每个 Agent 一套职责
|
||||
- 每个 Agent 一套输出 schema
|
||||
- Prompt 版本可管理
|
||||
- 后续支持 A/B 调参
|
||||
|
||||
---
|
||||
|
||||
## 8. 弱模型适配策略
|
||||
|
||||
## 8.1 先检索后生成
|
||||
|
||||
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
|
||||
|
||||
## 8.2 短上下文任务化
|
||||
|
||||
每个节点只给弱模型一小段明确任务:
|
||||
|
||||
- 研究归纳
|
||||
- 提纲整理
|
||||
- 单章节写作
|
||||
- 单章节审稿
|
||||
|
||||
## 8.3 结构化输出
|
||||
|
||||
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
|
||||
|
||||
## 8.4 章节级重写
|
||||
|
||||
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
|
||||
|
||||
## 8.5 程序接管质量门槛
|
||||
|
||||
例如:
|
||||
|
||||
- 资料引用数不足,不进入下一步
|
||||
- 提纲章节数过少,直接重生
|
||||
- 编辑评分低于阈值,自动重写
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型建议
|
||||
|
||||
## 9.1 Workflow
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `status`
|
||||
- `current_stage`
|
||||
- `user_id`
|
||||
- `thread_id`
|
||||
- `input_payload`
|
||||
- `current_artifact_id`
|
||||
- `retry_count`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## 9.2 Workflow Artifact
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `artifact_type`
|
||||
- `stage`
|
||||
- `version`
|
||||
- `title`
|
||||
- `content`
|
||||
- `metadata`
|
||||
|
||||
## 9.3 Workflow Event
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `event_type`
|
||||
- `stage`
|
||||
- `agent_name`
|
||||
- `payload`
|
||||
- `created_at`
|
||||
|
||||
## 9.4 Human Action
|
||||
|
||||
建议字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `action_type`
|
||||
- `payload`
|
||||
- `operator`
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## 10. API 草案
|
||||
|
||||
## 10.1 创建任务
|
||||
|
||||
`POST /api/ai-writing/workflows`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "主题",
|
||||
"articleType": "行业分析",
|
||||
"audience": "管理层",
|
||||
"tone": "专业克制",
|
||||
"targetLength": 3000,
|
||||
"constraints": ["避免口语化"],
|
||||
"referenceScope": ["内网知识库", "项目复盘文档"]
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "wf_xxx",
|
||||
"status": "idle"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.2 启动任务
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/start`
|
||||
|
||||
## 10.3 查询任务
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}`
|
||||
|
||||
## 10.4 订阅事件
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}/events`
|
||||
|
||||
返回事件流示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stage_changed",
|
||||
"stage": "generating_outline",
|
||||
"message": "正在生成提纲"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.5 确认提纲
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
|
||||
## 10.6 指定章节重写
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
|
||||
## 10.7 确认终稿
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
---
|
||||
|
||||
## 11. LangGraph 实现建议
|
||||
|
||||
## 11.1 推荐 graph 节点
|
||||
|
||||
- `load_input`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
## 11.2 推荐 state
|
||||
|
||||
建议 state 字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `input_payload`
|
||||
- `materials`
|
||||
- `outline`
|
||||
- `outline_confirmed`
|
||||
- `draft_sections`
|
||||
- `merged_draft`
|
||||
- `review_result`
|
||||
- `rewrite_targets`
|
||||
- `final_article`
|
||||
- `retry_count`
|
||||
- `events`
|
||||
|
||||
## 11.3 条件边
|
||||
|
||||
建议条件边逻辑:
|
||||
|
||||
- 提纲未确认 -> 等待用户
|
||||
- 编辑通过 -> 完成
|
||||
- 编辑不通过且可重试 -> 重写
|
||||
- 编辑不通过且超限 -> 人工介入
|
||||
|
||||
---
|
||||
|
||||
## 12. 可观测性要求
|
||||
|
||||
第一版就建议带上以下能力:
|
||||
|
||||
- 每个 workflow 的事件日志
|
||||
- 每个 Agent 的输入输出摘要
|
||||
- 每次重试原因
|
||||
- 每个阶段耗时
|
||||
- 每次模型调用 token 与错误日志
|
||||
|
||||
后续排问题会非常依赖这些数据。
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试建议
|
||||
|
||||
## 13.1 单元测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- stage 流转
|
||||
- retry policy
|
||||
- quality policy
|
||||
- 事件构造
|
||||
|
||||
## 13.2 集成测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- 创建任务到完成的主链路
|
||||
- 提纲确认链路
|
||||
- 审稿失败后重写链路
|
||||
- 人工介入链路
|
||||
|
||||
## 13.3 Prompt 回归集
|
||||
|
||||
建议建立 10-20 个固定写作样例,做回归:
|
||||
|
||||
- 短文
|
||||
- 长文
|
||||
- 事实密集型文章
|
||||
- 宣传型文章
|
||||
- 分析型文章
|
||||
|
||||
---
|
||||
|
||||
## 14. 开发顺序建议
|
||||
|
||||
## Step 1:先做 domain 与 API 契约
|
||||
|
||||
先定:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- human action payload
|
||||
|
||||
## Step 2:先做 fake orchestrator
|
||||
|
||||
先不接真实模型,只模拟事件流,把前后端联调跑通。
|
||||
|
||||
## Step 3:接 Researcher + Outliner
|
||||
|
||||
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再接初稿和审稿重写。
|
||||
|
||||
## Step 5:接可观测性和缓存
|
||||
|
||||
最后再补生产化能力。
|
||||
|
||||
---
|
||||
|
||||
## 15. 风险控制建议
|
||||
|
||||
### 风险 1:直接复用现有线程流,导致协议污染
|
||||
|
||||
建议:
|
||||
|
||||
- workflow 用独立 route 和独立 event schema
|
||||
|
||||
### 风险 2:写作结果与右侧 artifact 模型不兼容
|
||||
|
||||
建议:
|
||||
|
||||
- 后端统一输出标准 markdown artifact
|
||||
|
||||
### 风险 3:弱模型频繁输出不合法 JSON
|
||||
|
||||
建议:
|
||||
|
||||
- 每个节点加 schema repair
|
||||
- 加轻量重试
|
||||
- 尽量减少复杂嵌套输出
|
||||
|
||||
### 风险 4:研究资料太多导致上下文爆炸
|
||||
|
||||
建议:
|
||||
|
||||
- 检索后先裁剪、摘要、分桶
|
||||
|
||||
---
|
||||
|
||||
## 16. 最终建议
|
||||
|
||||
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
|
||||
|
||||
建议 MVP 只保留:
|
||||
|
||||
1. Researcher
|
||||
2. Outliner
|
||||
3. Writer
|
||||
4. Editor
|
||||
5. Supervisor
|
||||
|
||||
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
|
||||
@@ -0,0 +1,806 @@
|
||||
# AI 写作执行方案
|
||||
|
||||
## 1. 总体原则
|
||||
|
||||
本项目的最终落地原则:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
|
||||
3. 编排框架采用 `LangGraph`
|
||||
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
|
||||
6. 前后端通过 `SSE` 传递工作流事件
|
||||
7. 第一版只做最小闭环,不做过多花哨扩展
|
||||
|
||||
一句话说明:
|
||||
|
||||
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目边界
|
||||
|
||||
## 2.1 前端边界
|
||||
|
||||
继续使用当前页面:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
前端做的事情:
|
||||
|
||||
- 左侧增加“写作工作流模式”
|
||||
- 展示多 Agent 执行进度与中间产物
|
||||
- 提供提纲确认和指定章节重写
|
||||
- 将初稿、终稿同步到右侧 artifact
|
||||
|
||||
前端不做的事情:
|
||||
|
||||
- 不重写右侧编辑器
|
||||
- 不重写现有普通 chat 流程
|
||||
- 不把 workflow 状态散落在多个旧 context 里
|
||||
|
||||
## 2.2 后端边界
|
||||
|
||||
后端采用独立新路线:
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
后端做的事情:
|
||||
|
||||
- 实现写作 workflow 状态机
|
||||
- 实现 A/B/C Agent 与 Supervisor
|
||||
- 接内网模型与内网检索
|
||||
- 输出标准事件流和文章 artifact
|
||||
|
||||
后端不做的事情:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把工作流逻辑塞到现有通用 thread 协议里
|
||||
|
||||
## 2.3 资料搜集边界
|
||||
|
||||
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
|
||||
|
||||
第一版要求:
|
||||
|
||||
- 研究员可以调用互联网搜索能力收集公开资料
|
||||
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
|
||||
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
|
||||
|
||||
第二版预留:
|
||||
|
||||
- 后续替换或并行接入内网搜索接口
|
||||
- 后续接入内部知识库、文档库、FAQ、项目复盘库
|
||||
- 前端和工作流层不因检索源变化而改协议
|
||||
|
||||
统一设计要求:
|
||||
|
||||
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
|
||||
- `writing_graph` 只读取标准化后的素材结果
|
||||
- API、事件流、artifact 结构不区分互联网资料和内网资料
|
||||
|
||||
推荐检索抽象:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
```
|
||||
|
||||
建议接口:
|
||||
|
||||
```python
|
||||
class RetrievalProvider(Protocol):
|
||||
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
|
||||
|
||||
class RetrievedDocument(BaseModel):
|
||||
title: str
|
||||
url: str | None
|
||||
source_type: str
|
||||
snippet: str
|
||||
published_at: str | None = None
|
||||
fetched_at: str
|
||||
reliability: str | None = None
|
||||
```
|
||||
|
||||
第一版路由策略:
|
||||
|
||||
- 默认 `internet`
|
||||
- 配置项预留 `intranet`、`hybrid`
|
||||
|
||||
例如:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
```
|
||||
|
||||
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一版产品目标
|
||||
|
||||
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
|
||||
|
||||
1. 输入主题、体裁、读者、风格、篇幅
|
||||
2. 研究员搜集资料并输出素材卡片
|
||||
3. 提纲 Agent 生成提纲
|
||||
4. 用户确认提纲
|
||||
5. Writer 生成初稿
|
||||
6. Editor 给出问题清单与评分
|
||||
7. 不达标则重写一次或两次
|
||||
8. 终稿写入右侧 artifact
|
||||
|
||||
第一版成功标准:
|
||||
|
||||
- 不是“生成得像最强模型一样好”
|
||||
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
|
||||
|
||||
## 3.1 第一版研究员目标
|
||||
|
||||
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
|
||||
|
||||
第一版输入:
|
||||
|
||||
- 用户主题
|
||||
- 用户写作要求
|
||||
- 检索源配置,第一版默认 `internet`
|
||||
|
||||
第一版输出至少包含:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
每条素材最少要带:
|
||||
|
||||
- 来源标题
|
||||
- 来源 URL
|
||||
- 摘要
|
||||
- 可信度标签
|
||||
- 被引用的原始片段或整理后的事实点
|
||||
|
||||
第一版 Researcher 的程序流程建议:
|
||||
|
||||
1. 根据主题自动生成 3-5 个检索 query
|
||||
2. 调用互联网检索接口拿回候选文档
|
||||
3. 做去重、裁剪、初筛
|
||||
4. 让模型将结果整理成结构化素材卡片
|
||||
5. 将结构化结果返回给 Outliner 和 Writer
|
||||
|
||||
第一版先不做:
|
||||
|
||||
- 深度网页 crawling
|
||||
- 多轮自动追问式深研究
|
||||
- 内网知识库接入
|
||||
- 复杂引用格式生成
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终工作流
|
||||
|
||||
## 4.1 Agent 角色
|
||||
|
||||
### `Researcher`
|
||||
|
||||
职责:
|
||||
|
||||
- 从配置的检索源检索信息
|
||||
- 产出结构化素材卡片
|
||||
- 标注来源、可信度、风险点
|
||||
|
||||
第一版说明:
|
||||
|
||||
- 默认走互联网公开资料检索
|
||||
- 必须预留后续切换到内网检索源的接口
|
||||
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
|
||||
|
||||
第一版检索源字段建议:
|
||||
|
||||
- `source_type = "internet"`
|
||||
- 后续预留 `source_type = "intranet" | "hybrid"`
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
### `Outliner`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户要求和研究结果生成提纲
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
### `Writer`
|
||||
|
||||
职责:
|
||||
|
||||
- 基于提纲和素材写初稿
|
||||
- 按章节写,不一次生成整篇长文
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
### `Editor`
|
||||
|
||||
职责:
|
||||
|
||||
- 检查事实、结构、文风
|
||||
- 给出评分与重写建议
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
### `Supervisor`
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程调度
|
||||
- 负责通过 / 重写 / 人工介入判断
|
||||
|
||||
## 4.2 状态机
|
||||
|
||||
第一版统一采用:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
## 4.3 流程闭环
|
||||
|
||||
最小闭环固定为:
|
||||
|
||||
1. `Research`
|
||||
2. `Outline`
|
||||
3. `HumanConfirmOutline`
|
||||
4. `Draft`
|
||||
5. `Review`
|
||||
6. `RewriteIfNeeded`
|
||||
7. `Complete`
|
||||
|
||||
重试规则:
|
||||
|
||||
- 最多重写 2 次
|
||||
- 达标则完成
|
||||
- 超限则进入人工介入
|
||||
|
||||
## 4.4 LangGraph 推荐节点
|
||||
|
||||
第一版推荐节点:
|
||||
|
||||
- `load_input`
|
||||
- `build_search_queries`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
其中和资料搜集直接相关的节点说明:
|
||||
|
||||
### `build_search_queries`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
|
||||
|
||||
### `retrieve_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 调用检索 provider 获取候选文档
|
||||
- 第一版默认走 `internet_search_client`
|
||||
- 后续可切到 `intranet_search_client`
|
||||
|
||||
### `normalize_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 做去重
|
||||
- 做摘要裁剪
|
||||
- 做来源字段统一
|
||||
- 输出标准 `RetrievedDocument[]`
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端最终做法
|
||||
|
||||
## 5.1 目录建议
|
||||
|
||||
在现有 `open-canvas` 下新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
CanvasWorkflowPanel.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
```
|
||||
|
||||
## 5.2 左侧改造策略
|
||||
|
||||
`CanvasChatPanel` 只承担模式切换与容器职责:
|
||||
|
||||
- `chat` 模式:沿用现有普通聊天
|
||||
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
|
||||
|
||||
新面板负责:
|
||||
|
||||
- 写作任务表单
|
||||
- 进度时间线
|
||||
- 事件列表
|
||||
- 中间产物抽屉
|
||||
- 提纲确认卡片
|
||||
|
||||
## 5.3 右侧衔接策略
|
||||
|
||||
继续复用现有 `CanvasArtifactPanel`。
|
||||
|
||||
同步规则:
|
||||
|
||||
- 提纲:可选同步到右侧预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖当前草稿
|
||||
- 终稿:作为最终 artifact
|
||||
|
||||
## 5.4 前端状态设计
|
||||
|
||||
新增:
|
||||
|
||||
- `workflowMode`
|
||||
- `workflowRunId`
|
||||
- `workflowStage`
|
||||
- `workflowStatus`
|
||||
- `workflowEvents`
|
||||
- `workflowArtifacts`
|
||||
- `workflowPendingAction`
|
||||
|
||||
注意:
|
||||
|
||||
- workflow 状态放进新的 `WritingWorkflowContext`
|
||||
- 不塞进现有 `CanvasGraphContext`
|
||||
|
||||
## 5.5 前端显示的资料信息
|
||||
|
||||
第一版左侧至少要能展示研究员产出的资料摘要:
|
||||
|
||||
- 来源标题
|
||||
- 来源域名或 URL
|
||||
- 资料摘要
|
||||
- 可信度标签
|
||||
- 是否被提纲或正文引用
|
||||
|
||||
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
|
||||
|
||||
注意:
|
||||
|
||||
- 第一版显示公开互联网来源即可
|
||||
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端最终做法
|
||||
|
||||
## 6.1 分层
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
## 6.2 核心模块
|
||||
|
||||
建议核心文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 6.3 检索层实现要求
|
||||
|
||||
第一版检索层必须独立成基础设施模块,不允许:
|
||||
|
||||
- 在 Agent prompt 中直接拼接“去网上找资料”
|
||||
- 在 graph 节点中散落请求第三方搜索的代码
|
||||
- 把互联网检索和内网检索混在一个无抽象的函数里
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
material_normalizer.py
|
||||
```
|
||||
|
||||
建议职责:
|
||||
|
||||
- `internet_search_client.py`
|
||||
- 第一版实际使用
|
||||
- 对接互联网搜索 API 或爬取网关
|
||||
|
||||
- `intranet_search_client.py`
|
||||
- 第一版只保留接口和空实现或 mock
|
||||
- 后续接企业内部接口
|
||||
|
||||
- `retriever_router.py`
|
||||
- 根据配置选择 provider
|
||||
|
||||
- `material_normalizer.py`
|
||||
- 统一清洗和标准化结果
|
||||
|
||||
## 6.4 Prompt 管理
|
||||
|
||||
统一独立存放:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
## 6.5 输出约束
|
||||
|
||||
每个 Agent 都必须:
|
||||
|
||||
- 优先输出 JSON / Pydantic schema
|
||||
- 失败时允许轻量 repair 和一次重试
|
||||
- 避免自由散文式输出
|
||||
|
||||
## 6.6 Researcher 的实现细则
|
||||
|
||||
Researcher 推荐由两部分组成:
|
||||
|
||||
1. 程序化检索
|
||||
2. 模型化整理
|
||||
|
||||
推荐伪流程:
|
||||
|
||||
```python
|
||||
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
|
||||
queries = build_queries(topic, article_type)
|
||||
raw_docs = []
|
||||
for query in queries:
|
||||
raw_docs.extend(provider.search(query=query, top_k=5))
|
||||
docs = normalize_materials(raw_docs)
|
||||
docs = dedupe_materials(docs)
|
||||
docs = rank_materials(docs)
|
||||
return llm_extract_material_cards(docs)
|
||||
```
|
||||
|
||||
第一版建议:
|
||||
|
||||
- 先把 query 生成逻辑做简单
|
||||
- `top_k` 先控制在 5-10
|
||||
- 先做轻量去重和摘要裁剪
|
||||
- 不做很重的网页全文抓取
|
||||
|
||||
---
|
||||
|
||||
## 7. 前后端协议
|
||||
|
||||
## 7.1 API
|
||||
|
||||
第一版统一提供:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
## 7.2 SSE 事件
|
||||
|
||||
统一事件类型:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
|
||||
|
||||
## 7.3 配置项补充
|
||||
|
||||
建议增加以下配置项:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
enable_content_fetch: false
|
||||
|
||||
workflow:
|
||||
max_revisions: 2
|
||||
|
||||
models:
|
||||
researcher: your-default-model
|
||||
outliner: your-default-model
|
||||
writer: your-default-model
|
||||
editor: your-default-model
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 第一版 `provider=internet`
|
||||
- 第二版可切成 `intranet` 或 `hybrid`
|
||||
|
||||
---
|
||||
|
||||
## 8. 必须做 / 延后做 / 不建议现在做
|
||||
|
||||
## 8.1 必须做
|
||||
|
||||
### 架构
|
||||
|
||||
- 独立后端路线 `ai-writing-orchestrator`
|
||||
- 前端独立 `workflow/` 目录
|
||||
- LangGraph 状态机
|
||||
- `Researcher / Outliner / Writer / Editor / Supervisor`
|
||||
- 独立检索抽象层,第一版先落互联网 provider
|
||||
|
||||
### 协议
|
||||
|
||||
- 标准 workflow 状态枚举
|
||||
- 标准 SSE 事件枚举
|
||||
- 标准 artifact 结构
|
||||
- JSON / Pydantic 输出约束
|
||||
|
||||
### 功能
|
||||
|
||||
- 启动写作任务
|
||||
- 研究阶段
|
||||
- 提纲阶段
|
||||
- 提纲确认
|
||||
- 初稿生成
|
||||
- 审稿与最多 2 次重写
|
||||
- 终稿同步到右侧
|
||||
- 素材卡片展示
|
||||
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
|
||||
|
||||
### UI
|
||||
|
||||
- 模式切换
|
||||
- 时间线
|
||||
- 事件列表
|
||||
- 提纲确认卡片
|
||||
- 右侧终稿显示
|
||||
- 左侧素材卡片视图
|
||||
|
||||
## 8.2 延后做
|
||||
|
||||
- 章节级精准重写
|
||||
- 初稿多版本树
|
||||
- FactChecker
|
||||
- StyleEditor
|
||||
- ComplianceReviewer
|
||||
- 自动评分看板
|
||||
- 素材引用可视化高亮
|
||||
- 更复杂的人机协作节点
|
||||
- 内网知识库正式接入
|
||||
- 混合检索 `hybrid`
|
||||
- 网页全文抓取与深摘要
|
||||
|
||||
## 8.3 不建议现在做
|
||||
|
||||
- 直接接入现有 DeerFlow 主 agent 包
|
||||
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
|
||||
- 一开始就做 5 个以上 Agent
|
||||
- 一开始就做很复杂的文章版本管理
|
||||
- 一开始就把评分阈值和 prompt 写死得非常细
|
||||
|
||||
## 9. 最终开发顺序
|
||||
|
||||
## Step 1:先定契约
|
||||
|
||||
先一起拍板:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- confirm / rewrite 的 payload
|
||||
- retrieval provider 接口
|
||||
- RetrievedDocument 结构
|
||||
|
||||
## Step 2:先做 fake workflow
|
||||
|
||||
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
|
||||
|
||||
## Step 3:先接互联网版 Researcher + Outliner
|
||||
|
||||
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
|
||||
|
||||
这一阶段要求:
|
||||
|
||||
- `Researcher` 先走互联网检索
|
||||
- `retriever_router` 可配置
|
||||
- `intranet_search_client` 先留接口空壳
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再补初稿与审稿重写闭环。
|
||||
|
||||
## Step 5:接右侧 artifact
|
||||
|
||||
把初稿和终稿稳定写入右侧。
|
||||
|
||||
## Step 6:补生产化
|
||||
|
||||
- 缓存
|
||||
- 日志
|
||||
- 错误回放
|
||||
- 配置化 prompt
|
||||
|
||||
## 10. 推荐任务拆分
|
||||
|
||||
## 前端
|
||||
|
||||
- `CanvasChatPanel` 模式切换
|
||||
- `workflow/` 目录与类型
|
||||
- 时间线 / 事件列表 / 确认卡片
|
||||
- SSE 订阅 hook
|
||||
- 右侧 artifact 映射
|
||||
- 素材卡片列表展示
|
||||
- 来源字段展示规范
|
||||
|
||||
## 后端
|
||||
|
||||
- workflow API
|
||||
- LangGraph graph
|
||||
- 5 个 Agent 模块
|
||||
- Prompt 目录
|
||||
- SSE 输出
|
||||
- 仓储与日志
|
||||
- `RetrievalProvider` 抽象
|
||||
- `internet_search_client`
|
||||
- `intranet_search_client` 预留接口
|
||||
- `material_normalizer`
|
||||
|
||||
## Prompt / 算法
|
||||
|
||||
- Researcher prompt
|
||||
- Outliner prompt
|
||||
- Writer prompt
|
||||
- Editor prompt
|
||||
- JSON schema
|
||||
- 失败样本回归
|
||||
|
||||
## 联调
|
||||
|
||||
- mock SSE
|
||||
- 真模型接入
|
||||
- artifact 同步校验
|
||||
- 提纲确认链路验证
|
||||
- 互联网资料来源字段联调
|
||||
- 素材卡片到提纲引用链路联调
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发过程中的约束
|
||||
|
||||
开发过程中统一采用下面这套口径:
|
||||
|
||||
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
|
||||
- 技术底座:`LangGraph`
|
||||
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
- 后端方式:独立 `ai-writing-orchestrator`
|
||||
- 检索方式:第一版 `internet`,接口预留 `intranet`
|
||||
- 通信方式:`SSE`
|
||||
- 输出方式:`JSON Schema / Pydantic`
|
||||
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
|
||||
|
||||
给开发团队的统一口径:
|
||||
|
||||
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
|
||||
@@ -0,0 +1,538 @@
|
||||
# AI 写作 — 前端开发文档
|
||||
|
||||
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
|
||||
>
|
||||
> **配套文档**:
|
||||
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
|
||||
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
|
||||
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位
|
||||
|
||||
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
|
||||
|
||||
### UI 入口
|
||||
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
|
||||
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
|
||||
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── pages/
|
||||
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
|
||||
│
|
||||
├── api/ # ── 后端 API 适配层 ──
|
||||
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
|
||||
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
|
||||
│ └── ai-writing-article-types.ts # 文章类型 CRUD
|
||||
│
|
||||
├── hooks/ # ── 状态管理 ──
|
||||
│ ├── useAIWriting.ts # 写作主状态机(最重要)
|
||||
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
|
||||
│ └── useArticleTypes.ts # 文章类型选项 hook
|
||||
│
|
||||
├── components/ai-writing/ # ── UI 组件 ──
|
||||
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
|
||||
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
|
||||
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
|
||||
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
|
||||
│ │
|
||||
│ ├── AIWritingTimeline.tsx # 时间线主容器
|
||||
│ ├── TimelineStep.tsx # 单条进度事件
|
||||
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
|
||||
│ │
|
||||
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
|
||||
│ ├── MaterialsCard.tsx # 素材确认
|
||||
│ ├── MaterialItem.tsx # 单个素材项
|
||||
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
|
||||
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
|
||||
│ ├── OutlineSection.tsx # 大纲单章节
|
||||
│ ├── ReviewCard.tsx # 审核确认
|
||||
│ ├── IssueList.tsx # 审核意见列表
|
||||
│ ├── ScoreBar.tsx # 评分进度条
|
||||
│ │
|
||||
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
|
||||
│ ├── DraftStreamingView.tsx # 草稿流式打字机
|
||||
│ ├── ReviewStreamingView.tsx # 审核流式打字机
|
||||
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
|
||||
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
|
||||
│ │
|
||||
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
|
||||
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
|
||||
│
|
||||
└── types/
|
||||
└── ai-writing.ts # 全部类型定义(重要参考)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AIWritingPanel │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ useAIWriting() 主状态 hook │ │
|
||||
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
|
||||
│ │ │ ↓ 返回 session_id │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ subscribeToProgress(sessionId) │ │ │
|
||||
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
|
||||
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
|
||||
│ │ │ ─── handleEvent reducer → setState │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ submitIntervention() │ │ │
|
||||
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
|
||||
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
|
||||
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
|
||||
│ │ └────────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 渲染: │
|
||||
│ ├ AIWritingForm(idle 状态) │
|
||||
│ ├ AIWritingTimeline(运行中) │
|
||||
│ │ ├ TimelineStep × N(progressEvents) │
|
||||
│ │ └ CompletedInterventionCard × N(已完成的干预) │
|
||||
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
|
||||
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
|
||||
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、状态机(useAIWriting.ts)
|
||||
|
||||
### 状态字段(完整定义见 `types/ai-writing.ts`)
|
||||
|
||||
```typescript
|
||||
interface AIWritingState {
|
||||
// —— 核心标识 ——
|
||||
sessionId: string | null; // 后端返回的会话 id
|
||||
status: WritingStatus; // 当前阶段(13 种状态之一)
|
||||
isPaused: boolean; // 是否在干预点等待用户
|
||||
error: string | null;
|
||||
|
||||
// —— 配置 ——
|
||||
request: AIWritingRequest | null; // 用户提交的写作请求
|
||||
|
||||
// —— 累计数据 ——
|
||||
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
|
||||
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
|
||||
outline: AIOutline | null; // 大纲(大纲就绪后填充)
|
||||
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
|
||||
currentDraftTitle: string;
|
||||
reviewResult: ReviewResult | null;
|
||||
revisionCount: number;
|
||||
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
|
||||
|
||||
// —— 干预 ——
|
||||
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
|
||||
|
||||
// —— 流式视图状态 ——
|
||||
streamingDraftTitle: string;
|
||||
streamingDraftSections: StreamingDraftSection[];
|
||||
outlineStreamingText: string;
|
||||
outlineThinkingText: string;
|
||||
researchProgress: string;
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
revisionSummary: RevisionSummary | null;
|
||||
pendingRevisionContext: { /* ... */ } | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 状态转移图(写作状态机)
|
||||
|
||||
```
|
||||
┌──────┐
|
||||
│ idle │ ←─────────────────────────────────────┐
|
||||
└──┬───┘ │
|
||||
│ startWriting() │
|
||||
↓ │
|
||||
┌─────────────────┐ │
|
||||
│ researching │ │
|
||||
└──┬──────────────┘ │
|
||||
↓ materials_ready │
|
||||
┌─────────────────────────────┐ │
|
||||
│ awaiting_material_confirm │ ← await_user │
|
||||
└──┬──────────────────────────┘ │
|
||||
↓ submit(confirm) │
|
||||
┌─────────────────┐ │
|
||||
│ writing_outline │ ← (re_search 路径) │
|
||||
└──┬──────────────┘ │
|
||||
↓ outline_ready │
|
||||
┌─────────────────────────────┐ │
|
||||
│ awaiting_outline_confirm │ │
|
||||
└──┬──────────────────────────┘ │
|
||||
↓ submit(confirm) │
|
||||
┌─────────────────┐ │
|
||||
│ writing_draft │ ← (re_outline 路径) │
|
||||
└──┬──────────────┘ │
|
||||
│ │
|
||||
┌───────┴────────┐ │
|
||||
↓ ↓ │
|
||||
awaiting_section_ awaiting_draft_confirm │
|
||||
help (素材不足) └─┬───┬───┬──────────────┐ │
|
||||
│ (supplement) │ │ │ to_editor │ │
|
||||
│ ↑回到 research │ │ ↓ │ │
|
||||
│ │ │ reviewing │ │
|
||||
│ │ │ └─┬──────┐ │ │
|
||||
│ │ │ ↓ ↓ │ │
|
||||
│ │ │ awaiting_review_confirm │
|
||||
│ │ │ └─┬────────┐ │
|
||||
│ │ │ ↓ submit ↓ │
|
||||
│ │ │ revising done ───────────────┘
|
||||
│ │ │ │
|
||||
│ │ │ └─→ writing_draft (循环修订)
|
||||
│ │ │
|
||||
│ │ │ user_revise
|
||||
│ │ └─→ revising
|
||||
│ │
|
||||
│ │ finalize
|
||||
│ └─→ done
|
||||
│
|
||||
└──→ (delete/loose) writing_draft 继续
|
||||
```
|
||||
|
||||
### handleEvent reducer(核心)
|
||||
|
||||
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
|
||||
|
||||
| 事件类型 | 主要影响的状态字段 |
|
||||
|---|---|
|
||||
| `connected` | 仅记录到 progressEvents |
|
||||
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
|
||||
| `step_done` | 仅记录 |
|
||||
| `research_progress` | `researchProgress`(实时检索进度文案) |
|
||||
| `materials_ready` | `materials`、清空 researchProgress |
|
||||
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
|
||||
| `outline_ready` | `outline`、清空流式文本 |
|
||||
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
|
||||
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
|
||||
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
|
||||
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
|
||||
| `review_ready` | `reviewResult` |
|
||||
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
|
||||
| `resumed` | `currentPause = null` |
|
||||
| `notice` | 仅记录(非致命告警) |
|
||||
| `done` | `status = 'done'` |
|
||||
| `error` | `status = 'error'`,`error = event.message` |
|
||||
|
||||
---
|
||||
|
||||
## 五、API 层(api/)
|
||||
|
||||
### 1. `api/ai-writing.ts` — 核心写作 API
|
||||
|
||||
```typescript
|
||||
// 启动写作 → 返回 session_id
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string>
|
||||
|
||||
// 订阅 SSE 进度流 → 返回取消订阅函数
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
): () => void
|
||||
|
||||
// 提交用户干预 → 让 graph 续推
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**关键实现细节**:
|
||||
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
|
||||
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
|
||||
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
|
||||
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
|
||||
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
|
||||
|
||||
### 2. `api/ai-writing-sessions.ts` — 历史会话
|
||||
|
||||
```typescript
|
||||
// CRUD
|
||||
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
|
||||
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
|
||||
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
|
||||
deleteAIWritingSession(id) : Promise<void>
|
||||
|
||||
// 时间线持久化(fire-and-forget,永不抛错)
|
||||
saveAIWritingTranscript(id, transcript) : Promise<boolean>
|
||||
```
|
||||
|
||||
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
|
||||
|
||||
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
|
||||
|
||||
| 函数 | 用途 |
|
||||
|---|---|
|
||||
| `listArticleTypes()` | 表单下拉框选项 |
|
||||
| `createArticleType()` | 管理员新增 |
|
||||
| `updateArticleType()` | 管理员编辑 |
|
||||
| `deleteArticleType()` | 管理员删除 |
|
||||
|
||||
---
|
||||
|
||||
## 六、组件树详解
|
||||
|
||||
### 顶层:`AIWritingPanel`(457 行,最大)
|
||||
|
||||
负责:
|
||||
1. 调用 `useAIWriting()` 拿主状态
|
||||
2. 调用 `useAIWritingSessions()` 拿历史列表
|
||||
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
|
||||
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
|
||||
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
|
||||
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
|
||||
|
||||
### 干预卡:`InterventionCard`(663 行,第二大)
|
||||
|
||||
按 `pausePoint` 渲染**不同的卡片**:
|
||||
|
||||
| pausePoint | 卡片 | 用户能做的 |
|
||||
|---|---|---|
|
||||
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
|
||||
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
|
||||
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
|
||||
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
|
||||
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
|
||||
|
||||
### 时间线:`AIWritingTimeline`
|
||||
|
||||
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
|
||||
|
||||
### 流式视图:`{Outline,Draft,Review}StreamingView`
|
||||
|
||||
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
|
||||
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
|
||||
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
|
||||
|
||||
### 历史回显:`AIWritingHistoryView`
|
||||
|
||||
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
|
||||
|
||||
---
|
||||
|
||||
## 七、SSE 事件协议(前后端契约)
|
||||
|
||||
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
|
||||
|
||||
### 解析流程
|
||||
|
||||
```
|
||||
ReadableStream → TextDecoder
|
||||
→ 按 "\n\n" 分帧
|
||||
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
|
||||
→ JSON.parse(data)
|
||||
→ normalize(parsed)(snake → camel)
|
||||
→ onEvent(normalized)
|
||||
```
|
||||
|
||||
### 事件载荷字段(不完全列表)
|
||||
|
||||
| 字段 | 出现在哪些事件 | 用途 |
|
||||
|---|---|---|
|
||||
| `type` | 全部 | 事件类别 |
|
||||
| `sessionId` | 全部 | 标识 |
|
||||
| `message` | 大多数 | 显示给用户的文案 |
|
||||
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
|
||||
| `materials` | materials_ready | 素材数组 |
|
||||
| `outline` | outline_ready | 大纲对象 |
|
||||
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
|
||||
| `reviewResult` | review_ready | 完整审核结果 |
|
||||
| `pausePoint` | await_user | 哪个干预点 |
|
||||
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
|
||||
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
|
||||
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
|
||||
|
||||
---
|
||||
|
||||
## 八、扩展指南:常见开发任务
|
||||
|
||||
### 新增一个干预点
|
||||
|
||||
1. **前端类型扩展**:
|
||||
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
|
||||
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
|
||||
- 在 `CompletedIntervention.pausePoint` 加 case
|
||||
|
||||
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
|
||||
|
||||
3. **后端配合**(详见后端开发文档):
|
||||
- `graph.py` 加 `pause_xxx_confirm` 节点
|
||||
- graph 拓扑加这个节点的边
|
||||
|
||||
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
|
||||
|
||||
### 新增一个 SSE 事件类型
|
||||
|
||||
1. `types/ai-writing.ts`:
|
||||
- `ProgressEventType` 联合类型加新值
|
||||
- `ProgressEvent` 接口加新字段(如果有载荷)
|
||||
|
||||
2. `api/ai-writing.ts`:
|
||||
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
|
||||
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
|
||||
|
||||
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
|
||||
|
||||
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
|
||||
|
||||
### 修改一个流式打字机的行为
|
||||
|
||||
每个 streaming view 内部都是独立的:
|
||||
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
|
||||
- 渲染在 `*StreamingView.tsx`
|
||||
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
|
||||
|
||||
### 新增草稿到右侧画布的逻辑
|
||||
|
||||
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
|
||||
|
||||
---
|
||||
|
||||
## 九、容错与边界处理
|
||||
|
||||
### 已落实的容错(不会阻断写作)
|
||||
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
|
||||
- ✅ 历史会话列表加载失败仅 `console.warn`
|
||||
- ✅ 重命名/删除会话失败静默吞掉
|
||||
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
|
||||
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
|
||||
|
||||
### 已知边界(可能影响 UI 体验)
|
||||
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
|
||||
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
|
||||
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
|
||||
|
||||
---
|
||||
|
||||
## 十、调试技巧
|
||||
|
||||
### 看 SSE 原始事件
|
||||
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
|
||||
|
||||
### 查当前 hook 状态
|
||||
在浏览器 console 里:
|
||||
```js
|
||||
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
|
||||
// 或在 useAIWriting 里临时加:
|
||||
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
|
||||
// 然后在 console 里 __aiwriting 看
|
||||
```
|
||||
|
||||
### 模拟事件
|
||||
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
|
||||
|
||||
### 看后端 transcript
|
||||
```
|
||||
GET /api/ai-writing/sessions/<id>
|
||||
```
|
||||
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
|
||||
|
||||
---
|
||||
|
||||
## 十一、性能注意点
|
||||
|
||||
### 大数组渲染
|
||||
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
|
||||
- `streamingDraftSections` 章节多时也是潜在瓶颈
|
||||
|
||||
### transcript PUT 频率
|
||||
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
|
||||
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
|
||||
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
|
||||
|
||||
### 浏览器内存
|
||||
- progressEvents 不主动清理 → 长会话可能占用几十 MB
|
||||
- 完成一篇文章后建议 `reset()` 清空状态
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关后端接口(速查)
|
||||
|
||||
| 接口 | 用途 | 前端调用方 |
|
||||
|---|---|---|
|
||||
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
|
||||
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
|
||||
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
|
||||
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
|
||||
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
|
||||
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
|
||||
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
|
||||
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
|
||||
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
|
||||
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、未来优化方向
|
||||
|
||||
| 项目 | 说明 | 工作量 |
|
||||
|---|---|---|
|
||||
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
|
||||
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
|
||||
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
|
||||
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
|
||||
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
|
||||
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:所有类型一览
|
||||
|
||||
完整定义在 `types/ai-writing.ts`,主要类型:
|
||||
|
||||
```typescript
|
||||
// 业务实体
|
||||
AIMaterial // 单个素材
|
||||
AIOutline // 大纲
|
||||
OutlineSection // 大纲单章节
|
||||
ReviewIssue // 审核问题
|
||||
ReviewResult // 完整审核结果
|
||||
BlockedSection // 素材不足的章节
|
||||
SectionDecision // 章节求助决策
|
||||
|
||||
// 流程
|
||||
WritingStatus // 13 种状态
|
||||
ProgressEventType // 17 种事件类型
|
||||
ProgressEvent // SSE 事件载荷
|
||||
UserInterventionPayload // 干预提交体
|
||||
AIWritingRequest // 启动写作的请求
|
||||
CompletedIntervention // 已完成的干预记录
|
||||
|
||||
// 流式视图
|
||||
StreamingDraftSection
|
||||
StreamingReviewItem
|
||||
RevisionSummary
|
||||
|
||||
// Hook 状态
|
||||
AIWritingState // useAIWriting 的完整状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见踩坑
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
|
||||
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
|
||||
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
|
||||
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
|
||||
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
|
||||
|
||||
---
|
||||
|
||||
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
|
||||
@@ -0,0 +1,472 @@
|
||||
# AI 写作前端改造清单
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
|
||||
|
||||
当前核心文件:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
---
|
||||
|
||||
## 2. 改造原则
|
||||
|
||||
- 不改右侧主编辑器交互心智
|
||||
- 不复制新页面
|
||||
- 不破坏现有普通 chat 模式
|
||||
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
|
||||
- 所有 workflow 能力都应允许逐步灰度
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐前端结构
|
||||
|
||||
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
utils/
|
||||
workflow-event.ts
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `open-canvas` 原有聊天、artifact、editor 目录不重构
|
||||
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态设计
|
||||
|
||||
## 4.1 新增的核心状态
|
||||
|
||||
建议新增:
|
||||
|
||||
- `workflowMode: "chat" | "writing_pipeline"`
|
||||
- `workflowRunId: string | null`
|
||||
- `workflowStage`
|
||||
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
|
||||
- `workflowEvents: WritingWorkflowEvent[]`
|
||||
- `workflowArtifacts: WritingWorkflowArtifact[]`
|
||||
- `workflowPendingAction: WritingPendingAction | null`
|
||||
|
||||
## 4.2 推荐类型定义
|
||||
|
||||
建议在 `workflow/types/workflow.ts` 中先定义:
|
||||
|
||||
```ts
|
||||
export type WritingWorkflowStage =
|
||||
| "idle"
|
||||
| "collecting_materials"
|
||||
| "generating_outline"
|
||||
| "await_outline_confirmation"
|
||||
| "writing_draft"
|
||||
| "reviewing_draft"
|
||||
| "rewriting_draft"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type WritingWorkflowEventType =
|
||||
| "workflow_started"
|
||||
| "stage_changed"
|
||||
| "agent_started"
|
||||
| "agent_output_chunk"
|
||||
| "artifact_generated"
|
||||
| "human_confirmation_required"
|
||||
| "retry_scheduled"
|
||||
| "workflow_completed"
|
||||
| "workflow_failed";
|
||||
```
|
||||
|
||||
第一版把类型先收紧,后续再扩展。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件拆分建议
|
||||
|
||||
## 5.1 左侧主面板改造
|
||||
|
||||
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
|
||||
|
||||
建议改造为:
|
||||
|
||||
- `chat` 模式继续渲染现有消息列表 + 输入框
|
||||
- `writing_pipeline` 模式渲染新的工作流视图
|
||||
|
||||
建议拆分出:
|
||||
|
||||
- `CanvasChatConversation.tsx`
|
||||
- `CanvasChatInputBox.tsx`
|
||||
- `CanvasWorkflowPanel.tsx`
|
||||
|
||||
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
|
||||
|
||||
## 5.2 新增组件职责
|
||||
|
||||
### `WritingWorkflowEntryForm.tsx`
|
||||
|
||||
负责采集:
|
||||
|
||||
- 主题
|
||||
- 文章类型
|
||||
- 目标读者
|
||||
- 语气风格
|
||||
- 目标篇幅
|
||||
- 是否需要引用资料
|
||||
- 禁写项
|
||||
- 补充说明
|
||||
|
||||
### `WritingWorkflowTimeline.tsx`
|
||||
|
||||
负责显示阶段:
|
||||
|
||||
- 研究中
|
||||
- 提纲生成中
|
||||
- 等待确认提纲
|
||||
- 正在写初稿
|
||||
- 正在审稿
|
||||
- 正在重写
|
||||
- 已完成
|
||||
|
||||
### `WritingWorkflowEventList.tsx`
|
||||
|
||||
负责显示细粒度事件:
|
||||
|
||||
- 哪个 Agent 开始执行
|
||||
- 当前在处理哪一步
|
||||
- 当前产生了什么中间结果
|
||||
- 是否重试
|
||||
|
||||
### `WritingWorkflowArtifactDrawer.tsx`
|
||||
|
||||
负责展示中间产物:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑意见
|
||||
- 重写稿
|
||||
|
||||
### `WritingWorkflowConfirmCard.tsx`
|
||||
|
||||
负责展示用户确认点:
|
||||
|
||||
- 确认提纲
|
||||
- 驳回提纲并附带修改意见
|
||||
- 继续写稿
|
||||
- 指定重写章节
|
||||
|
||||
---
|
||||
|
||||
## 6. Context 与 Hook 改造建议
|
||||
|
||||
## 6.1 `CanvasGraphContext`
|
||||
|
||||
当前它只维护:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `streamingArtifact`
|
||||
- `isStreaming`
|
||||
|
||||
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
|
||||
- 新增 `WritingWorkflowContext`
|
||||
|
||||
## 6.2 `WritingWorkflowContext`
|
||||
|
||||
建议承载:
|
||||
|
||||
- 当前 workflow 运行状态
|
||||
- 中间产物列表
|
||||
- 待确认操作
|
||||
- workflow 事件缓存
|
||||
- 启动 / 取消 / 确认 / 重写等 action
|
||||
|
||||
## 6.3 `useCanvasThreadStream`
|
||||
|
||||
当前这个 hook 已经负责:
|
||||
|
||||
- 创建 thread
|
||||
- 提交消息
|
||||
- 接收 thread stream
|
||||
- 将 `update_artifact` 同步到右侧
|
||||
|
||||
建议不要把“多 Agent workflow 状态机”也硬塞进去。
|
||||
|
||||
推荐方案:
|
||||
|
||||
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
|
||||
- 新增 `useWritingWorkflow`
|
||||
- 新增 `useWritingWorkflowEvents`
|
||||
|
||||
分工如下:
|
||||
|
||||
- `useCanvasThreadStream`:普通对话流
|
||||
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
|
||||
- `useWritingWorkflowEvents`:订阅 workflow 事件流
|
||||
|
||||
---
|
||||
|
||||
## 7. API 接入建议
|
||||
|
||||
## 7.1 前端 API 文件
|
||||
|
||||
新增:
|
||||
|
||||
- `workflow/api/writing-workflow.ts`
|
||||
|
||||
建议暴露方法:
|
||||
|
||||
- `createWorkflow(payload)`
|
||||
- `startWorkflow(id)`
|
||||
- `getWorkflow(id)`
|
||||
- `subscribeWorkflowEvents(id, handlers)`
|
||||
- `confirmOutline(id, payload)`
|
||||
- `rewriteSection(id, payload)`
|
||||
- `approveFinal(id)`
|
||||
|
||||
## 7.2 事件流形式
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `SSE`
|
||||
2. 流式 HTTP chunk
|
||||
3. 轮询兜底
|
||||
|
||||
理由:
|
||||
|
||||
- 事件驱动比整段消息流更适合 workflow UI
|
||||
- 可以更方便映射到时间线和事件列表
|
||||
|
||||
---
|
||||
|
||||
## 8. 与右侧 Artifact 的衔接
|
||||
|
||||
## 8.1 现有优势
|
||||
|
||||
当前 `CanvasArtifactPanel` 已具备:
|
||||
|
||||
- 流式展示内容
|
||||
- 最终 artifact 渲染
|
||||
- 右侧编辑体验成熟
|
||||
|
||||
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
|
||||
|
||||
## 8.2 建议接法
|
||||
|
||||
工作流各阶段结果按两类处理:
|
||||
|
||||
- 中间产物:进入左侧 `workflowArtifacts`
|
||||
- 可阅读正文:同步一份到右侧 `artifact`
|
||||
|
||||
推荐映射:
|
||||
|
||||
- 提纲:可选同步到右侧,供用户预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖或新增版本到右侧
|
||||
- 终稿:作为最终右侧主版本
|
||||
|
||||
## 8.3 版本策略
|
||||
|
||||
建议右侧版本保留:
|
||||
|
||||
- `outline`
|
||||
- `draft_v1`
|
||||
- `draft_v2`
|
||||
- `final`
|
||||
|
||||
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面交互流程
|
||||
|
||||
## 9.1 用户首次进入
|
||||
|
||||
- 默认仍是现有 chat 模式
|
||||
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
|
||||
|
||||
## 9.2 启动写作工作流
|
||||
|
||||
- 用户填写写作任务表单
|
||||
- 点击“开始生成”
|
||||
- 左侧切换到时间线 + 事件流视图
|
||||
- 右侧进入 loading 或素材 / 提纲预览态
|
||||
|
||||
## 9.3 等待提纲确认
|
||||
|
||||
- 时间线停在 `await_outline_confirmation`
|
||||
- 左侧出现确认卡片
|
||||
- 用户可执行:
|
||||
- 通过提纲
|
||||
- 要求调整提纲
|
||||
- 追加要求
|
||||
|
||||
## 9.4 生成初稿与审稿
|
||||
|
||||
- 左侧显示 Agent 事件
|
||||
- 右侧展示初稿内容
|
||||
- 审稿意见在左侧展示,同时高亮需要重写的章节
|
||||
|
||||
## 9.5 完成
|
||||
|
||||
- 左侧显示“终稿已完成”
|
||||
- 右侧进入可继续局部 AI 编辑状态
|
||||
|
||||
---
|
||||
|
||||
## 10. 分步开发清单
|
||||
|
||||
## Step 1:类型与假数据
|
||||
|
||||
目标:
|
||||
|
||||
- 先把 workflow 的类型、mock 数据、组件骨架搭好
|
||||
|
||||
任务:
|
||||
|
||||
- 新建 `workflow/types/workflow.ts`
|
||||
- 新建 `workflow/components/*`
|
||||
- 用本地 mock 事件驱动时间线和事件流
|
||||
|
||||
验收:
|
||||
|
||||
- 不接后端也能在页面中演示完整流程
|
||||
|
||||
## Step 2:模式切换
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
|
||||
|
||||
任务:
|
||||
|
||||
- 拆出 `CanvasWorkflowPanel`
|
||||
- 增加模式切换 UI
|
||||
- 保证旧 chat 不回归
|
||||
|
||||
验收:
|
||||
|
||||
- 现有功能不受影响
|
||||
- 新模式可进入表单页
|
||||
|
||||
## Step 3:接入 workflow API
|
||||
|
||||
目标:
|
||||
|
||||
- 从 mock 切到真实 API
|
||||
|
||||
任务:
|
||||
|
||||
- 接 `create/start/get/events`
|
||||
- 实现基础错误态和重试态
|
||||
|
||||
验收:
|
||||
|
||||
- 前后端可联调跑一条主链路
|
||||
|
||||
## Step 4:接入确认节点
|
||||
|
||||
目标:
|
||||
|
||||
- 支持提纲确认和指定章节重写
|
||||
|
||||
任务:
|
||||
|
||||
- 做确认卡片
|
||||
- 做动作回传
|
||||
|
||||
验收:
|
||||
|
||||
- 用户能在流程中介入
|
||||
|
||||
## Step 5:打通右侧版本
|
||||
|
||||
目标:
|
||||
|
||||
- 让 workflow 产物稳定进入右侧编辑器
|
||||
|
||||
任务:
|
||||
|
||||
- 约定 artifact 映射
|
||||
- 接入版本切换或当前版本覆盖
|
||||
|
||||
验收:
|
||||
|
||||
- 终稿稳定显示在右侧
|
||||
|
||||
---
|
||||
|
||||
## 11. 建议修改点位
|
||||
|
||||
优先涉及文件:
|
||||
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
建议新增,不建议重写原有:
|
||||
|
||||
- `open-canvas/workflow/api/writing-workflow.ts`
|
||||
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
|
||||
- `open-canvas/workflow/components/*`
|
||||
|
||||
---
|
||||
|
||||
## 12. 联调约定
|
||||
|
||||
前后端需要尽早统一:
|
||||
|
||||
- workflow id 命名
|
||||
- stage 枚举
|
||||
- event type 枚举
|
||||
- artifact type 枚举
|
||||
- user action payload
|
||||
- 错误码与可恢复动作
|
||||
|
||||
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终建议
|
||||
|
||||
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
|
||||
|
||||
第一版建议先保证三件事:
|
||||
|
||||
1. 左侧能看见阶段进展
|
||||
2. 用户能确认提纲
|
||||
3. 右侧能稳定收到初稿和终稿
|
||||
|
||||
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
|
||||
@@ -0,0 +1,718 @@
|
||||
# AI 写作弱模型多 Agent 编排需求文档
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
|
||||
|
||||
- 结构松散,长文容易跑题
|
||||
- 素材不足,内容空泛
|
||||
- 事实依据弱,容易幻觉
|
||||
- 润色能力有限,语言风格不统一
|
||||
- 一次生成失败后缺少可控的纠偏机制
|
||||
|
||||
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
|
||||
|
||||
建议的角色分工:
|
||||
|
||||
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
|
||||
- `Agent B 作家`:基于选题、素材和结构写初稿
|
||||
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
|
||||
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次建设边界
|
||||
|
||||
### 2.1 前端边界
|
||||
|
||||
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
|
||||
|
||||
当前相关入口:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
前端本期只做以下增强:
|
||||
|
||||
- 在左侧聊天区增加“写作工作流模式”
|
||||
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
|
||||
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
|
||||
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
|
||||
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
|
||||
|
||||
### 2.2 后端边界
|
||||
|
||||
后端必须单开新路线,不影响其他同事当前功能开发。
|
||||
|
||||
建议原则:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把多 Agent 逻辑硬塞进当前通用对话链路
|
||||
- 以独立服务或独立模块形式建设
|
||||
- 通过清晰 API 与当前前端页面对接
|
||||
|
||||
推荐落地位置:
|
||||
|
||||
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
|
||||
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么多 Agent 对弱模型更合适
|
||||
|
||||
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
|
||||
|
||||
- 选题理解
|
||||
- 信息检索
|
||||
- 信息筛选
|
||||
- 结构规划
|
||||
- 长文写作
|
||||
- 自我校验
|
||||
- 风格润色
|
||||
|
||||
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
|
||||
|
||||
- 降低单次上下文复杂度
|
||||
- 降低一次性长输出失败率
|
||||
- 把“写得不好”变成“某一步做得不好”,便于重试
|
||||
- 可以在关键节点插入规则校验与人工确认
|
||||
- 可以用程序补足模型短板,而不是完全依赖模型智力
|
||||
|
||||
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 可借鉴的开源项目
|
||||
|
||||
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
|
||||
|
||||
### 4.1 第一优先级:LangGraph
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 天然适合把写作流程建模成状态图
|
||||
- 支持多步骤、条件分支、重试、人工介入
|
||||
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
|
||||
- 对“弱模型 + 强流程控制”很友好
|
||||
|
||||
可借鉴点:
|
||||
|
||||
- `StateGraph` 的状态驱动编排
|
||||
- 每一步单独定义输入、输出、失败处理
|
||||
- 可视化地表达“通过 / 打回 / 重试 / 终止”
|
||||
|
||||
参考:
|
||||
|
||||
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
|
||||
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
|
||||
### 4.2 第二优先级:STORM
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
|
||||
- 对“先研究后写作”的思路非常契合
|
||||
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
|
||||
|
||||
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
|
||||
|
||||
这和我们的目标高度一致,尤其适合借鉴:
|
||||
|
||||
- 提纲先行
|
||||
- 素材引用先行
|
||||
- 分模块写作
|
||||
- 后置润色
|
||||
|
||||
参考:
|
||||
|
||||
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
|
||||
|
||||
### 4.3 第三优先级:Deep Research Agent
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 明确支持本地模型与云模型混用
|
||||
- 已经包含研究、写作、质量校验、缓存、重试等能力
|
||||
- 很适合借鉴“研究阶段如何约束弱模型”
|
||||
|
||||
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
|
||||
|
||||
特别适合借鉴:
|
||||
|
||||
- 素材缓存
|
||||
- 分阶段质量评分
|
||||
- 节点失败重试
|
||||
- 研究结果结构化存储
|
||||
|
||||
参考:
|
||||
|
||||
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
|
||||
|
||||
### 4.4 可选借鉴:CrewAI
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 角色扮演表达自然
|
||||
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
|
||||
- Flow + Crew 的组织方式适合做 demo 和快速验证
|
||||
|
||||
不建议作为首选底座的原因:
|
||||
|
||||
- 更偏“Agent 角色编排体验”
|
||||
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
|
||||
|
||||
适合借鉴:
|
||||
|
||||
- 角色定义写法
|
||||
- Task 模板
|
||||
- 多角色协作 prompt 结构
|
||||
|
||||
参考:
|
||||
|
||||
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
|
||||
|
||||
### 4.5 不建议新项目优先采用:AutoGen
|
||||
|
||||
原因:
|
||||
|
||||
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
|
||||
- 官方建议新项目优先采用 `Microsoft Agent Framework`
|
||||
|
||||
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
|
||||
|
||||
参考:
|
||||
|
||||
- [microsoft/autogen](https://github.com/microsoft/autogen)
|
||||
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
|
||||
|
||||
### 4.6 结论
|
||||
|
||||
综合本项目需求,建议技术选型优先级如下:
|
||||
|
||||
1. `LangGraph` 作为主编排框架
|
||||
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
|
||||
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
|
||||
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
|
||||
|
||||
---
|
||||
|
||||
## 5. 目标产品形态
|
||||
|
||||
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
|
||||
|
||||
理想交互流程:
|
||||
|
||||
1. 用户输入主题、用途、风格、读者、篇幅要求
|
||||
2. 系统先进入“研究阶段”
|
||||
3. 系统给出资料卡片与候选提纲
|
||||
4. 用户确认提纲或调整
|
||||
5. 系统生成初稿
|
||||
6. 严苛编辑给出问题清单
|
||||
7. 系统自动重写问题段落
|
||||
8. 系统输出终稿到右侧编辑区
|
||||
9. 用户可继续局部改写、扩写、精简
|
||||
|
||||
核心体验目标:
|
||||
|
||||
- 左侧像“编辑部工作台”
|
||||
- 右侧像“成文编辑器”
|
||||
- 用户知道系统现在进行到哪一步
|
||||
- 用户可以在关键节点接管,而不是只能等待最终结果
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 流程设计
|
||||
|
||||
## 6.1 建议的最小可用流程 MVP
|
||||
|
||||
第一版不要一上来做很复杂,建议先做 4 步闭环:
|
||||
|
||||
1. `Research`
|
||||
- 输入:主题、用户要求、内网资料源
|
||||
- 输出:素材卡片列表、可引用事实列表、风险点列表
|
||||
|
||||
2. `Outline`
|
||||
- 输入:用户要求 + 素材卡片
|
||||
- 输出:结构化提纲
|
||||
|
||||
3. `Draft`
|
||||
- 输入:提纲 + 素材卡片
|
||||
- 输出:初稿
|
||||
|
||||
4. `ReviewAndRewrite`
|
||||
- 输入:初稿 + 用户要求 + 素材卡片
|
||||
- 输出:问题清单、改写稿、终稿
|
||||
|
||||
MVP 终止条件:
|
||||
|
||||
- 编辑评分达标则结束
|
||||
- 不达标最多重写 2 次
|
||||
- 超过次数则返回“需要人工介入”
|
||||
|
||||
## 6.2 第二阶段增强流程
|
||||
|
||||
在 MVP 跑通后,再增加:
|
||||
|
||||
- `Planner`:根据文章类型动态规划流程
|
||||
- `SectionWriter`:按章节分别写作,避免长文崩坏
|
||||
- `FactChecker`:逐段核对素材来源
|
||||
- `StyleEditor`:统一语气、标题风格、句式节奏
|
||||
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
|
||||
|
||||
## 6.3 建议的状态机
|
||||
|
||||
建议将流程状态固定为:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
这样前端和后端都可以围绕统一状态通信。
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent 角色定义建议
|
||||
|
||||
## 7.1 Agent A 研究员
|
||||
|
||||
职责:
|
||||
|
||||
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
|
||||
- 输出结构化素材,而不是长篇自然语言
|
||||
- 对每条素材标注来源、可信度、适用角度
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `facts[]`
|
||||
- `quotes[]`
|
||||
- `examples[]`
|
||||
- `risks[]`
|
||||
- `source_refs[]`
|
||||
|
||||
约束建议:
|
||||
|
||||
- 禁止直接生成整篇文章
|
||||
- 不做风格化表达
|
||||
- 每条结论必须可回溯到来源
|
||||
|
||||
## 7.2 Agent B 作家
|
||||
|
||||
职责:
|
||||
|
||||
- 严格基于提纲和素材写稿
|
||||
- 按章节写,而不是一次写完整篇
|
||||
- 对每段附带素材引用 ID,便于后续审核
|
||||
|
||||
约束建议:
|
||||
|
||||
- 无素材不发挥
|
||||
- 每段只服务一个小节目标
|
||||
- 段落长度控制
|
||||
- 输出结构化草稿数据,而不仅是纯文本
|
||||
|
||||
## 7.3 Agent C 严苛编辑
|
||||
|
||||
职责:
|
||||
|
||||
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
|
||||
- 输出明确的“问题清单”和“改写指令”
|
||||
- 必要时打回指定章节,而不是整篇推翻
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `issues[]`
|
||||
- `severity`
|
||||
- `rewrite_instructions[]`
|
||||
- `accept/reject`
|
||||
|
||||
编辑原则建议:
|
||||
|
||||
- 优先挑事实问题,再挑结构问题,最后才是文风问题
|
||||
- 问题描述必须可执行,不能只说“写得不够好”
|
||||
|
||||
## 7.4 Supervisor
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程启动、状态流转、失败重试、记录日志
|
||||
- 判断是否需要回到上一步
|
||||
- 控制最大重试次数
|
||||
- 对外提供统一 API 与流式事件
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端需求
|
||||
|
||||
## 8.1 保持当前页面结构不变
|
||||
|
||||
继续沿用当前左右布局:
|
||||
|
||||
- 左侧:聊天 + 流程进度 + 中间产物
|
||||
- 右侧:最终文章编辑与局部 AI 改写
|
||||
|
||||
不建议再复制一套新页面,以免造成维护分叉。
|
||||
|
||||
## 8.2 左侧新增 UI 模块
|
||||
|
||||
建议在 `CanvasChatPanel` 基础上新增:
|
||||
|
||||
- 写作任务表单
|
||||
- 工作流阶段时间线
|
||||
- Agent 执行日志面板
|
||||
- 中间产物抽屉
|
||||
- 人工确认卡片
|
||||
|
||||
建议新增的前端概念:
|
||||
|
||||
- `workflowMode`: `chat | writing_pipeline`
|
||||
- `workflowStage`
|
||||
- `workflowRunId`
|
||||
- `workflowEvents[]`
|
||||
- `workflowArtifacts[]`
|
||||
|
||||
## 8.3 需要展示的中间产物
|
||||
|
||||
至少支持展示:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑问题单
|
||||
- 重写结果
|
||||
- 最终定稿
|
||||
|
||||
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
|
||||
|
||||
## 8.4 人工介入节点
|
||||
|
||||
至少提供两个确认点:
|
||||
|
||||
- 提纲确认
|
||||
- 初稿确认或编辑后重跑
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- 指定重写某个章节
|
||||
- 锁定某段不允许再改
|
||||
- 替换某条素材后重新生成
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端需求
|
||||
|
||||
## 9.1 架构原则
|
||||
|
||||
后端新路线必须满足:
|
||||
|
||||
- 与现有通用对话链路解耦
|
||||
- 与现有 Agent 运行时解耦
|
||||
- 独立维护 Prompt、状态机、数据结构
|
||||
- 可以单独部署、单独回滚、单独压测
|
||||
|
||||
## 9.2 推荐模块划分
|
||||
|
||||
建议后端拆分为以下模块:
|
||||
|
||||
- `workflow_orchestrator`
|
||||
- `agent_registry`
|
||||
- `material_retriever`
|
||||
- `outline_generator`
|
||||
- `draft_writer`
|
||||
- `editor_reviewer`
|
||||
- `rewrite_engine`
|
||||
- `workflow_store`
|
||||
- `stream_event_adapter`
|
||||
|
||||
## 9.3 API 建议
|
||||
|
||||
建议最小 API:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- 创建写作任务
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- 启动流程
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- 查询当前状态
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- 获取事件流或轮询事件
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- 用户确认提纲
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- 指定章节重写
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
- 确认终稿
|
||||
|
||||
## 9.4 流式事件建议
|
||||
|
||||
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
前端只要订阅事件,就能把左侧工作台做出来。
|
||||
|
||||
---
|
||||
|
||||
## 10. 弱模型优化策略
|
||||
|
||||
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
|
||||
|
||||
## 10.1 强约束输出格式
|
||||
|
||||
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
|
||||
|
||||
原因:
|
||||
|
||||
- 便于程序消费
|
||||
- 便于后续 Agent 接续
|
||||
- 便于失败重试
|
||||
- 便于质量评估
|
||||
|
||||
## 10.2 长文拆成章节生成
|
||||
|
||||
不要让弱模型一次生成整篇长文,建议:
|
||||
|
||||
- 先出提纲
|
||||
- 再按章节逐段写
|
||||
- 最后统一润色
|
||||
|
||||
## 10.3 把检索和写作分离
|
||||
|
||||
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
|
||||
|
||||
## 10.4 程序兜底而不是模型兜底
|
||||
|
||||
程序需要承担:
|
||||
|
||||
- 去重
|
||||
- 引用格式化
|
||||
- 章节拼接
|
||||
- 长度控制
|
||||
- 评分阈值判断
|
||||
- 重试路由
|
||||
|
||||
## 10.5 小步快跑、失败可回退
|
||||
|
||||
每步都要能独立失败、独立重试、独立回放。
|
||||
|
||||
## 10.6 引入可量化评分
|
||||
|
||||
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
|
||||
|
||||
- 相关性
|
||||
- 事实性
|
||||
- 结构完整度
|
||||
- 语言流畅度
|
||||
- 风格一致性
|
||||
|
||||
系统据此决定是否进入下一步。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分阶段实施计划
|
||||
|
||||
## Phase 1:需求冻结与技术验证
|
||||
|
||||
目标:
|
||||
|
||||
- 确认文章类型范围
|
||||
- 确认内网资料来源
|
||||
- 选定主框架
|
||||
- 跑通最小多 Agent demo
|
||||
|
||||
交付物:
|
||||
|
||||
- 技术选型说明
|
||||
- 角色与状态机设计
|
||||
- 1 个主题的端到端 demo
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
|
||||
|
||||
## Phase 2:后端新路线搭建
|
||||
|
||||
目标:
|
||||
|
||||
- 建立独立 orchestrator
|
||||
- 建立 workflow state store
|
||||
- 建立事件流输出
|
||||
- 建立可配置 prompt 模板
|
||||
|
||||
交付物:
|
||||
|
||||
- 独立服务目录
|
||||
- 基础 API
|
||||
- 状态机运行骨架
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不依赖现有主流程改造即可独立跑通
|
||||
|
||||
## Phase 3:前端接入当前页面
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 接入工作流模式
|
||||
- 增加阶段展示与人工确认
|
||||
- 中间产物可视化
|
||||
|
||||
交付物:
|
||||
|
||||
- 左侧工作流时间线
|
||||
- 中间产物展示面板
|
||||
- 流程控制按钮
|
||||
|
||||
验收标准:
|
||||
|
||||
- 用户可以在当前页面完成一次全流程写作
|
||||
|
||||
## Phase 4:质量优化
|
||||
|
||||
目标:
|
||||
|
||||
- 强化素材检索质量
|
||||
- 提高提纲质量
|
||||
- 引入章节级重写
|
||||
- 引入评分与回退机制
|
||||
|
||||
交付物:
|
||||
|
||||
- 质量评估规则
|
||||
- 重试策略
|
||||
- Prompt 调优包
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型效果明显优于单轮直出
|
||||
|
||||
## Phase 5:生产化
|
||||
|
||||
目标:
|
||||
|
||||
- 加缓存
|
||||
- 加日志
|
||||
- 加监控
|
||||
- 加失败告警
|
||||
- 加审计记录
|
||||
|
||||
交付物:
|
||||
|
||||
- 可观测性方案
|
||||
- 运行日志与回放
|
||||
- 配置中心或 prompt 管理机制
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可长期维护并可定位问题
|
||||
|
||||
---
|
||||
|
||||
## 12. 团队协作与隔离方案
|
||||
|
||||
为了不影响其他同事,建议采用以下协作原则:
|
||||
|
||||
- 新后端目录独立开发
|
||||
- 新接口使用独立路由前缀
|
||||
- 前端只在 `open-canvas` 内增量开发
|
||||
- 不修改现有公共线程协议,必要时增加新事件类型
|
||||
- Prompt、Agent 配置、评分规则独立存放
|
||||
|
||||
推荐分工:
|
||||
|
||||
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
|
||||
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
|
||||
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与规避
|
||||
|
||||
### 风险 1:Agent 太多,成本和耗时暴涨
|
||||
|
||||
规避:
|
||||
|
||||
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
|
||||
- 长文按章节写,失败只重写局部
|
||||
|
||||
### 风险 2:弱模型在自由对话场景中失控
|
||||
|
||||
规避:
|
||||
|
||||
- 全流程结构化
|
||||
- 每步只做一件事
|
||||
- 强约束输入输出格式
|
||||
|
||||
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
|
||||
|
||||
规避:
|
||||
|
||||
- 先把资料源治理好
|
||||
- 对素材做来源、可信度、去重处理
|
||||
|
||||
### 风险 4:前后端协议频繁变化影响联调
|
||||
|
||||
规避:
|
||||
|
||||
- 先定义事件模型和 artifact 模型
|
||||
- 先 mock 数据,再并行开发
|
||||
|
||||
### 风险 5:影响现有功能稳定性
|
||||
|
||||
规避:
|
||||
|
||||
- 新路线、新接口、新状态机
|
||||
- 不侵入现有主链路
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐结论
|
||||
|
||||
本项目推荐的总体策略:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
|
||||
3. 主框架优先采用 `LangGraph`
|
||||
4. 流程设计借鉴 `STORM`
|
||||
5. 质量控制借鉴 `Deep Research Agent`
|
||||
6. 角色设计与任务模板可参考 `CrewAI`
|
||||
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
|
||||
|
||||
一句话总结:
|
||||
|
||||
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考链接
|
||||
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
- [STORM](https://github.com/stanford-oval/storm)
|
||||
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
|
||||
- [CrewAI](https://github.com/crewAIInc/crewAI)
|
||||
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
|
||||
- [AutoGen](https://github.com/microsoft/autogen)
|
||||
@@ -0,0 +1,501 @@
|
||||
# AI 写作 — 迁移到 LangGraph Server 的分步实现方案
|
||||
|
||||
> **文档目的**:彻底解决 AI 写作在多 worker 部署下"SSE 时断时续 / resume 偶发 404"的根因,参照主聊天(`/workspace/chats/new`)的实现,把 AI 写作的 graph 注册成 LangGraph Server 上的一个 assistant,让 Server 接管线程、checkpoint、SSE、interrupt/resume 的全部协议。
|
||||
>
|
||||
> **配套文档**:[ai-writing-说明.md](./ai-writing-说明.md) 里描述了为什么会出问题、当前已落地的兜底方案,以及方案 B/C/F 三条"绕过"路径。本文档是其中"长期方案"的具体实现路线 —— 不是绕过多 worker,而是从架构层面让它**天然不受 worker 数影响**。
|
||||
>
|
||||
> **更新日期**:2026-05-20
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么走这条路
|
||||
|
||||
主聊天和 AI 写作功能形态几乎一致(多步流式、可中断/恢复、有持久化),但**主聊天完全不受多 worker 影响**。差别在于:
|
||||
|
||||
| 维度 | 主聊天(现状) | AI 写作(现状) | AI 写作(迁移后) |
|
||||
|---|---|---|---|
|
||||
| 前端流接入 | `useStream` (SDK) | `fetch` + 自写 `EventSource` | `useStream` (SDK) |
|
||||
| 后端运行时 | **LangGraph Server**(独立进程,端口 2024 / 经 nginx 走 `/api/langgraph`) | 嵌在 FastAPI Gateway 路由里直接跑 | **LangGraph Server**(同主聊天) |
|
||||
| 线程状态 | Server 自带持久化 checkpointer | 自管 sqlite + 一堆模块级 dict | Server 自带持久化 checkpointer |
|
||||
| SSE 通道 | Server ↔ SDK 协议层 | 自写 `asyncio.Queue` per session_id | Server ↔ SDK 协议层 |
|
||||
| 多 worker | 任何 worker 都能查 Server | per-worker 内存 dict 隔离 | 任何 worker 都能查 Server |
|
||||
| Interrupt/Resume | Server 协议(`Command(resume=...)`)| 自写 `_session_pending_pause` + `/resume` 路由 | Server 协议 |
|
||||
|
||||
**核心收益**:迁完后 `_event_queues`、`_session_pending_pause`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_article_types`、`_session_error_flags`、`_session_pending_pause`、`_inflight_graph_tasks`、`_recover_session()`、自管 sqlite checkpointer 这一整套"per-process 内存状态"全部可以删掉 —— 不是"绕过"问题,是**让问题消失**。
|
||||
|
||||
**代价**:节点要改造(流式 chunk 改走 LangGraph 原生 `StreamWriter`/state update)、前端干预卡的交互要重写、业务表写入路径要从"路由里写"挪到"节点里写"。约 **6 人日**核心改造 + 50% buffer,建议按 **9 人日**排期。
|
||||
|
||||
---
|
||||
|
||||
## 二、现状盘点:要删 / 要留 / 要改
|
||||
|
||||
### 2.1 要删的(迁完即可移除)
|
||||
|
||||
| 位置 | 内容 | 备注 |
|
||||
|---|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | `POST /start`、`GET /{id}/stream`、`POST /{id}/resume`、`GET /{id}/state` | 由 LangGraph Server 的 `runs.stream / threads.create / runs.wait` 替代 |
|
||||
| 同上 | `_event_queues`、`_session_pending_pause`、`_session_model_names`、`_session_repos`、`_session_user_ids`、`_session_interventions`、`_session_error_flags`、`_session_article_types` 这 8 个模块级 dict | 状态全部交给 LangGraph Server |
|
||||
| 同上 | `_recover_session()`、`_inflight_graph_tasks`、`_spawn_graph_task()`、`wait_for_inflight_graph_tasks()` | 跨 worker 恢复 / drain 都由 Server 内建机制处理 |
|
||||
| 同上 | `_make_config()` 里注入 `sse_queue` / `sse_session_id` 的逻辑 | 流式输出改走 LangGraph 原生通道 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()`、`AI_WRITING_GRAPH` 单例、`_async_checkpointer_singleton` | checkpoint 由 langgraph.json 的全局 checkpointer 接管 |
|
||||
| `app/gateway/ai_writing_cleanup.py` | "联动清理 sqlite checkpointer thread" 那一段 | 改成调 `langgraph_sdk` 的 `threads.delete()` |
|
||||
| `offline-backend-20260512/config.yaml` | `checkpointer:` 段里 AI 写作专用的 sqlite 路径(如果跟主 checkpointer 不同) | 共用主配置 |
|
||||
|
||||
### 2.2 要保留的
|
||||
|
||||
| 位置 | 内容 | 理由 |
|
||||
|---|---|---|
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/` | MySQL 业务表(标题、最终 draft、completed_interventions、transcript、article_type、status…) | 列表页 / 历史回显 / 全文搜索都依赖业务表,**不能让 LangGraph checkpoint 取代它** —— checkpoint 是流程状态,业务表是产品形态 |
|
||||
| `GET/PATCH/DELETE /api/ai-writing/sessions[/{id}]` | 列表 / 重命名 / 删除 | 只查业务表,不碰 graph |
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | 时间线持久化 | 维持锦上添花的回显方案 |
|
||||
| `GET/POST/PATCH/DELETE /api/ai-writing/article-types[/{id}]` | 文章类型 CRUD | 纯业务路由,跟 graph 无关 |
|
||||
| `POST /api/ai-writing/admin/cleanup` | admin 清理入口 | 路由保留,内部实现改 |
|
||||
| 4 个 interrupt 暂停点 + 5 个节点 + 全部 state 字段 | 业务逻辑零改动 | `pause_material_confirm` / `pause_outline_confirm` / `pause_section_help` / `pause_draft_confirm` / `pause_review_confirm` 用的就是 LangGraph 原生 `interrupt()`,直接吃迁移红利 |
|
||||
|
||||
### 2.3 要改的
|
||||
|
||||
| 位置 | 改造点 |
|
||||
|---|---|
|
||||
| `graph.py` | 删自管 checkpointer,导出 `make_ai_writing_graph(config) → CompiledStateGraph`,由 Server 注入 checkpointer |
|
||||
| `langgraph.json` | `graphs` 里增加 `"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"` |
|
||||
| 各 node(`researcher.py` / `writer_outline.py` / `writer_draft.py` / `editor.py`) | 流式输出从 `sse_queue.put_nowait(...)` 改成 LangGraph 原生 `get_stream_writer()` 推 custom 事件 |
|
||||
| 各 node | DB 写入(草稿、审核结果、status)从「路由里调 repo」改成「节点里通过 `RunnableConfig` 拿 `thread_id` 调 repo」 |
|
||||
| 各 node | "创建 session 行"从 `/start` 路由里挪到 `intent_parser_node`(或前端 `onCreated` 回调里调一个轻量级 upsert API) |
|
||||
| `langgraph_auth.py` | 给 `ai_writing` assistant 加授权规则(保证只能访问自己的 thread) |
|
||||
| 前端 `frontend-web/src/open-canvas/` | 完全弃用现有 `api/ai-writing.ts` 的 `fetch + EventSource`,新写 `useAIWritingStream` hook(基于 `@langchain/langgraph-sdk/react` 的 `useStream`) |
|
||||
| 前端 4 个干预卡 | 提交回调从 `POST /resume` 改为 `thread.submit(new Command({ resume: payload }))` |
|
||||
| 前端"启动写作"按钮 | 从 `POST /start` + 拿 session_id + 建 EventSource 改成 `thread.submit({...args})` 触发 `onCreated` 回调 |
|
||||
| `tests/test_ai_writing_session_recovery.py` | 整体删除 —— 跨 worker 恢复由 LangGraph Server 接管,不再是 AI 写作自己的事 |
|
||||
| 新增 `tests/test_ai_writing_assistant.py` | 通过 LangGraph SDK in-process 跑一遍 graph,覆盖 interrupt / resume / custom stream / state 持久化 |
|
||||
|
||||
---
|
||||
|
||||
## 三、分步实施
|
||||
|
||||
### 阶段 0:可行性预演(0.5 d)
|
||||
|
||||
**目标**:在不动现有代码的前提下,验证"把 ai_writing graph 注册到 LangGraph Server 并通过 SDK 跑通"这条路完全可行。
|
||||
|
||||
1. 临时在 `langgraph.json` 的 `graphs` 里加一条 `"ai_writing_probe": "deerflow.agents.ai_writing.graph:build_ai_writing_graph"`(直接复用现有 `build_ai_writing_graph`,不传 checkpointer 让 Server 注入)。
|
||||
2. 起 `make dev`,确认 LangGraph Server 把 `ai_writing_probe` 列入 assistant 列表。
|
||||
3. 用 Python SDK 客户端(`langgraph_sdk.get_client()`):
|
||||
```python
|
||||
from langgraph_sdk import get_client
|
||||
client = get_client(url="http://localhost:8001/api/langgraph")
|
||||
thread = await client.threads.create()
|
||||
async for chunk in client.runs.stream(
|
||||
thread["thread_id"], "ai_writing_probe",
|
||||
input={"user_intent": "测试", "article_type": "analysis", ...},
|
||||
stream_mode=["values", "messages-tuple", "custom"],
|
||||
): print(chunk)
|
||||
# 跑到 pause_material_confirm 应该收到 interrupt 事件
|
||||
await client.runs.stream(thread["thread_id"], "ai_writing_probe",
|
||||
command={"resume": {"action": "confirm", "approvedMaterialIds": [...]}})
|
||||
```
|
||||
4. 验证:interrupt 能正确暂停、`Command(resume=...)` 能续跑、state 能持久化到 Server checkpoint。
|
||||
5. 跑通后**回滚 langgraph.json** —— 这一步只是"摸路"。
|
||||
|
||||
**通过条件**:4 个 pause 点全部能用 SDK 跑通 interrupt + resume。如果某个 pause 点的 payload 结构跟 SDK 协议不兼容(例如用了非 JSON 可序列化的对象),阶段 0 就要解决,否则会在阶段 2 翻车。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 1:graph 注册为 assistant(0.5 d)
|
||||
|
||||
**改动文件**:`packages/harness/deerflow/agents/ai_writing/graph.py`、`offline-backend-20260512/backend/langgraph.json`
|
||||
|
||||
1. `graph.py` 顶部新增:
|
||||
```python
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
def make_ai_writing_graph(config: RunnableConfig | None = None):
|
||||
"""LangGraph Server 入口:不接收 checkpointer,由 Server 注入。"""
|
||||
g = StateGraph(AIWritingState)
|
||||
# ... 跟现在 build_ai_writing_graph 完全一样的节点 / 边定义 ...
|
||||
return g.compile() # 注意:不传 checkpointer
|
||||
```
|
||||
2. 旧的 `build_ai_writing_graph(checkpointer=None)`、`get_ai_writing_graph()`、`_get_async_checkpointer()`、`get_ai_writing_checkpointer()`、`shutdown_ai_writing_graph()` 全部**先标 `@deprecated` 暂留** —— 阶段 7 再删,避免阶段 2/3 出错时无法快速回退。
|
||||
3. `langgraph.json`:
|
||||
```json
|
||||
{
|
||||
"graphs": {
|
||||
"lead_agent": "deerflow.agents:make_lead_agent",
|
||||
"canvas_agent": "deerflow.agents.canvas_agent:make_canvas_agent",
|
||||
"ai_writing": "deerflow.agents.ai_writing.graph:make_ai_writing_graph"
|
||||
}
|
||||
}
|
||||
```
|
||||
4. `checkpointer.path` 已经指向 `make_checkpointer`(看 `langgraph.json:15-17`),AI 写作直接共享主 checkpointer 即可,**不要**重新搞一套。
|
||||
|
||||
**通过条件**:启动 LangGraph Server 后 `GET /api/langgraph/assistants` 能列出 `ai_writing`,state schema 字段齐全。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 2:节点改造 —— 流式输出走原生通道(1.5 d)
|
||||
|
||||
**核心问题**:现在的 `writer_draft_node` / `writer_outline_node` / `researcher_node` 通过 `config["configurable"]["sse_queue"]` 把 LLM token chunk 推给前端。LangGraph Server 不会再注入 `sse_queue`,必须改用原生流通道。
|
||||
|
||||
**选型**:用 `langgraph.config.get_stream_writer()` 推 `custom` 事件(保留现有"非聊天消息、自定义协议"的形态)。
|
||||
|
||||
> 不选 `messages-tuple` 模式的原因:草稿生成不是聊天消息流,而是大段 markdown;硬塞成 `AIMessage` 会让前端渲染逻辑变得很别扭。custom 通道改动最小、契合度最高。
|
||||
|
||||
**改造模板**(每个节点都按这个模板改):
|
||||
```python
|
||||
# 旧:
|
||||
from .nodes._utils import push_sse_event
|
||||
async def writer_draft_node(state, config):
|
||||
sse_queue = config["configurable"].get("sse_queue")
|
||||
sse_queue.put_nowait({"event": "progress", "data": {...}})
|
||||
async for chunk in llm.astream(...):
|
||||
sse_queue.put_nowait({"event": "draft_chunk", "data": {"text": chunk}})
|
||||
|
||||
# 新:
|
||||
from langgraph.config import get_stream_writer
|
||||
async def writer_draft_node(state, config):
|
||||
writer = get_stream_writer() # 注意:必须在 async 函数体内调,不能模块级缓存
|
||||
writer({"type": "progress", "agent": "writer", "message": "开始写作"})
|
||||
async for chunk in llm.astream(...):
|
||||
writer({"type": "draft_chunk", "text": chunk})
|
||||
```
|
||||
|
||||
**业务表写入改造**:现在 `writer_draft_node` 收尾时通过 `config["configurable"]["sse_session_id"]` + `_session_repos[sid]` 调 `repo.save_draft()`。新方案:
|
||||
```python
|
||||
async def writer_draft_node(state, config):
|
||||
thread_id = config["configurable"]["thread_id"] # LangGraph Server 注入
|
||||
# 从 thread metadata / state 里拿到 user_id(在阶段 4 写入)
|
||||
user_id = state.get("_user_id") or config["configurable"].get("user_id")
|
||||
repo = get_ai_writing_session_store() # 进程内全局,无 per-session 字典
|
||||
try:
|
||||
await repo.save_draft(thread_id, user_id, state["current_draft"])
|
||||
except Exception:
|
||||
logger.exception("save_draft failed (non-fatal)")
|
||||
```
|
||||
|
||||
**注意事项**:
|
||||
- `get_stream_writer()` 在子图 / 工具函数里会拿到当前 graph 的 writer,**不要**在模块 import 时缓存。
|
||||
- 节点要兼容"测试场景下没有 LangGraph runtime":包一层 `try: writer = get_stream_writer() except ...`,单测里 writer 是 no-op。
|
||||
- progress_events 字段保留写入 state(现在节点都已经 return `{"progress_events": [...]}`),这样 history / 回放仍然能在 state 里看到完整轨迹;custom stream 只是为了**实时推送**。
|
||||
|
||||
**通过条件**:手动跑一次 `make dev`,用 SDK 客户端从前一阶段验证脚本流式拿到 `draft_chunk` 事件。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:前端接入 LangGraph SDK(1 d)
|
||||
|
||||
**核心改动**:`frontend-web/src/open-canvas/api/ai-writing.ts` 整文件作废,改写 `frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`。
|
||||
|
||||
**新 hook 骨架**(参考 `frontend-web/src/core/threads/hooks.ts:207` 现有主聊天写法):
|
||||
```ts
|
||||
import { useStream } from "@langchain/langgraph-sdk/react";
|
||||
import { Command } from "@langchain/langgraph-sdk";
|
||||
import { getAPIClient } from "@/core/api/api-client";
|
||||
|
||||
interface AIWritingValues {
|
||||
user_intent: string;
|
||||
article_type: string;
|
||||
status: string;
|
||||
material_package?: MaterialPackage;
|
||||
current_outline?: Outline;
|
||||
current_draft?: DraftArticle;
|
||||
review_result?: ReviewResult;
|
||||
progress_events: any[];
|
||||
}
|
||||
|
||||
export function useAIWritingStream(sessionId: string | "new") {
|
||||
const stream = useStream<AIWritingValues, { CustomEventType: AIWritingCustomEvent }>({
|
||||
client: getAPIClient(),
|
||||
assistantId: "ai_writing",
|
||||
threadId: sessionId === "new" ? undefined : sessionId,
|
||||
reconnectOnMount: true,
|
||||
onCreated(meta) {
|
||||
// 后端会用同一个 thread_id 做 ai_writing_sessions.id;通知 UI 写入 URL
|
||||
navigateTo(`/workspace/ai-writing/${meta.thread_id}`);
|
||||
},
|
||||
onCustomEvent(event) {
|
||||
// 节点 push 来的 draft_chunk / progress / notice
|
||||
pushUIEvent(event);
|
||||
},
|
||||
});
|
||||
return stream;
|
||||
}
|
||||
```
|
||||
|
||||
**4 个干预卡的提交逻辑**(旧 → 新):
|
||||
```ts
|
||||
// 旧(自写 fetch):
|
||||
await fetch(`/api/ai-writing/${sessionId}/resume`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ action: "confirm", approvedMaterialIds: [...] }),
|
||||
});
|
||||
|
||||
// 新(SDK 原生 Command):
|
||||
stream.submit(undefined, {
|
||||
command: { resume: { action: "confirm", approvedMaterialIds: [...] } },
|
||||
});
|
||||
```
|
||||
|
||||
**启动写作**:
|
||||
```ts
|
||||
// 旧:POST /start → 返回 session_id → 建 EventSource
|
||||
// 新:
|
||||
stream.submit({
|
||||
user_intent, article_type, target_audience, word_count_target, keyword_count, writing_mode,
|
||||
}, {
|
||||
config: { configurable: { model_name } },
|
||||
metadata: { feature: "ai_writing", article_type }, // 走业务表筛选
|
||||
});
|
||||
```
|
||||
|
||||
**列表页 / 历史回显**:仍然走 `GET /api/ai-writing/sessions` 业务 API,**无改动**。点进某条历史记录时:
|
||||
- 拿到 `sessionId` (== LangGraph `thread_id`)
|
||||
- `useAIWritingStream(sessionId)` 自动调 `client.threads.getState()` 拉最新 state,渲染最后一次草稿 / 干预点 / interrupt 等待
|
||||
- 已结束的会话 (`status == "done"`) 直接读业务表的 `draft_markdown` 字段,**不挂 SDK stream**(省 Server 资源)
|
||||
|
||||
**通过条件**:手动跑完一个完整流程(启动 → 4 次干预 → 拿到草稿),过程中刷新页面 / 切 worker,进度都不丢。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:业务表写入解耦(0.5 d)
|
||||
|
||||
**问题**:现在 `/start` 路由先 INSERT 一行 `ai_writing_sessions(id=session_id, ...)`,再启动 graph。迁移后前端用 `stream.submit(...)` 直接触发 LangGraph 建 thread + 跑 graph,业务表行没人建 → 列表页查不到。
|
||||
|
||||
**两个方案二选一**:
|
||||
|
||||
**方案 4A(推荐)**:节点里建业务表行
|
||||
- `intent_parser_node` 开头:`await repo.upsert_session(thread_id, user_id, user_intent, article_type, status="researching")`
|
||||
- 优点:单一数据源(节点),不依赖前端做"双写"。
|
||||
- 缺点:用户从前端点"启动" → 看到列表里出现这条 → 之间有 200~500ms 延迟(intent_parser 跑完才入库),需要前端 onCreated 后乐观插入一条临时行。
|
||||
|
||||
**方案 4B**:前端 onCreated 时调一个轻量 upsert API
|
||||
- 新增 `POST /api/ai-writing/sessions/{thread_id}/init`:只插业务表行,不碰 graph。
|
||||
- 前端 `onCreated(meta)` 回调里立刻 fire-and-forget 调它。
|
||||
- 优点:列表瞬间出现新行。
|
||||
- 缺点:前端要多调一次 API,且失败要兜底;多了一条"双方都能建行"的路径。
|
||||
|
||||
**推荐 4A** —— 单一数据源比 UI 响应更重要,200ms 延迟用 loading skeleton 即可。
|
||||
|
||||
**用户身份传递**:节点要拿到 `user_id` 写入业务表。两条路:
|
||||
- LangGraph Server 已有 `langgraph_auth.py` 注入 auth context,在节点里 `config["configurable"].get("langgraph_auth_user")` 取(具体字段名以现有实现为准)。
|
||||
- 或者前端 `submit()` 时 metadata 里带上 `user_id`,节点从 `config["configurable"]["metadata"]` 拿。
|
||||
|
||||
**通过条件**:从启动到列表显示新行,延迟 < 1s;多用户场景下不会出现"A 用户的 thread 落到 B 的业务表行"。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5:transcript 持久化(0.2 d)
|
||||
|
||||
**当前**:前端 `PUT /api/ai-writing/sessions/{id}/transcript` 全量覆盖 `ai_writing_sessions.transcript` 列。
|
||||
|
||||
**改造选择**:
|
||||
- **维持现状**(推荐):transcript 是前端组装的时间线 blob,让前端继续 fire-and-forget PUT 即可,跟迁移正交。
|
||||
- 或者完全弃用 transcript 列,直接从 `client.threads.getHistory()` + `getState()` 还原 —— 工作量大且需要重写回显逻辑,**不推荐第一版做**。
|
||||
|
||||
**通过条件**:原 PUT 接口照常工作;新建会话也能落库。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 6:admin 清理改造(0.3 d)
|
||||
|
||||
**改动文件**:`app/gateway/ai_writing_cleanup.py`
|
||||
|
||||
```python
|
||||
# 旧:
|
||||
saver = get_ai_writing_checkpointer()
|
||||
await saver.adelete_thread(sid) # 自管 sqlite
|
||||
|
||||
# 新:
|
||||
from langgraph_sdk import get_client
|
||||
lg_client = get_client(url=internal_langgraph_url())
|
||||
await lg_client.threads.delete(sid)
|
||||
```
|
||||
|
||||
**活跃保护**:旧逻辑是看 `sid in _event_queues`。新逻辑:
|
||||
```python
|
||||
state = await lg_client.threads.get_state(sid)
|
||||
is_active = (
|
||||
state.get("next") # graph 还有未执行的节点
|
||||
or state.get("tasks") # 当前有 pending task
|
||||
or state.get("metadata", {}).get("interrupted") # 卡在 interrupt 上
|
||||
)
|
||||
if is_active:
|
||||
skipped_active += 1
|
||||
continue
|
||||
```
|
||||
|
||||
**通过条件**:admin cleanup 接口照常返回 `deleted_db / deleted_checkpoints / skipped_active`,且能正确跳过仍在 interrupt 等待用户的会话。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 7:删冗余代码(0.3 d)
|
||||
|
||||
按 §2.1 列表逐项删除。建议**单独开一个 PR**,跟阶段 1~6 解耦:
|
||||
- 前 6 个 PR 全部上线、灰度验证至少 1 周
|
||||
- 确认旧代码路径再无流量后,再走清理 PR
|
||||
- 这样万一灰度阶段需要回滚,旧路径还在
|
||||
|
||||
**通过条件**:`make test` 全绿,`grep -r "_event_queues\|_session_pending_pause\|_recover_session" app/ packages/` 零命中。
|
||||
|
||||
---
|
||||
|
||||
### 阶段 8:测试 + 灰度(1 d)
|
||||
|
||||
**新增测试**:
|
||||
- `tests/test_ai_writing_assistant.py` —— 用 `langgraph_sdk` 客户端打 LangGraph Server,覆盖:
|
||||
- 完整流程:start → 4 个 interrupt → resume → done
|
||||
- state 持久化:跑到 pause_outline 后重建客户端,能 `getState` 看到 outline
|
||||
- custom stream:能收到 `draft_chunk` 等事件
|
||||
- 错误兜底:节点抛错时 status 落 error、业务表 status 同步
|
||||
- 保留 `tests/test_portable_long_text.py` / `tests/test_ai_writing_transcript_endpoint.py` / `tests/test_ai_writing_session_repo.py` —— 这几个跟业务表强相关,不受迁移影响。
|
||||
|
||||
**删除测试**:
|
||||
- `tests/test_ai_writing_session_recovery.py` —— 跨 worker 恢复改由 LangGraph Server 提供,不再是 AI 写作自己的能力。
|
||||
- `tests/test_ai_writing_cleanup.py` 里关于"drain + checkpointer 降级 + SSE 序列化"的几个用例 —— 这些机制已删,对应用例失效。
|
||||
|
||||
**灰度策略**:
|
||||
- **前端开关**:`VITE_AI_WRITING_BACKEND` = `legacy | langgraph`,默认 `legacy`
|
||||
- `legacy` → 走老的 `fetch + EventSource + /resume`
|
||||
- `langgraph` → 走新 `useAIWritingStream`
|
||||
- **后端**:旧路由(`/start /stream /resume /state`)保留两个发版周期,逐步下线
|
||||
- **数据兼容**:业务表 schema 完全不变,两条路径都用同一张表
|
||||
- **回滚成本**:改回 `VITE_AI_WRITING_BACKEND=legacy` 重新部署即可,无 DB migration
|
||||
|
||||
**通过条件**:
|
||||
- 灰度环境跑 100+ 写作流程,对比新旧两条路径的成功率、SSE 中断率、resume 成功率
|
||||
- 新路径在 worker=4 部署下 SSE 中断率 = 0(旧路径基线约 5~15%)
|
||||
|
||||
---
|
||||
|
||||
## 四、关键设计决策与权衡
|
||||
|
||||
### 4.1 为什么不让 LangGraph checkpoint 替代业务表?
|
||||
|
||||
LangGraph checkpoint 存的是"图执行状态",业务表存的是"产品形态"。两者职责不同:
|
||||
- **会话列表筛选/排序**:业务表 `WHERE user_id = ? AND status = ? ORDER BY updated_at` —— LangGraph checkpoint 没法这样查(它是 `(thread_id, checkpoint_ns)` 主键)
|
||||
- **全文搜索 draft**:业务表 `LIKE '%关键词%'` —— checkpoint 是序列化 blob
|
||||
- **transcript 自定义 UI 协议**:前端组装的时间线 JSON,跟 graph state 不一一对应
|
||||
|
||||
**结论**:两者**并存**,`thread_id` 作为外键。这是主聊天的做法(`threads_meta` + LangGraph checkpoint),AI 写作迁完后也是同样的形态。
|
||||
|
||||
### 4.2 为什么 graph 节点里直接调 repo,不发到外面消费?
|
||||
|
||||
考虑过"节点只 emit custom event,路由层订阅事件后写业务表" —— 但这又把状态分散回路由层了。
|
||||
|
||||
直接在节点里调 `await repo.save_draft(...)`:
|
||||
- 节点拿得到 `thread_id`(== session_id == business id),主键一致
|
||||
- `repo` 是单例(DB 连接池),不依赖 per-session 字典
|
||||
- 写入失败包 try/except 即可(草稿落 state 比落 DB 更关键,DB 失败不阻断流程)
|
||||
|
||||
### 4.3 为什么用 custom stream 而不是 messages-tuple?
|
||||
|
||||
writer_draft 输出是大段 markdown,强行包成 `AIMessage` 后:
|
||||
- 前端要把它从消息列表里拎出来当草稿渲染(混杂)
|
||||
- 大纲 / 审核结果是结构化 JSON,更不像聊天消息
|
||||
- progress_events / notice / draft_chunk / outline_chunk 几种事件类型,全塞 message 通道得发明 metadata convention
|
||||
|
||||
custom stream 是为这种"非聊天但需要流式 push"场景设计的,匹配度更高。
|
||||
|
||||
### 4.4 在 Server 进程里跑 graph,资源够用吗?
|
||||
|
||||
LangGraph Server 是独立 Python 进程,跟 Gateway 共享同一台机器但不共享内存。新增一个 assistant 的常驻开销 ~10 MB(graph 编译后的 schema 缓存),每个并发会话峰值 ~50 MB(LLM client + state)。
|
||||
|
||||
实测建议:在阶段 0 之后跑一次 10 个并发的压测,观察 Server RSS 增量。若一台机器 4 GB 内存,理论支撑 60~80 并发 AI 写作,足够 50 人规模团队使用。
|
||||
|
||||
---
|
||||
|
||||
## 五、风险清单
|
||||
|
||||
| # | 风险 | 缓解 |
|
||||
|---|---|---|
|
||||
| R1 | LangGraph Server 升级版本时 `interrupt()` / `Command(resume=...)` 协议有 breaking change | 在 `pyproject.toml` 里 pin `langgraph` minor 版本;升级前先在阶段 0 那个 probe 跑一遍冒烟 |
|
||||
| R2 | 前端 `useStream` 的 `reconnectOnMount` 行为跟自写 EventSource 不完全一致,刷新页面后某些情况下不会自动续接 SSE | 阶段 3 手动测刷新场景;如不达预期,配合 `client.threads.getState()` 拉一次 state 兜底 |
|
||||
| R3 | 节点里 `get_stream_writer()` 在单测里没有 LangGraph runtime → 抛错 | 节点 wrap `try: writer = get_stream_writer() except: writer = lambda x: None` |
|
||||
| R4 | 多用户场景下 `langgraph_auth.py` 没对 ai_writing assistant 做隔离,可能跨用户拿到别人的 thread state | 阶段 4 一并配置 auth;新增专项测试 |
|
||||
| R5 | `client.threads.delete()` 是异步删,admin 清理脚本调完后立即查 state 还能短暂查到 → `skipped_active` 误判 | 删之前先 lock thread 或调用 `client.runs.cancel()` 强制收敛 |
|
||||
| R6 | LangGraph Server 自带 checkpoint 仍然是 sqlite(`langgraph.json` 里配的),在容器重启时跟 AI 写作原有的 `.deer-flow/data/checkpoints.db` 是同一个文件 → schema 共用 | 阶段 1 之前先确认主 checkpointer 配置路径,避免双开 sqlite 文件锁竞争 |
|
||||
| R7 | 现有用户在迁移窗口期发起的会话,灰度切到新路径后无法续跑(state 在旧 sqlite,新路径查不到) | 迁移前 1 小时禁止新会话;或写一个一次性脚本把旧 checkpointer 的 thread 复制到主 checkpointer |
|
||||
|
||||
---
|
||||
|
||||
## 六、工时与里程碑
|
||||
|
||||
| 阶段 | 工时 | 可独立交付 |
|
||||
|---|---|---|
|
||||
| 阶段 0:可行性预演 | 0.5 d | ✅ |
|
||||
| 阶段 1:graph 注册 | 0.5 d | ❌(后端只生效,前端没接) |
|
||||
| 阶段 2:节点改造 | 1.5 d | ❌ |
|
||||
| 阶段 3:前端接入 | 1 d | ✅(带开关上灰度) |
|
||||
| 阶段 4:业务表写入解耦 | 0.5 d | ❌(跟阶段 3 合并发) |
|
||||
| 阶段 5:transcript | 0.2 d | ✅ |
|
||||
| 阶段 6:admin 清理改造 | 0.3 d | ✅ |
|
||||
| 阶段 7:删冗余 | 0.3 d | ✅(独立 PR,灰度后再发) |
|
||||
| 阶段 8:测试 + 灰度 | 1 d | ✅ |
|
||||
| **核心工时** | **5.8 d** | |
|
||||
| **+ 50% buffer** | **8.7 d ≈ 9 d** | |
|
||||
|
||||
**建议排期**:
|
||||
- 第 1 周:阶段 0 + 1 + 2(后端完整、本地能跑通)
|
||||
- 第 2 周:阶段 3 + 4 + 5 + 6(前端 + 业务集成)
|
||||
- 第 3 周:阶段 8(测试 + 灰度)
|
||||
- 灰度 1~2 周后:阶段 7(删冗余)
|
||||
|
||||
---
|
||||
|
||||
## 七、回滚预案
|
||||
|
||||
每个阶段都设计为可独立回滚:
|
||||
|
||||
| 场景 | 回滚动作 |
|
||||
|---|---|
|
||||
| 阶段 3 上线后发现 SSE 不稳 | 前端开关 `VITE_AI_WRITING_BACKEND=legacy`,重新部署,0 dataloss |
|
||||
| 阶段 7 删冗余代码后发现新路径有问题 | 从 git revert 删除 commit,恢复旧路由代码;业务表/checkpoint 不受影响 |
|
||||
| 阶段 1 注册 assistant 后 LangGraph Server 起不来 | 从 `langgraph.json` 移除 `ai_writing` 那一行,重启 Server,回到迁移前状态 |
|
||||
|
||||
**数据兼容承诺**:
|
||||
- 业务表 schema 完全不变
|
||||
- `thread_id` (== `ai_writing_sessions.id`) 含义保持一致
|
||||
- transcript 列含义不变
|
||||
|
||||
**最坏回滚**:把 `VITE_AI_WRITING_BACKEND` 改回 `legacy`、`langgraph.json` 删 `ai_writing` 那行,回到迁移前的"方案 C/F/B 路径"。已经走完的会话留在新 checkpoint 里成"孤儿",但业务表里的草稿和审核结果**完整无损**。
|
||||
|
||||
---
|
||||
|
||||
## 八、迁移成功后能享受的"免费红利"
|
||||
|
||||
1. **完整 history API**:`client.threads.getHistory(thread_id)` 拿到每一步 checkpoint,做"回到某一步重试"非常便宜
|
||||
2. **time travel**:用户能从任意 checkpoint 分叉重写
|
||||
3. **跟主聊天共享基础设施**:升级 LangGraph 版本时一并升级,不用维护两套 SSE / checkpoint 协议
|
||||
4. **天然支持水平扩容**:哪天上 K8s 多副本部署,AI 写作跟主聊天一样开箱即用
|
||||
5. **天然支持 IM 渠道接入**:`app/channels/` 那套 Feishu / Slack 实现是 `langgraph-sdk` 的,迁完后 AI 写作也能在 IM 里用,零额外代码
|
||||
|
||||
---
|
||||
|
||||
## 九、相关文件索引(迁移前后参照)
|
||||
|
||||
### 现状(迁前)
|
||||
- `app/gateway/routers/ai_writing.py:148-176` —— 8 个模块级 dict + drain task
|
||||
- `packages/harness/deerflow/agents/ai_writing/graph.py:370-538` —— 自管 checkpointer
|
||||
- `frontend-web/src/open-canvas/api/ai-writing.ts` —— 自写 fetch + EventSource
|
||||
|
||||
### 参照(主聊天迁后形态)
|
||||
- `frontend-web/src/core/threads/hooks.ts:207` —— `useStream` 用法
|
||||
- `frontend-web/src/core/api/api-client.ts` —— LangGraph SDK Client 初始化
|
||||
- `offline-backend-20260512/backend/langgraph.json` —— assistant 注册位置
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/` —— graph 工厂参考写法
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/runtime/checkpointer/async_provider.py` —— 共享 checkpointer 入口
|
||||
|
||||
### 目标(迁后)
|
||||
- 新增:`packages/harness/deerflow/agents/ai_writing/graph.py:make_ai_writing_graph`
|
||||
- 新增:`frontend-web/src/open-canvas/hooks/useAIWritingStream.ts`
|
||||
- 删除:`app/gateway/routers/ai_writing.py` 中的 `/start /stream /resume /state` 路由 + 8 个 dict
|
||||
- 删除:`frontend-web/src/open-canvas/api/ai-writing.ts` 中的 `startWriting / streamSession / resume` 全部 fetch 调用
|
||||
- 简化:`app/gateway/ai_writing_cleanup.py`(改用 SDK client 删 thread)
|
||||
|
||||
---
|
||||
|
||||
## 十、决策摘要(给 PM / 维护者)
|
||||
|
||||
> **TL;DR**
|
||||
> 1. 主聊天不受多 worker 影响,是因为它把状态托管给了独立的 LangGraph Server 进程,前端直接通过 SDK 跟 Server 通信。AI 写作要做同样的事。
|
||||
> 2. 改造范围:后端 graph 注册 + 节点流通道改造 + 前端 hook 换 SDK + 业务表写入解耦。**核心约 6 人日,含 buffer 9 人日**。
|
||||
> 3. 业务表、节点逻辑、4 个干预点、UI 形态全部保留,**用户感知层面零变化**。
|
||||
> 4. 改造完一次性获得"刷新不丢进度、多 worker 不裂、跟主聊天共享基础设施、IM 渠道免费支持"等长期红利。
|
||||
> 5. 建议跟"方案 C"并行:先按 [ai-writing-说明.md](./ai-writing-说明.md) 上方案 C 应急止血,本方案作为长期改造按周排期。两者不冲突,方案 C 上线即受益,本方案完工后可下线方案 C 的双进程配置。
|
||||
@@ -0,0 +1,787 @@
|
||||
# AI 写作 — 故障排查与解决方案说明
|
||||
|
||||
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
|
||||
>
|
||||
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
|
||||
>
|
||||
> **更新日期**:2026-05-20
|
||||
|
||||
---
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
### 起因
|
||||
内网 Docker 部署后,AI 写作出现两类报错:
|
||||
|
||||
| 接口 | 报错 | 频率 |
|
||||
|---|---|---|
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
|
||||
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
|
||||
|
||||
### 这两个接口的作用
|
||||
|
||||
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
|
||||
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
|
||||
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
|
||||
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
|
||||
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
|
||||
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
|
||||
|
||||
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
|
||||
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
|
||||
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
|
||||
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
|
||||
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
|
||||
- MySQL `TEXT` 列上限 64 KB
|
||||
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
|
||||
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
|
||||
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
|
||||
|
||||
### 根因 2:内存中的会话状态在多 worker 部署下隔离
|
||||
后端用了大量模块级 dict 维护会话状态:
|
||||
|
||||
```python
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 文章类型列表
|
||||
```
|
||||
|
||||
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
|
||||
|
||||
```
|
||||
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
|
||||
graph 推进任务跑在 worker A
|
||||
T1: GET /stream → 落 worker B,没有 QueueA 的引用
|
||||
→ 触发 _recover_session,从 sqlite 重建 QueueB
|
||||
→ SSE 推 QueueB 但事件都在 QueueA 里
|
||||
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
|
||||
```
|
||||
|
||||
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
|
||||
|
||||
### 根因 3:Graph 用了内存 checkpointer
|
||||
原代码:
|
||||
```python
|
||||
# ai_writing/graph.py
|
||||
checkpointer = MemorySaver()
|
||||
```
|
||||
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
|
||||
|
||||
### 根因 4:路径配错
|
||||
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
|
||||
|
||||
---
|
||||
|
||||
## 三、已落地的修复(已合入 dhk-1 分支)
|
||||
|
||||
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
|
||||
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
|
||||
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
|
||||
|
||||
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
|
||||
|
||||
### 修复 2:Checkpointer 改为持久化 SQLite
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
|
||||
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
|
||||
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
|
||||
|
||||
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
|
||||
|
||||
### 修复 3:跨 worker / 进程重启会话恢复
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
|
||||
|
||||
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
|
||||
|
||||
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
|
||||
|
||||
### 修复 4:多层失败兜底,保证写作流程能继续
|
||||
|
||||
| 改动 | 作用 |
|
||||
|---|---|
|
||||
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
|
||||
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
|
||||
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
|
||||
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
|
||||
|
||||
### 修复 5:定期清理 + admin 触发接口
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
|
||||
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
|
||||
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
|
||||
|
||||
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
|
||||
|
||||
### 修复 6:优雅关闭 — In-flight Drain
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
|
||||
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
|
||||
|
||||
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
|
||||
|
||||
### 修复 7:多 worker 配置陷阱检测
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
|
||||
|
||||
下次启动容器看 docker logs 第一屏就能看到强提醒。
|
||||
|
||||
---
|
||||
|
||||
## 四、当前状态评估
|
||||
|
||||
### transcript 500
|
||||
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
|
||||
|
||||
### resume 报错
|
||||
⚠️ **大幅缓解**:
|
||||
- 接口本身(在 worker > 1 下)能跑通
|
||||
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
|
||||
|
||||
❌ **未彻底解决**:
|
||||
- SSE 事件流仍 per-worker 隔离
|
||||
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
|
||||
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
|
||||
|
||||
### 「写作流程能不能跑完」
|
||||
✅ **能**。最坏情况下:
|
||||
- 用户感受:进度断断续续,需要刷新几次
|
||||
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
|
||||
|
||||
---
|
||||
|
||||
## 五、剩余风险
|
||||
|
||||
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
|
||||
|
||||
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
|
||||
|
||||
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
|
||||
|
||||
**解决方案见第六章**。
|
||||
|
||||
### 风险 B:容器优雅关闭时 in-flight 写作进度
|
||||
|
||||
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
|
||||
|
||||
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
|
||||
|
||||
### 风险 C:节点级别 LLM 抽风
|
||||
|
||||
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
|
||||
|
||||
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
|
||||
|
||||
**改进方向**(不在本次修复范围):
|
||||
- 给节点加 `tenacity.retry` 重试装饰器
|
||||
- 节点抛错时把已生成的部分塞进 progress_events
|
||||
|
||||
### 风险 D:跨 worker 恢复时降级丢失
|
||||
|
||||
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
|
||||
|
||||
| 数据 | 默认值 | 影响 |
|
||||
|---|---|---|
|
||||
| `_session_model_names` | `""` | 退回默认模型 |
|
||||
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
|
||||
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
|
||||
|
||||
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
|
||||
|
||||
---
|
||||
|
||||
## 六、Worker > 1 限制下的解决方案路径
|
||||
|
||||
### 三条路径对比
|
||||
|
||||
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|
||||
|---|---|---|---|---|
|
||||
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
|
||||
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
|
||||
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
|
||||
|
||||
### 方案 B:Redis Pub/Sub(最彻底)
|
||||
|
||||
**改造点**:
|
||||
|
||||
```
|
||||
之前(per-worker memory dict) 之后(Redis 共享)
|
||||
───────────────────────── ─────────────────────
|
||||
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
|
||||
_session_pending_pause → Redis Hash "ai_writing:pause"
|
||||
_session_model_names → Redis Hash "ai_writing:meta:model"
|
||||
_session_interventions → Redis Hash + JSON
|
||||
_session_repos / _session_user_ids → Redis Hash
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
|
||||
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
|
||||
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
|
||||
|
||||
**优点**:根治。任何 worker 数、任何部署形态都能跑。
|
||||
|
||||
**缺点**:
|
||||
- 引入 Redis 依赖(运维负担)
|
||||
- 代码改动较大(约 150 行)
|
||||
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
|
||||
|
||||
### 方案 C:AI 写作独立服务(次推荐)
|
||||
|
||||
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
|
||||
|
||||
```bash
|
||||
# 主 gateway(多 worker,处理其他业务)
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
|
||||
|
||||
# AI 写作专用单 worker
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
|
||||
```
|
||||
|
||||
nginx 配置:
|
||||
```nginx
|
||||
location /api/ai-writing/ {
|
||||
proxy_pass http://localhost:8002;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 零代码改动
|
||||
- 不引入新依赖
|
||||
- AI 写作彻底单 worker,问题根除
|
||||
|
||||
**缺点**:
|
||||
- 容器内多一个进程
|
||||
- 内存占用 +1 份 Python runtime(约 200 MB)
|
||||
|
||||
### 方案 F:多容器单 worker + nginx sticky
|
||||
|
||||
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
|
||||
|
||||
nginx:
|
||||
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
|
||||
```
|
||||
|
||||
```nginx
|
||||
upstream gateway_pool {
|
||||
ip_hash;
|
||||
server gateway-1:8001;
|
||||
server gateway-2:8001;
|
||||
server gateway-3:8001;
|
||||
server gateway-4:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:零代码改动,标准做法
|
||||
|
||||
**缺点**:
|
||||
- 改动 docker-compose
|
||||
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
|
||||
|
||||
### 推荐顺序
|
||||
|
||||
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
|
||||
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
|
||||
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
|
||||
|
||||
---
|
||||
|
||||
## 六-A、方案 C 在 Docker 容器内的详细实施指南
|
||||
|
||||
> **目标**:在不引入新依赖、不动业务代码、不拆容器的前提下,让 AI 写作走专门的单 worker 进程,其它业务继续走多 worker。
|
||||
>
|
||||
> **基础设施**:本项目镜像基于 `python:3.12-slim-bookworm`,用 `uv` 管理依赖,Dockerfile 位于 `offline-backend-20260512/backend/Dockerfile`,默认入口是
|
||||
>
|
||||
> ```
|
||||
> CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
> ```
|
||||
|
||||
### 总览:单容器双 uvicorn 进程
|
||||
|
||||
```
|
||||
┌─────────────── nginx (容器外或前置) ───────────────┐
|
||||
│ │
|
||||
/api/ai-writing/ ──┼──► container:8002 (uvicorn --workers 1) ───► AI 写作专用
|
||||
其余 /api/* ──┼──► container:8001 (uvicorn --workers N) ───► 其他业务
|
||||
└────────────────────────────────────────────────────┘
|
||||
▲
|
||||
│ (两个进程跑在同一个容器里,共享 .deer-flow 卷
|
||||
│ /同一个 MySQL /同一个 sqlite checkpoints.db)
|
||||
```
|
||||
|
||||
两进程都用 **同一个 `app.gateway.app:app`**,区别只在:
|
||||
- `8002` 单 worker,专门接 `/api/ai-writing/*`
|
||||
- `8001` 多 worker,接其他所有路由
|
||||
- 容器内用 `supervisord` 同时托管这两个 uvicorn
|
||||
|
||||
### 步骤 1:在镜像里加 supervisord
|
||||
|
||||
编辑 `offline-backend-20260512/backend/Dockerfile`,给 **runtime stage**(第 67 行 `FROM python:3.12-slim-bookworm` 之后)补一段:
|
||||
|
||||
```dockerfile
|
||||
# 安装 supervisord(约 4 MB,纯 Python,无需编译)
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends supervisor \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
```
|
||||
|
||||
如果内网无法访问 debian 源,可用现有的 `APT_MIRROR` build-arg:
|
||||
|
||||
```bash
|
||||
docker build \
|
||||
--build-arg APT_MIRROR=mirrors.aliyun.com \
|
||||
-f offline-backend-20260512/backend/Dockerfile \
|
||||
-t deerflow-gateway:dual .
|
||||
```
|
||||
|
||||
### 步骤 2:写 supervisord 配置
|
||||
|
||||
新建 `offline-backend-20260512/backend/deploy/supervisord.conf`:
|
||||
|
||||
```ini
|
||||
[supervisord]
|
||||
nodaemon=true ; 容器主进程,必须前台
|
||||
user=root
|
||||
logfile=/dev/null ; 让子进程日志直接走 stdout/stderr,由 docker logs 接管
|
||||
logfile_maxbytes=0
|
||||
pidfile=/tmp/supervisord.pid
|
||||
|
||||
; ─── 主 Gateway:多 worker,处理除 ai-writing 以外的所有业务 ───
|
||||
[program:gateway-main]
|
||||
command=uv run --no-sync uvicorn app.gateway.app:app
|
||||
--host 0.0.0.0 --port 8001
|
||||
--workers %(ENV_GATEWAY_MAIN_WORKERS)s
|
||||
--proxy-headers --forwarded-allow-ips=*
|
||||
directory=/app/backend
|
||||
environment=PYTHONPATH=".",AI_WRITING_DEDICATED_PORT="8002"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=35 ; 给 lifespan 的 30s drain 留点余量
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
|
||||
; ─── AI 写作专用:单 worker,处理 /api/ai-writing/* ───
|
||||
[program:gateway-aiwriting]
|
||||
command=uv run --no-sync uvicorn app.gateway.app:app
|
||||
--host 0.0.0.0 --port 8002
|
||||
--workers 1
|
||||
--proxy-headers --forwarded-allow-ips=*
|
||||
directory=/app/backend
|
||||
environment=PYTHONPATH=".",DEER_FLOW_ROLE="ai-writing"
|
||||
autostart=true
|
||||
autorestart=true
|
||||
stopasgroup=true
|
||||
killasgroup=true
|
||||
stopsignal=TERM
|
||||
stopwaitsecs=35
|
||||
stdout_logfile=/dev/stdout
|
||||
stdout_logfile_maxbytes=0
|
||||
stderr_logfile=/dev/stderr
|
||||
stderr_logfile_maxbytes=0
|
||||
```
|
||||
|
||||
**几个关键点**:
|
||||
- `nodaemon=true` 让 supervisord 自己变成 PID 1,docker stop 发的 SIGTERM 才能正确传给它,它再传给两个 uvicorn 子进程
|
||||
- `stopasgroup=true` + `killasgroup=true` 确保 uvicorn 多 worker 模式下所有子进程一起退
|
||||
- `stopwaitsecs=35` 必须大于 lifespan 的 30s drain 超时,否则 in-flight 写作会被 SIGKILL
|
||||
- `%(ENV_GATEWAY_MAIN_WORKERS)s` 让主进程 worker 数走环境变量,下面会传
|
||||
|
||||
把这个配置文件 COPY 进镜像(在 Dockerfile runtime stage `COPY --from=builder /app/backend ./backend` 之后加):
|
||||
|
||||
```dockerfile
|
||||
COPY backend/deploy/supervisord.conf /etc/supervisor/conf.d/deerflow.conf
|
||||
```
|
||||
|
||||
### 步骤 3:把 Dockerfile 入口换成 supervisord
|
||||
|
||||
把 Dockerfile **runtime stage 最后一行** 改成:
|
||||
|
||||
```dockerfile
|
||||
# 之前
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
|
||||
# 之后
|
||||
EXPOSE 8001 8002 2024
|
||||
CMD ["supervisord", "-c", "/etc/supervisor/conf.d/deerflow.conf"]
|
||||
```
|
||||
|
||||
> **`dev` stage 不用改**:开发环境本来就是单 worker、reload 模式,方案 C 是为生产准备的,dev stage 维持原 CMD。
|
||||
|
||||
### 步骤 4:暴露端口 / 挂卷 / 环境变量
|
||||
|
||||
启动容器时:
|
||||
|
||||
```bash
|
||||
docker run -d --name deerflow-gateway \
|
||||
-p 8001:8001 \
|
||||
-p 8002:8002 \
|
||||
-e GATEWAY_MAIN_WORKERS=4 \
|
||||
-e DEER_FLOW_CONFIG_PATH=/app/config.yaml \
|
||||
-v /your/host/path/.deer-flow:/app/.deer-flow \
|
||||
-v /your/host/path/config.yaml:/app/config.yaml:ro \
|
||||
-v /your/host/path/extensions_config.json:/app/extensions_config.json:ro \
|
||||
deerflow-gateway:dual
|
||||
```
|
||||
|
||||
**两个进程必须共享 `.deer-flow` 卷** —— 因为 `checkpoints.db` 在里面(`offline-backend-20260512/config.yaml` 里配的是相对路径 `.deer-flow/data/checkpoints.db`)。两个进程跑在同一个容器内,天然共享文件系统,**这一步不用做任何额外配置**,只要确认数据卷挂出去就行。
|
||||
|
||||
> ⚠️ 即便 8002 是单 worker,仍然推荐把 checkpointer 持久化到磁盘,因为:① 容器重启会重建进程;② 如果哪天临时改回多容器/多 worker,数据不丢;③ 跟主 gateway 共享 checkpoint,未来想做读副本也方便。
|
||||
|
||||
### 步骤 5:nginx 路由
|
||||
|
||||
如果你前面已经有 nginx(容器外或者另一个容器),把 AI 写作的路由独立出来:
|
||||
|
||||
```nginx
|
||||
upstream gateway_main { server gateway-host:8001; }
|
||||
upstream gateway_aiwriting { server gateway-host:8002; }
|
||||
|
||||
server {
|
||||
listen 80;
|
||||
server_name deerflow.example.com;
|
||||
|
||||
# SSE 长连接必须关 buffering
|
||||
proxy_buffering off;
|
||||
proxy_request_buffering off;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Connection "";
|
||||
|
||||
# AI 写作 → 单 worker 后端
|
||||
location /api/ai-writing/ {
|
||||
proxy_pass http://gateway_aiwriting;
|
||||
proxy_read_timeout 3600s; # graph 跑得久,给足时间
|
||||
proxy_send_timeout 3600s;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 其它一切 API → 多 worker 后端
|
||||
location /api/ {
|
||||
proxy_pass http://gateway_main;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# 前端静态资源不变
|
||||
location / {
|
||||
proxy_pass http://frontend:5174;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**`proxy_buffering off` 是必须的** —— 不关 buffering,nginx 会把 SSE 流缓冲到响应结束才一次性吐给前端,等于把流式写作变成「等几分钟啥都看不到 → 一下吐一整篇」。
|
||||
|
||||
如果前端是 Vite 直连后端(开发态没走 nginx),就只需要把 `frontend-web` 里 AI 写作相关的 `fetch`/`EventSource` 改成 `:8002`,但更推荐 **始终走 nginx**,对前端透明。
|
||||
|
||||
### 步骤 6:如果你用 docker-compose
|
||||
|
||||
完整 compose 示例:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
gateway:
|
||||
image: deerflow-gateway:dual
|
||||
container_name: deerflow-gateway
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
GATEWAY_MAIN_WORKERS: "4"
|
||||
DEER_FLOW_CONFIG_PATH: /app/config.yaml
|
||||
ports:
|
||||
- "8001:8001"
|
||||
- "8002:8002" # 如果有外层 nginx,且 nginx 跟 gateway 同网段,可去掉这行只走内网
|
||||
volumes:
|
||||
- ./data/deer-flow:/app/.deer-flow
|
||||
- ./config.yaml:/app/config.yaml:ro
|
||||
- ./extensions_config.json:/app/extensions_config.json:ro
|
||||
healthcheck:
|
||||
test: ["CMD", "sh", "-c", "curl -fsS http://localhost:8001/health && curl -fsS http://localhost:8002/health"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
start_period: 30s
|
||||
stop_grace_period: 45s # 给 supervisord 的 35s + 余量,让两路 drain 跑完
|
||||
|
||||
nginx:
|
||||
image: nginx:1.27-alpine
|
||||
depends_on: [gateway]
|
||||
ports: ["80:80"]
|
||||
volumes:
|
||||
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
|
||||
```
|
||||
|
||||
`stop_grace_period: 45s` 这一行很关键 —— docker-compose 默认只给 10s,到点直接 SIGKILL,in-flight 写作会全部丢。设到 45s 等于 supervisord 的 35s + 10s 余量。
|
||||
|
||||
### 步骤 7:验证(按这个清单逐项过)
|
||||
|
||||
```bash
|
||||
# 1) 两个进程都起来了
|
||||
docker exec deerflow-gateway sh -c 'ps -ef | grep -E "uvicorn|supervisord" | grep -v grep'
|
||||
# 期望看到:
|
||||
# 1 root supervisord -c /etc/supervisor/conf.d/deerflow.conf
|
||||
# N root uvicorn app.gateway.app:app --port 8001 --workers 4
|
||||
# M root uvicorn app.gateway.app:app --port 8002 --workers 1
|
||||
# (8001 那一行会有 N 个 worker 子进程,8002 那一行只有 1 个)
|
||||
|
||||
# 2) 8002 只有 1 个 worker(这是方案 C 的命脉)
|
||||
docker exec deerflow-gateway sh -c 'pgrep -f "port 8002" | wc -l'
|
||||
# 期望 = 2(1 个 master + 1 个 worker);如果 > 2,单 worker 没生效
|
||||
|
||||
# 3) 8001 不再警告多 worker
|
||||
docker logs deerflow-gateway 2>&1 | grep -i 'WEB_CONCURRENCY'
|
||||
# 期望:没有 ERROR 警告(因为 8001 走的是其他业务,8002 走的是单 worker)
|
||||
|
||||
# 4) 8002 进程能看到 ai_writing 持久化日志
|
||||
docker logs deerflow-gateway 2>&1 | grep 'AsyncSqliteSaver'
|
||||
# 期望至少 1 行:ai_writing: 使用 AsyncSqliteSaver (...)
|
||||
|
||||
# 5) /api/ai-writing/* 走 8002,其他走 8001
|
||||
curl -sI http://deerflow.example.com/api/ai-writing/health # 看 nginx access log 落到 gateway_aiwriting
|
||||
curl -sI http://deerflow.example.com/api/models # 看落到 gateway_main
|
||||
|
||||
# 6) 真跑一次 AI 写作,确认全程没有「resume 404 / 进度断流」
|
||||
```
|
||||
|
||||
### 步骤 8:监控两个进程的健康
|
||||
|
||||
supervisord 自带 `unix_http_server` + `supervisorctl`,但对容器化场景更直接的是看 docker logs:
|
||||
|
||||
```bash
|
||||
# 实时看两个进程的日志
|
||||
docker logs -f deerflow-gateway 2>&1 | grep -E '(gateway-main|gateway-aiwriting)'
|
||||
|
||||
# 进程级状态
|
||||
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf status
|
||||
# 期望:
|
||||
# gateway-main RUNNING pid 7, uptime 0:05:12
|
||||
# gateway-aiwriting RUNNING pid 18, uptime 0:05:12
|
||||
|
||||
# 单独重启 AI 写作进程(不影响主业务,前端只是 AI 写作页几秒不可用)
|
||||
docker exec deerflow-gateway supervisorctl -c /etc/supervisor/conf.d/deerflow.conf restart gateway-aiwriting
|
||||
```
|
||||
|
||||
### 资源开销(实测预估)
|
||||
|
||||
| 项 | 单 worker 镜像 | 双进程镜像 | 增量 |
|
||||
|---|---|---|---|
|
||||
| 镜像大小 | 基线 | +约 5 MB | supervisord 依赖 |
|
||||
| 启动时 RSS | ~250 MB | ~450 MB | 多一份 Python runtime + 模型客户端预加载 |
|
||||
| 稳态 RSS | 视业务而定 | +约 200 MB | 第二个 worker 的常驻内存 |
|
||||
| CPU | 不变 | 不变 | 单 worker 进程空闲基本不占 CPU |
|
||||
|
||||
**结论**:内存额外占用约 200 MB,对 4 GB 以上的部署机基本无压力。
|
||||
|
||||
### 常见坑位
|
||||
|
||||
1. **`stop_grace_period` 没改大 → 重启时 in-flight 写作被秒杀**
|
||||
表现:每次 `docker compose restart` 都有用户报告「写到一半没了」。修复见步骤 6。
|
||||
|
||||
2. **8002 没暴露 / nginx 没改 → 所有请求还是落 8001**
|
||||
表现:症状跟没做方案 C 一样。验证步骤 5 的 curl 即可发现。
|
||||
|
||||
3. **两路用了不同的 `config.yaml`**
|
||||
表现:8001 跟 8002 行为不一致。务必让两个进程**只读同一个文件**(在 compose 里只挂一次卷)。
|
||||
|
||||
4. **`.deer-flow` 卷没挂 → 8002 重启后会话全丢**
|
||||
表现:用户刷新页面,AI 写作历史空白。修复:步骤 4 的 `-v` 必须有。
|
||||
|
||||
5. **AI 写作进程崩溃后 supervisord 反复重启 → 日志爆炸**
|
||||
表现:`gateway-aiwriting` 状态在 RUNNING/EXITED/BACKOFF 间反复。
|
||||
修复:先 `supervisorctl tail gateway-aiwriting stderr` 看真正原因(多半是 sqlite 权限或配置文件路径),再修,**别盲目调 `autorestart=false`**。
|
||||
|
||||
6. **想做 K8s 部署但还想用方案 C**
|
||||
不要把两个进程塞一个 Pod,应该直接拆成两个 Deployment、共享 PVC(checkpoints.db)。这种情况下方案 C 已经变形成方案 F 的子集,建议直接走方案 F。
|
||||
|
||||
### 回滚方案
|
||||
|
||||
如果上线后发现问题,回滚只需要 1 步:
|
||||
|
||||
```bash
|
||||
# 把 Dockerfile 最后一行换回原来的 CMD,重新 build + 重启
|
||||
CMD ["sh", "-c", "cd backend && PYTHONPATH=. uv run --no-sync uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001"]
|
||||
```
|
||||
|
||||
数据完全兼容(checkpoints.db、MySQL 表结构都跟单进程模式一致),**不会丢任何用户数据**,只是回到「resume 偶发 404、SSE 断断续续」的旧体验。
|
||||
|
||||
---
|
||||
|
||||
## 七、运维操作清单
|
||||
|
||||
### 部署前验证
|
||||
|
||||
```bash
|
||||
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
|
||||
docker exec <gateway> ls -la /app/.deer-flow/data/
|
||||
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
|
||||
|
||||
# 2) 确认 alembic 迁移跑完了
|
||||
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
|
||||
# 应该看到包含 20260519_02 的 head
|
||||
|
||||
# 3) 确认 transcript 列已扩容
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
|
||||
# Type 列应该是 longtext
|
||||
|
||||
# 4) 查 worker 数
|
||||
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
|
||||
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
|
||||
```
|
||||
|
||||
### 启动时关注的日志
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
|
||||
```
|
||||
|
||||
期望看到:
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
|
||||
|
||||
警告:
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
|
||||
|
||||
### 用户报告「会话丢失」时的排查
|
||||
|
||||
```bash
|
||||
# 1) 查 sqlite 里这个 session 的快照
|
||||
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
|
||||
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
|
||||
|
||||
# 2) 查 MySQL 里这条会话
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
|
||||
|
||||
# 3) 看 gateway 日志里这个 session 的活动轨迹
|
||||
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
|
||||
|
||||
# 4) 看是否有 recover_session 调用
|
||||
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
|
||||
|
||||
# 5) 看是否有节点异常
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
|
||||
```
|
||||
|
||||
### 手动触发清理(admin only)
|
||||
|
||||
```bash
|
||||
# 用 admin 账号登录后,取 cookie,调清理接口
|
||||
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Cookie: <admin session cookie>' \
|
||||
-d '{"retention_days": 7}'
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"deleted_db": 12,
|
||||
"deleted_checkpoints": 12,
|
||||
"skipped_active": 1,
|
||||
"elapsed_seconds": 0.234,
|
||||
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
|
||||
}
|
||||
```
|
||||
|
||||
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件索引
|
||||
|
||||
### 后端
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
|
||||
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
|
||||
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
|
||||
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
|
||||
|
||||
### 前端
|
||||
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
|
||||
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
|
||||
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
|
||||
|
||||
### 测试
|
||||
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
|
||||
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
|
||||
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
|
||||
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
|
||||
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
|
||||
|
||||
总测试:**51 个,全过**。
|
||||
|
||||
---
|
||||
|
||||
## 九、变更历史
|
||||
|
||||
| 日期 | Commit | 内容 |
|
||||
|---|---|---|
|
||||
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
|
||||
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
|
||||
|
||||
---
|
||||
|
||||
## 十、决策摘要(给未来的你/同事)
|
||||
|
||||
> **如果你现在第一次接手这个问题**,按这个清单往下走:
|
||||
>
|
||||
> 1. ✅ **transcript 500 不用管** — 已彻底修复
|
||||
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
|
||||
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
|
||||
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
|
||||
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
|
||||
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
|
||||
|
||||
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
|
||||
File diff suppressed because it is too large.
Load diff
Whitespace-only changes.
@@ -0,0 +1,140 @@
|
||||
# App.tsx 路由与页面结构重构需求(首页与业务页分离)
|
||||
|
||||
## 一、需求背景
|
||||
|
||||
当前 `frontend-web/src/App.tsx` 同时承载了:
|
||||
|
||||
1. 登录/首页相关路由(如 `/login`、`/landing`)
|
||||
2. 工作台业务相关路由(如 `/workspace/chats`、`/workspace/agents`、`/workspace/scheduled/*`)
|
||||
3. 业务页所需布局与运行时包装(`WorkspaceLayout`、`ChatRuntime`)
|
||||
|
||||
这会导致 `App.tsx` 过重,职责不够单一,不利于后续维护和扩展。
|
||||
|
||||
---
|
||||
|
||||
## 二、目标(你提出的核心诉求)
|
||||
|
||||
你希望:
|
||||
|
||||
1. **除了登录与首页入口内容外**,将 `App.tsx` 中其余业务页面路由整体迁移到 `pages` 下一个新组件中。
|
||||
2. 再创建一个“承接导航/入口”的页面(或路由容器),用于放置这些导航逻辑。
|
||||
3. `App.tsx` 最终只保留顶层路由分发和基础 Provider,变得更薄。
|
||||
|
||||
> 你提到“`App.tsx 74-75 login`”,可理解为:保留登录/首页相关入口,其他工作台内容抽离。
|
||||
|
||||
---
|
||||
|
||||
## 三、重构范围
|
||||
|
||||
### 1)保留在 `App.tsx` 的内容
|
||||
|
||||
- 全局 Provider:`HashRouter`、`AppProviders`
|
||||
- 登录/首页路由:
|
||||
- `/`
|
||||
- `/login`
|
||||
- `/login/:username`
|
||||
- `/landing`
|
||||
- 404 路由(可留在 `App.tsx` 或交给新路由容器统一处理,二选一)
|
||||
|
||||
### 2)迁移出 `App.tsx` 的内容
|
||||
|
||||
将以下与工作台有关的路由从 `App.tsx` 抽离:
|
||||
|
||||
- `/workspace`(重定向)
|
||||
- `/workspace/chats`
|
||||
- `/workspace/chats/:thread_id`
|
||||
- `/workspace/agents`
|
||||
- `/workspace/agents/new`
|
||||
- `/workspace/agents/:agent_id/chats`(重定向)
|
||||
- `/workspace/agents/:agent_id/chats/:thread_id`
|
||||
- `/workspace/scheduled`
|
||||
- `/workspace/scheduled/tasks`
|
||||
- `/workspace/scheduled/runs/:run_id`
|
||||
|
||||
以及配套的包装组件职责:
|
||||
|
||||
- `WorkspaceLayout`
|
||||
- `ChatRuntime`
|
||||
|
||||
---
|
||||
|
||||
## 四、建议的新结构(方案)
|
||||
|
||||
## 方案 A(推荐):新增“业务路由容器组件”
|
||||
|
||||
在 `pages` 下新增一个路由容器组件,例如:
|
||||
|
||||
- `src/pages/WorkspaceRoutes.tsx`(或 `src/pages/AppWorkspaceRoutes.tsx`)
|
||||
|
||||
职责:
|
||||
- 专门维护 `/workspace/*` 相关路由
|
||||
- 内部统一使用 `WorkspaceLayout` 与 `ChatRuntime`
|
||||
- 输出一个可被 `App.tsx` 挂载的路由节点
|
||||
|
||||
同时在 `App.tsx` 里只保留:
|
||||
- 登录/首页/404
|
||||
- 一个 `Route path="/workspace/*" element={<WorkspaceRoutes />} />`
|
||||
|
||||
---
|
||||
|
||||
## 五、“再建立一个页面放置导航”的落地解释
|
||||
|
||||
你说的“建立一个页面放置这些导航”,可以有两种理解:
|
||||
|
||||
1. **路由容器页(推荐)**:
|
||||
- 不强调视觉 UI,重点是集中管理导航与路由跳转规则。
|
||||
- 即上面的 `WorkspaceRoutes.tsx`。
|
||||
|
||||
2. **可见的导航页(可选)**:
|
||||
- 例如新建 `WorkspaceHomePage.tsx`,展示聊天/Agents/定时任务入口按钮。
|
||||
- 路由上把 `/workspace` 指向该页,而不是直接重定向到 `/workspace/chats/new`。
|
||||
|
||||
当前从你的描述看,优先建议第 1 种(先做结构整理),之后若有产品需求再做第 2 种可见导航页。
|
||||
|
||||
---
|
||||
|
||||
## 六、实施步骤(开发任务拆解)
|
||||
|
||||
1. 新建 `WorkspaceRoutes` 组件(`pages` 目录下)。
|
||||
2. 把所有 `/workspace/*` 路由从 `App.tsx` 搬入该组件。
|
||||
3. 把 `WorkspaceLayout` 与 `ChatRuntime` 移入该组件(或抽到共享 layout/runtime 文件)。
|
||||
4. 在 `App.tsx` 中保留登录/首页路由,并挂载 `/workspace/*` 到新组件。
|
||||
5. 校验以下访问路径行为一致:
|
||||
- `/workspace` 重定向
|
||||
- chats/agents/scheduled 各页面可正常进入
|
||||
- 动态参数路由(`thread_id`、`agent_id`、`run_id`)正常
|
||||
- 404 不受影响
|
||||
6. 运行前端并做手工回归。
|
||||
|
||||
---
|
||||
|
||||
## 七、验收标准
|
||||
|
||||
1. `App.tsx` 明显瘦身,主要负责顶层入口路由。
|
||||
2. `/workspace/*` 全部由新建组件统一管理。
|
||||
3. 功能行为与改造前一致(或符合新定义)。
|
||||
4. 代码可读性提升:登录入口与业务入口职责分离。
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. 当前项目使用 `HashRouter`,路由写法需保持兼容。
|
||||
2. `Navigate` 重定向逻辑要完整迁移,避免出现死循环或空白页。
|
||||
3. `WorkspaceLayout` 与 `ChatRuntime` 的包裹层级不能错,否则可能影响侧边栏、输入区、子任务/Artifacts 状态。
|
||||
4. 若后续再加模块(例如 `/workspace/settings`),优先在 `WorkspaceRoutes` 中扩展。
|
||||
|
||||
---
|
||||
|
||||
## 九、建议的命名(可直接采用)
|
||||
|
||||
- 新增组件文件:`src/pages/WorkspaceRoutes.tsx`
|
||||
- (可选)可见导航页:`src/pages/WorkspaceHomePage.tsx`
|
||||
|
||||
---
|
||||
|
||||
## 十、后续可选优化
|
||||
|
||||
1. 将 `WorkspaceLayout` 抽到 `src/layouts/WorkspaceLayout.tsx`。
|
||||
2. 将 `ChatRuntime` 抽到 `src/runtime/ChatRuntime.tsx`。
|
||||
3. 把路由常量集中到 `src/core/router/paths.ts`,减少硬编码路径。
|
||||
@@ -0,0 +1,975 @@
|
||||
# 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/` |
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,312 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 API),两套流互不相通,快捷动作响应不出现在聊天面板。
|
||||
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、已解决的技术难题
|
||||
|
||||
| 问题 | 解法 |
|
||||
|------|------|
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -0,0 +1,950 @@
|
||||
# Open Canvas 模式需求拆解文档
|
||||
|
||||
## 1. 文档目标
|
||||
|
||||
本文档用于指导 DeerFlow 在现有架构中新增一个 `open-canvas` 风格的 Canvas 模式。
|
||||
|
||||
目标不是直接照搬 `langchain-ai/open-canvas` 的 Next.js + LangGraph.js 实现,而是在当前项目技术栈内完成一套等价能力的实现:
|
||||
|
||||
- 前端:基于 `React 19 + Vite + react-router-dom`
|
||||
- 后端:基于 `Python + FastAPI + LangGraph`
|
||||
- 模型层:支持云模型与本地模型自由配置
|
||||
- 状态层:支持结构化 artifact、版本历史、局部改写、快速动作、画布编辑
|
||||
|
||||
本文档重点是把需求拆成“可实现、可排期、可验收”的阶段性任务。
|
||||
|
||||
---
|
||||
|
||||
## 2. 最终目标
|
||||
|
||||
### 2.1 用户视角目标
|
||||
|
||||
用户进入一个新的 Canvas 工作模式后,可以获得与 open-canvas 接近的体验:
|
||||
|
||||
- 左侧是聊天区,右侧是 Canvas 编辑区
|
||||
- 可以从空白文本或代码模板快速开始
|
||||
- 可以通过聊天让 AI 生成一份结构化 artifact
|
||||
- 可以对 artifact 整体重写,也可以对选中的局部内容进行改写
|
||||
- artifact 支持版本历史切换
|
||||
- artifact 支持文本和代码两种主要类型
|
||||
- 支持快速动作,例如扩写、压缩、改风格、改语气、加注释、修 bug、换语言
|
||||
- 支持启用 Web 搜索增强
|
||||
- 支持切换模型
|
||||
- 支持使用本地模型,例如 Ollama 或 OpenAI-compatible 本地服务
|
||||
|
||||
### 2.2 工程视角目标
|
||||
|
||||
新增的 Canvas 模式需要满足以下工程原则:
|
||||
|
||||
- 不破坏现有 DeerFlow 工作区
|
||||
- 不影响现有基于 `artifacts: string[]` 的文件产物功能
|
||||
- 可逐阶段上线
|
||||
- 前后端状态协议清晰
|
||||
- 可兼容现有 LangGraph 线程、消息流、认证和模型接口
|
||||
|
||||
---
|
||||
|
||||
## 3. 范围定义
|
||||
|
||||
### 3.1 本次需求包含
|
||||
|
||||
- 前端新增 `open-canvas` 专用目录
|
||||
- 在 [PageRoutes.tsx](../src/pages/PageRoutes.tsx) 中引入新的 Canvas 路由
|
||||
- 前端新增 Canvas 页面、上下文、组件、状态管理
|
||||
- 后端新增 Canvas 专用线程状态字段
|
||||
- 后端新增 Canvas Agent 图或在现有 Agent 体系中新增 Canvas 模式分支
|
||||
- 模型配置统一为 DeerFlow 后端配置驱动
|
||||
- 支持本地大模型配置与模型列表动态下发
|
||||
- 支持结构化 artifact 的保存、读取、更新、版本切换
|
||||
|
||||
### 3.2 本次需求不包含
|
||||
|
||||
- 一次性复刻 open-canvas 全部 UI 细节
|
||||
- 完整照搬 Supabase 鉴权体系
|
||||
- 直接复用 open-canvas 的 Next.js API Route
|
||||
- 直接运行 open-canvas 的 LangGraph.js 后端
|
||||
- 首阶段就实现所有快捷动作、所有富文本效果、所有搜索增强能力
|
||||
|
||||
### 3.3 迁移原则
|
||||
|
||||
- 借鉴 open-canvas 的能力模型,不直接迁移其运行时架构
|
||||
- 前端尽量复用其交互设计和数据结构
|
||||
- 后端以 Python 实现同等能力
|
||||
- 配置以 DeerFlow 为真源,不以 open-canvas 的 TS 文件为真源
|
||||
|
||||
---
|
||||
|
||||
## 4. 总体设计思路
|
||||
|
||||
### 4.1 前端设计思路
|
||||
|
||||
在现有 `frontend-web/src` 下新增一个专用目录,例如:
|
||||
|
||||
```text
|
||||
src/
|
||||
open-canvas/
|
||||
pages/
|
||||
routes/
|
||||
components/
|
||||
contexts/
|
||||
hooks/
|
||||
types/
|
||||
api/
|
||||
utils/
|
||||
constants/
|
||||
```
|
||||
|
||||
建议将 Canvas 模式作为一个独立的页面子系统接入,而不是把现有工作区页面强行改造成 Canvas。
|
||||
|
||||
建议路由形式:
|
||||
|
||||
- `/canvas`
|
||||
- `/canvas/:threadId`
|
||||
|
||||
也可以挂在现有页面布局下,例如:
|
||||
|
||||
- `/workspace/canvas`
|
||||
- `/workspace/canvas/:threadId`
|
||||
|
||||
推荐优先采用独立一级功能路由:
|
||||
|
||||
- 逻辑更清晰
|
||||
- 状态隔离更容易
|
||||
- 不容易污染现有 `workspace` 功能
|
||||
|
||||
### 4.2 后端设计思路
|
||||
|
||||
后端不建议直接改造现有通用 Agent 去同时承担全部 Canvas 行为,而是建议采用以下两种方案之一:
|
||||
|
||||
#### 方案 A:新增独立 Canvas Assistant
|
||||
|
||||
- 新增 `canvas_agent`
|
||||
- 使用单独的 LangGraph 状态定义
|
||||
- 前端调用时通过 `assistant_id=canvas_agent`
|
||||
|
||||
优点:
|
||||
|
||||
- 与现有 `lead_agent` 隔离
|
||||
- 风险更低
|
||||
- 更容易对齐 open-canvas 的图结构
|
||||
|
||||
缺点:
|
||||
|
||||
- 会产生一套新的 Agent 图维护成本
|
||||
|
||||
#### 方案 B:在现有 Lead Agent 中增加 Canvas 模式分支
|
||||
|
||||
- 在 thread metadata 或 configurable 中增加 `ui_mode=canvas`
|
||||
- Agent 根据模式进入 Canvas 路径
|
||||
|
||||
优点:
|
||||
|
||||
- 统一入口
|
||||
|
||||
缺点:
|
||||
|
||||
- 会显著增加现有 Agent 复杂度
|
||||
- 容易影响现有聊天和文件产物模式
|
||||
|
||||
推荐先做方案 A。
|
||||
|
||||
### 4.3 模型配置设计思路
|
||||
|
||||
模型配置统一以 DeerFlow 后端 `config.yaml` 为真源。
|
||||
|
||||
前端不维护固定模型常量列表,而是通过 `/api/models` 获取模型能力元数据。
|
||||
|
||||
为了实现 open-canvas 风格体验,需要后端模型接口至少返回:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `provider`
|
||||
- `supports_thinking`
|
||||
- `supports_reasoning_effort`
|
||||
- `supports_vision`
|
||||
- `supports_tool_calling`
|
||||
- `supports_artifact_editing`
|
||||
- `supports_code_editing`
|
||||
- `temperature_range`
|
||||
- `max_tokens_range`
|
||||
- `base_url_source` 或 provider 标识
|
||||
- `is_local`
|
||||
|
||||
---
|
||||
|
||||
## 5. 与当前项目的关键差异
|
||||
|
||||
### 5.1 当前 DeerFlow 现状
|
||||
|
||||
当前 DeerFlow 线程状态中的 artifact 主要是文件路径列表:
|
||||
|
||||
- `artifacts: string[]`
|
||||
|
||||
这更接近“生成文件产物”的工作模式。
|
||||
|
||||
前端当前 artifact 面板也主要是:
|
||||
|
||||
- 文件列表
|
||||
- 文件详情
|
||||
- 文件下载/预览
|
||||
|
||||
### 5.2 open-canvas 的关键能力
|
||||
|
||||
open-canvas 的核心不是“生成文件”,而是“维护一个结构化的当前文档对象”:
|
||||
|
||||
- 一个 conversation 对应一个主 artifact
|
||||
- artifact 具有类型、标题、内容、版本
|
||||
- 用户可以选中部分内容让 AI 改写
|
||||
- 每次改写都会生成新版本
|
||||
|
||||
### 5.3 需要新增的核心能力
|
||||
|
||||
为支持 Canvas 模式,需要在 DeerFlow 中新增一套结构化 artifact 状态:
|
||||
|
||||
- 当前 artifact
|
||||
- 当前版本索引
|
||||
- 历史版本列表
|
||||
- 选区信息
|
||||
- 当前编辑模式
|
||||
- 快速动作状态
|
||||
- Web 搜索结果状态
|
||||
|
||||
---
|
||||
|
||||
## 6. 分阶段实施方案
|
||||
|
||||
以下为推荐的 8 个阶段,每个阶段都可单独开发、联调和验收。
|
||||
|
||||
---
|
||||
|
||||
## 7. 阶段一:前端路由与目录骨架
|
||||
|
||||
### 7.1 目标
|
||||
|
||||
在不影响现有页面的前提下,建立 Canvas 模式的前端骨架。
|
||||
|
||||
### 7.2 需求
|
||||
|
||||
- 新增 `src/open-canvas/` 目录
|
||||
- 新增 Canvas 页面路由
|
||||
- 将路由接入 [PageRoutes.tsx](../src/pages/PageRoutes.tsx)
|
||||
- 提供 Canvas 首页和 Canvas Thread 页
|
||||
- 支持空状态展示
|
||||
|
||||
### 7.3 建议目录
|
||||
|
||||
```text
|
||||
src/open-canvas/
|
||||
api/
|
||||
threads.ts
|
||||
runs.ts
|
||||
models.ts
|
||||
components/
|
||||
layout/
|
||||
chat/
|
||||
canvas/
|
||||
artifacts/
|
||||
quick-actions/
|
||||
selectors/
|
||||
contexts/
|
||||
CanvasThreadContext.tsx
|
||||
CanvasGraphContext.tsx
|
||||
CanvasUIContext.tsx
|
||||
hooks/
|
||||
useCanvasThread.ts
|
||||
useCanvasStream.ts
|
||||
useCanvasArtifact.ts
|
||||
pages/
|
||||
CanvasHomePage.tsx
|
||||
CanvasThreadPage.tsx
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx
|
||||
types/
|
||||
artifact.ts
|
||||
thread.ts
|
||||
model.ts
|
||||
utils/
|
||||
artifact.ts
|
||||
selection.ts
|
||||
stream.ts
|
||||
constants/
|
||||
index.ts
|
||||
```
|
||||
|
||||
### 7.4 路由建议
|
||||
|
||||
- 在 `PageRoutes.tsx` 中新增 `canvas/*`
|
||||
- 在 `src/open-canvas/routes/OpenCanvasRoutes.tsx` 中维护子路由
|
||||
|
||||
建议形式:
|
||||
|
||||
```text
|
||||
/canvas
|
||||
/canvas/:threadId
|
||||
```
|
||||
|
||||
### 7.5 验收标准
|
||||
|
||||
- 能访问 Canvas 首页
|
||||
- 能访问指定 thread 的 Canvas 页面
|
||||
- 不影响现有 `workspace/*` 与 `strategy/*`
|
||||
- 页面有独立布局骨架
|
||||
|
||||
---
|
||||
|
||||
## 8. 阶段二:前端 Canvas 布局与交互容器
|
||||
|
||||
### 8.1 目标
|
||||
|
||||
先做出“左聊天、右画布”的基础容器,不接真实 AI 改写逻辑。
|
||||
|
||||
### 8.2 需求
|
||||
|
||||
- 左侧聊天区
|
||||
- 右侧画布区
|
||||
- 支持面板拖拽或折叠
|
||||
- 初始空状态
|
||||
- 支持“快速开始”:
|
||||
- 新建空白文本 artifact
|
||||
- 新建代码 artifact
|
||||
|
||||
### 8.3 组件拆分建议
|
||||
|
||||
- `CanvasShell`
|
||||
- `CanvasSidebar`
|
||||
- `CanvasChatPanel`
|
||||
- `CanvasArtifactPanel`
|
||||
- `CanvasEmptyState`
|
||||
- `QuickStartActions`
|
||||
|
||||
### 8.4 快速开始能力
|
||||
|
||||
用户进入空线程时可以选择:
|
||||
|
||||
- 开始写文章
|
||||
- 开始写代码
|
||||
- 从模板开始
|
||||
|
||||
首版模板可只做最小集:
|
||||
|
||||
- 空白文档
|
||||
- HTML 模板
|
||||
- React 组件模板
|
||||
- Python 脚本模板
|
||||
|
||||
### 8.5 验收标准
|
||||
|
||||
- 页面可稳定展示双栏布局
|
||||
- 快速开始能创建前端本地 artifact 状态
|
||||
- 代码和文本可分别进入不同渲染器
|
||||
|
||||
---
|
||||
|
||||
## 9. 阶段三:结构化 Artifact 数据模型
|
||||
|
||||
### 9.1 目标
|
||||
|
||||
定义 DeerFlow 的 Canvas Artifact 协议,作为前后端统一契约。
|
||||
|
||||
### 9.2 核心要求
|
||||
|
||||
需要支持以下能力:
|
||||
|
||||
- 文本 artifact
|
||||
- 代码 artifact
|
||||
- 标题
|
||||
- 当前版本索引
|
||||
- 版本历史
|
||||
- 当前选中版本切换
|
||||
- 后续支持局部高亮编辑
|
||||
|
||||
### 9.3 建议数据结构
|
||||
|
||||
```ts
|
||||
type CanvasArtifact = {
|
||||
currentIndex: number;
|
||||
contents: Array<CanvasArtifactText | CanvasArtifactCode>;
|
||||
};
|
||||
|
||||
type CanvasArtifactText = {
|
||||
index: number;
|
||||
type: "text";
|
||||
title: string;
|
||||
fullMarkdown: string;
|
||||
};
|
||||
|
||||
type CanvasArtifactCode = {
|
||||
index: number;
|
||||
type: "code";
|
||||
title: string;
|
||||
language: string;
|
||||
code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 9.4 线程状态建议新增字段
|
||||
|
||||
前端与后端需共同支持:
|
||||
|
||||
- `artifact`
|
||||
- `highlighted_text`
|
||||
- `highlighted_code`
|
||||
- `canvas_mode`
|
||||
- `web_search_enabled`
|
||||
- `web_search_results`
|
||||
- `quick_action_id`
|
||||
|
||||
### 9.5 与现有 `artifacts: string[]` 的关系
|
||||
|
||||
建议保留现有 `artifacts: string[]` 不动。
|
||||
|
||||
新增字段:
|
||||
|
||||
- `artifact` 用于 Canvas 主文档
|
||||
- `artifacts` 继续用于文件产物
|
||||
|
||||
这样可以兼容未来“Canvas 生成网页文件、导出文件、保存文件”的场景。
|
||||
|
||||
### 9.6 验收标准
|
||||
|
||||
- 前端类型定义完成
|
||||
- 后端状态字段设计完成
|
||||
- 确认与现有线程状态兼容
|
||||
|
||||
---
|
||||
|
||||
## 10. 阶段四:后端线程状态与 API 扩展
|
||||
|
||||
### 10.1 目标
|
||||
|
||||
让后端线程状态可以安全存储和返回 Canvas Artifact。
|
||||
|
||||
### 10.2 需求
|
||||
|
||||
- 扩展 Python `ThreadState`
|
||||
- 扩展状态序列化逻辑
|
||||
- 确保 `/api/threads/*` 返回结构化 `artifact`
|
||||
- 确保 LangGraph 流中可带上 `artifact` 更新
|
||||
|
||||
### 10.3 后端改造点
|
||||
|
||||
建议关注以下模块:
|
||||
|
||||
- `packages/harness/deerflow/agents/thread_state.py`
|
||||
- 线程 state serializer
|
||||
- `app/gateway/routers/threads.py`
|
||||
- 流事件适配层
|
||||
|
||||
### 10.4 注意事项
|
||||
|
||||
- 旧线程没有 `artifact` 字段时必须兼容
|
||||
- 状态更新不能破坏现有消息结构
|
||||
- 必须确保 JSON 可序列化
|
||||
|
||||
### 10.5 验收标准
|
||||
|
||||
- 新建线程可写入 `artifact`
|
||||
- 读取线程时可返回 `artifact`
|
||||
- 不影响现有线程读取
|
||||
- 前端能从线程 state 中恢复 Canvas 内容
|
||||
|
||||
---
|
||||
|
||||
## 11. 阶段五:Canvas 专用 Agent 图
|
||||
|
||||
### 11.1 目标
|
||||
|
||||
实现与 open-canvas 类似的 AI 路由能力。
|
||||
|
||||
### 11.2 推荐方案
|
||||
|
||||
新增 `canvas_agent`,单独维护图逻辑。
|
||||
|
||||
### 11.3 节点拆分建议
|
||||
|
||||
首版建议至少拆成这些节点:
|
||||
|
||||
- `route_user_intent`
|
||||
- `reply_general_input`
|
||||
- `generate_artifact`
|
||||
- `rewrite_artifact`
|
||||
- `update_highlighted_text`
|
||||
- `update_highlighted_code`
|
||||
- `generate_followup`
|
||||
|
||||
二期可加:
|
||||
|
||||
- `rewrite_artifact_theme`
|
||||
- `rewrite_code_theme`
|
||||
- `change_length`
|
||||
- `change_reading_level`
|
||||
- `translate_artifact`
|
||||
- `add_comments`
|
||||
- `add_logs`
|
||||
- `fix_bugs`
|
||||
- `port_language`
|
||||
- `web_search_enrich`
|
||||
|
||||
### 11.4 路由逻辑要求
|
||||
|
||||
系统需要能判断:
|
||||
|
||||
- 用户是在闲聊
|
||||
- 用户要新生成 artifact
|
||||
- 用户要整体重写当前 artifact
|
||||
- 用户要局部修改当前 artifact
|
||||
- 用户要执行某个快捷动作
|
||||
|
||||
### 11.5 状态输入要求
|
||||
|
||||
节点需要读取:
|
||||
|
||||
- 当前消息历史
|
||||
- 当前 artifact
|
||||
- 当前选区
|
||||
- 当前模型配置
|
||||
- Web 搜索开关
|
||||
|
||||
### 11.6 输出要求
|
||||
|
||||
节点输出必须能更新:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `web_search_results`
|
||||
- `followup`
|
||||
|
||||
### 11.7 验收标准
|
||||
|
||||
- 能根据输入创建新 artifact
|
||||
- 能根据输入整体改写 artifact
|
||||
- 能保留版本历史
|
||||
- 能在改写后返回自然语言 followup
|
||||
|
||||
---
|
||||
|
||||
## 12. 阶段六:前端流式同步与版本管理
|
||||
|
||||
### 12.1 目标
|
||||
|
||||
让前端能像 open-canvas 一样在流式过程中更新画布与聊天。
|
||||
|
||||
### 12.2 需求
|
||||
|
||||
- 复用现有 LangGraph stream client
|
||||
- 在 Canvas 模式中解析 `artifact` 更新
|
||||
- 支持流式文本更新
|
||||
- 支持版本切换
|
||||
- 支持断线后恢复线程状态
|
||||
|
||||
### 12.3 前端状态层建议
|
||||
|
||||
- `CanvasThreadContext`:线程基础信息
|
||||
- `CanvasGraphContext`:消息、artifact、streaming 状态
|
||||
- `CanvasUIContext`:面板、编辑态、选中态
|
||||
|
||||
### 12.4 关键能力
|
||||
|
||||
- 当前版本切换
|
||||
- 新版本创建后自动定位到最新版本
|
||||
- 手动编辑内容后保存
|
||||
- 流结束后线程状态持久化
|
||||
|
||||
### 12.5 验收标准
|
||||
|
||||
- 发送消息时聊天和画布可同步更新
|
||||
- 版本数会增加
|
||||
- 切换历史版本稳定
|
||||
- 刷新页面后线程状态能恢复
|
||||
|
||||
---
|
||||
|
||||
## 13. 阶段七:局部选区改写
|
||||
|
||||
### 13.1 目标
|
||||
|
||||
实现 Canvas 模式最关键的差异化能力之一:选中局部内容进行 AI 改写。
|
||||
|
||||
### 13.2 文本选区需求
|
||||
|
||||
- 支持在 Markdown 文本中选择一段文本
|
||||
- 记录选区位置与原始文本
|
||||
- 用户发出修改请求后,仅重写该选区
|
||||
- 将选区结果合并为新版本 artifact
|
||||
|
||||
### 13.3 代码选区需求
|
||||
|
||||
- 支持在代码编辑器中选择一段代码
|
||||
- 记录 start/end index 或行列信息
|
||||
- AI 只返回替换片段
|
||||
- 前端或后端负责将新片段拼接回完整代码
|
||||
|
||||
### 13.4 实现建议
|
||||
|
||||
首版建议:
|
||||
|
||||
- 文本:前端记录纯文本字符位置
|
||||
- 代码:使用 CodeMirror selection 信息
|
||||
|
||||
不建议首版就做复杂块级富文本局部编辑。
|
||||
|
||||
### 13.5 风险
|
||||
|
||||
- Markdown 渲染态与源码态位置映射复杂
|
||||
- 富文本块级位置难维护
|
||||
- 流式更新中选区同步容易错位
|
||||
|
||||
### 13.6 首版折中方案
|
||||
|
||||
首版文本只基于 `fullMarkdown` 纯文本改写,不做 BlockNote 富文本块定位。
|
||||
|
||||
### 13.7 验收标准
|
||||
|
||||
- 文本局部改写成功
|
||||
- 代码局部改写成功
|
||||
- 原内容未改动部分保持不变
|
||||
- 新版本正确入历史
|
||||
|
||||
---
|
||||
|
||||
## 14. 阶段八:快捷动作系统
|
||||
|
||||
### 14.1 目标
|
||||
|
||||
实现 open-canvas 风格的快捷动作体系。
|
||||
|
||||
### 14.2 首批推荐动作
|
||||
|
||||
文本类:
|
||||
|
||||
- 扩写
|
||||
- 精简
|
||||
- 改正式语气
|
||||
- 改口语语气
|
||||
- 改阅读难度
|
||||
- 翻译
|
||||
|
||||
代码类:
|
||||
|
||||
- 加注释
|
||||
- 加日志
|
||||
- 修复 bug
|
||||
- 优化代码
|
||||
- 转换语言
|
||||
|
||||
### 14.3 设计方式
|
||||
|
||||
快捷动作不要直接写死在 UI,建议配置化:
|
||||
|
||||
- `id`
|
||||
- `label`
|
||||
- `type`
|
||||
- `applicable_to`
|
||||
- `prompt_template`
|
||||
|
||||
### 14.4 后端设计
|
||||
|
||||
快捷动作可通过:
|
||||
|
||||
- 前端传 `quick_action_id`
|
||||
- 后端路由节点根据动作 id 选择 prompt
|
||||
|
||||
### 14.5 验收标准
|
||||
|
||||
- 文本动作可执行
|
||||
- 代码动作可执行
|
||||
- 结果进入新版本
|
||||
- 不同动作提示词可扩展
|
||||
|
||||
---
|
||||
|
||||
## 15. 阶段九:模型配置统一与本地模型支持
|
||||
|
||||
### 15.1 目标
|
||||
|
||||
让 Canvas 模式支持和主系统统一的模型选择,并且能自由配置本地大模型。
|
||||
|
||||
### 15.2 原则
|
||||
|
||||
- 后端 `config.yaml` 为真源
|
||||
- 前端只展示后端暴露的模型信息
|
||||
- 不维护一份脱离后端的前端常量模型表
|
||||
|
||||
### 15.3 本地模型支持范围
|
||||
|
||||
建议优先支持两类:
|
||||
|
||||
- `Ollama`
|
||||
- OpenAI-compatible 本地服务
|
||||
- LM Studio
|
||||
- vLLM
|
||||
- LocalAI
|
||||
- one-api 风格代理
|
||||
|
||||
### 15.4 后端配置建议
|
||||
|
||||
后端模型配置应至少支持:
|
||||
|
||||
- `name`
|
||||
- `display_name`
|
||||
- `use`
|
||||
- `model`
|
||||
- `api_key`
|
||||
- `base_url`
|
||||
- `supports_vision`
|
||||
- `supports_thinking`
|
||||
- `supports_tool_calling`
|
||||
- `is_local`
|
||||
- `canvas_capabilities`
|
||||
|
||||
### 15.5 Canvas 模式额外要求
|
||||
|
||||
由于不是所有模型都适合 Canvas 改写任务,建议增加能力标识:
|
||||
|
||||
- `supports_canvas_generation`
|
||||
- `supports_canvas_rewrite`
|
||||
- `supports_canvas_code_edit`
|
||||
- `supports_streaming_text`
|
||||
- `supports_streaming_tool_calls`
|
||||
|
||||
### 15.6 与 open-canvas 的关系
|
||||
|
||||
可以借鉴其命名习惯和能力分层,但不建议直接依赖其 `packages/shared/src/models.ts` 作为配置源。
|
||||
|
||||
### 15.7 验收标准
|
||||
|
||||
- 前端模型选择器可显示后端返回模型
|
||||
- 可切换本地模型
|
||||
- Canvas 模式能用本地模型生成和改写
|
||||
|
||||
---
|
||||
|
||||
## 16. 阶段十:Web 搜索增强
|
||||
|
||||
### 16.1 目标
|
||||
|
||||
实现类似 open-canvas 的可选 Web 搜索增强。
|
||||
|
||||
### 16.2 需求
|
||||
|
||||
- UI 提供 Web 搜索开关
|
||||
- 后端在需要时走搜索节点
|
||||
- 搜索结果写入线程状态
|
||||
- 结果可在画布旁侧栏展示
|
||||
|
||||
### 16.3 首版建议
|
||||
|
||||
只做:
|
||||
|
||||
- 开关
|
||||
- 搜索结果列表
|
||||
- 搜索结果用于生成 artifact
|
||||
|
||||
暂不做复杂网页抓取编辑链路。
|
||||
|
||||
### 16.4 验收标准
|
||||
|
||||
- 打开搜索后可返回搜索结果
|
||||
- 搜索结果可参与 artifact 生成
|
||||
- 结果展示不阻塞主画布
|
||||
|
||||
---
|
||||
|
||||
## 17. 阶段十一:持久化、导出与文件产物联动
|
||||
|
||||
### 17.1 目标
|
||||
|
||||
让 Canvas 文档与 DeerFlow 原有文件能力协同。
|
||||
|
||||
### 17.2 需求
|
||||
|
||||
- artifact 持久化在线程 state 中
|
||||
- 可手动导出为文件
|
||||
- 可生成真实 HTML/Markdown/代码文件到现有 outputs
|
||||
- Canvas artifact 与 `artifacts: string[]` 可双向关联
|
||||
|
||||
### 17.3 推荐能力
|
||||
|
||||
- 导出 Markdown
|
||||
- 导出代码文件
|
||||
- 另存为 artifact file
|
||||
- 从 artifact file 回填到 Canvas
|
||||
|
||||
### 17.4 验收标准
|
||||
|
||||
- 当前版本可导出
|
||||
- 导出文件可在现有文件面板查看
|
||||
- 文件型 artifact 和 Canvas artifact 能共存
|
||||
|
||||
---
|
||||
|
||||
## 18. 推荐开发顺序
|
||||
|
||||
建议严格按以下顺序推进:
|
||||
|
||||
1. 路由和目录骨架
|
||||
2. Canvas 页面布局
|
||||
3. 前端 artifact 数据模型
|
||||
4. 后端线程状态扩展
|
||||
5. 新建 Canvas Agent 图
|
||||
6. 前后端流式同步
|
||||
7. 整体生成与整体改写
|
||||
8. 局部选区改写
|
||||
9. 快捷动作
|
||||
10. 本地模型支持完善
|
||||
11. Web 搜索增强
|
||||
12. 导出与文件联动
|
||||
|
||||
不要先做局部改写和快捷动作,否则前面的状态与路由契约不稳定,返工会很多。
|
||||
|
||||
---
|
||||
|
||||
## 19. 里程碑定义
|
||||
|
||||
### M1:页面可进入
|
||||
|
||||
- 路由接通
|
||||
- 双栏布局完成
|
||||
- 本地 mock artifact 可渲染
|
||||
|
||||
### M2:后端状态打通
|
||||
|
||||
- 线程可保存结构化 artifact
|
||||
- 页面刷新后能恢复
|
||||
|
||||
### M3:AI 生成可用
|
||||
|
||||
- 可新建文本/代码 artifact
|
||||
- 可整体改写
|
||||
- 可产生版本历史
|
||||
|
||||
### M4:核心体验可用
|
||||
|
||||
- 选区改写可用
|
||||
- 快捷动作可用
|
||||
- 模型切换可用
|
||||
|
||||
### M5:增强能力完整
|
||||
|
||||
- 本地模型支持完善
|
||||
- Web 搜索增强
|
||||
- 导出和文件联动
|
||||
|
||||
---
|
||||
|
||||
## 20. 风险与难点
|
||||
|
||||
### 20.1 最大难点
|
||||
|
||||
- 结构化 artifact 状态从无到有
|
||||
- 局部选区改写的精确性
|
||||
- 流式状态与版本管理的一致性
|
||||
- 不破坏现有 DeerFlow 文件型 artifact 体系
|
||||
|
||||
### 20.2 技术风险
|
||||
|
||||
- 线程 state JSON 结构过大
|
||||
- 大模型对局部改写指令遵循不稳定
|
||||
- 本地模型 tool calling 与流式能力差异大
|
||||
- 文本渲染态与源码态位置映射复杂
|
||||
|
||||
### 20.3 规避建议
|
||||
|
||||
- 首版只支持 Markdown 源码态编辑,不做复杂富文本编辑
|
||||
- 首版只支持文本和代码两种 artifact 类型
|
||||
- 首版先做整体生成和整体改写,再做局部改写
|
||||
- 本地模型先支持“能生成、能重写”的最小能力,不强求与顶级闭源模型完全同效
|
||||
|
||||
---
|
||||
|
||||
## 21. 验收总清单
|
||||
|
||||
### 21.1 前端
|
||||
|
||||
- 有独立 Canvas 路由
|
||||
- 有专用目录和模块边界
|
||||
- 双栏布局可用
|
||||
- 文本和代码两类 artifact 可渲染
|
||||
- 历史版本可切换
|
||||
- 选区改写可交互
|
||||
- 快捷动作可触发
|
||||
- 模型可切换
|
||||
|
||||
### 21.2 后端
|
||||
|
||||
- 线程状态支持 `artifact`
|
||||
- 新增 `canvas_agent` 或等价模式
|
||||
- 生成、重写、局部改写可用
|
||||
- 流式返回稳定
|
||||
- 模型列表接口支持 Canvas 能力元数据
|
||||
|
||||
### 21.3 本地模型
|
||||
|
||||
- 可配置 Ollama
|
||||
- 可配置 OpenAI-compatible 本地服务
|
||||
- 前端可见并可切换
|
||||
- 至少能完成文本生成和整体改写
|
||||
|
||||
---
|
||||
|
||||
## 22. 推荐首版最小可交付范围
|
||||
|
||||
如果要控制风险,建议首版只交付以下内容:
|
||||
|
||||
- 新增 `canvas/*` 路由
|
||||
- 双栏布局
|
||||
- 结构化 `artifact`
|
||||
- `canvas_agent`
|
||||
- 文本/代码 artifact 新建
|
||||
- 整体生成
|
||||
- 整体改写
|
||||
- 历史版本切换
|
||||
- 后端模型动态加载
|
||||
- Ollama 或本地 OpenAI-compatible 至少一种可用
|
||||
|
||||
先不要把以下内容放进首版强制目标:
|
||||
|
||||
- 富文本块级精确编辑
|
||||
- 全量快捷动作
|
||||
- 复杂 Web 搜索增强
|
||||
- 多 artifact 并行管理
|
||||
|
||||
---
|
||||
|
||||
## 23. 后续实施建议
|
||||
|
||||
建议下一步基于本文档继续输出两份落地文档:
|
||||
|
||||
### 23.1 前端实施清单
|
||||
|
||||
逐文件列出:
|
||||
|
||||
- 要新建哪些文件
|
||||
- 哪些组件可复用
|
||||
- 哪些页面要接入路由
|
||||
- 哪些状态要从现有 hooks 抽离
|
||||
|
||||
### 23.2 后端实施清单
|
||||
|
||||
逐模块列出:
|
||||
|
||||
- 要改哪些 Python 文件
|
||||
- 新增哪些状态字段
|
||||
- 新增哪些 Agent 节点
|
||||
- 哪些 API 需要扩展
|
||||
|
||||
---
|
||||
|
||||
## 24. 结论
|
||||
|
||||
这个需求很大,但完全可拆。
|
||||
|
||||
正确做法不是“先抄 UI”,而是按下面的主线推进:
|
||||
|
||||
- 先建前端 Canvas 子系统
|
||||
- 再建结构化 artifact 状态
|
||||
- 再建 Canvas Agent 图
|
||||
- 再补局部改写和快捷动作
|
||||
- 最后统一本地模型能力
|
||||
|
||||
只要按本文档阶段推进,这个需求是可以落地的,而且能和你现在的 DeerFlow 架构保持长期兼容。
|
||||
@@ -0,0 +1,109 @@
|
||||
# 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
|
||||
@@ -0,0 +1,243 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
+12
-12
@@ -1,12 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>DeerFlow Vite</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CM 助手</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+10140
-8920
File diff suppressed because it is too large.
Load diff
@@ -37,6 +37,13 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@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",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
@@ -50,6 +57,7 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"codemirror": "^6.0.2",
|
||||
"date-fns": "^4.1.0",
|
||||
"docx": "^9.5.1",
|
||||
"dotenv": "^17.2.3",
|
||||
"embla-carousel-react": "^8.6.0",
|
||||
"gsap": "^3.13.0",
|
||||
@@ -62,8 +70,10 @@
|
||||
"ogl": "^1.0.11",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-resizable-panels": "^4.4.1",
|
||||
"react-router-dom": "^7.9.4",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"rehype-katex": "^7.0.1",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"remark-gfm": "^4.0.1",
|
||||
@@ -72,6 +82,8 @@
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
@@ -89,7 +101,8 @@
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0"
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
export default {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
+16
-106
@@ -3,27 +3,18 @@ import { Navigate, Route, Routes } from "react-router-dom";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
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 { detectLocale } from "@/core/i18n/locale";
|
||||
import { SubtasksProvider } from "@/core/tasks/context";
|
||||
import { ArtifactsProvider } from "@/components/workspace/artifacts";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
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 ScheduledTaskRunDetailPage from "./pages/ScheduledTaskRunDetailPage";
|
||||
import ScheduledTasksPage from "./pages/ScheduledTasksPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
@@ -39,33 +30,16 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</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() {
|
||||
return (
|
||||
<HashRouter>
|
||||
@@ -75,78 +49,14 @@ export function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only scheduled-task runs viewer — no authentication. */}
|
||||
<Route
|
||||
path="/workspace"
|
||||
element={<Navigate to="/workspace/chats/new" replace />}
|
||||
/>
|
||||
<Route
|
||||
path="/workspace/chats"
|
||||
element={
|
||||
<WorkspaceLayout>
|
||||
<ChatsPage />
|
||||
</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>
|
||||
}
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</AppProviders>
|
||||
|
||||
@@ -1,158 +1,158 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type LucideIcon, XIcon } from "lucide-react";
|
||||
import { forwardRef, type ComponentProps, type HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground size-8 p-0",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-foreground text-sm font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
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]",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto bg-white p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
ArtifactContent.displayName = "ArtifactContent";
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type LucideIcon, XIcon } from "lucide-react";
|
||||
import { forwardRef, type ComponentProps, type HTMLAttributes } from "react";
|
||||
|
||||
export type ArtifactProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Artifact = ({ className, ...props }: ArtifactProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"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,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactHeaderProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactHeaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center justify-between border-b border-[#efe9de] bg-white px-4 py-3",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactCloseProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ArtifactClose = ({
|
||||
className,
|
||||
children,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactCloseProps) => (
|
||||
<Button
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground size-8 p-0",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <XIcon className="size-4" />}
|
||||
<span className="sr-only">Close</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
export type ArtifactTitleProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactTitle = ({ className, ...props }: ArtifactTitleProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-foreground text-sm font-semibold tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ArtifactDescriptionProps = HTMLAttributes<HTMLParagraphElement>;
|
||||
|
||||
export const ArtifactDescription = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactDescriptionProps) => (
|
||||
<p className={cn("text-muted-foreground text-sm", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionsProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactActions = ({
|
||||
className,
|
||||
...props
|
||||
}: ArtifactActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ArtifactActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
icon?: LucideIcon;
|
||||
};
|
||||
|
||||
export const ArtifactAction = ({
|
||||
tooltip,
|
||||
label,
|
||||
icon: Icon,
|
||||
children,
|
||||
className,
|
||||
size = "sm",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: ArtifactActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
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]",
|
||||
className,
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon ? <Icon className="size-4" /> : children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
export type ArtifactContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ArtifactContent = forwardRef<HTMLDivElement, ArtifactContentProps>(
|
||||
({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-auto bg-white p-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
ArtifactContent.displayName = "ArtifactContent";
|
||||
@@ -1,22 +1,22 @@
|
||||
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
import { Background, ReactFlow, type ReactFlowProps } from "@xyflow/react";
|
||||
import type { ReactNode } from "react";
|
||||
import "@xyflow/react/dist/style.css";
|
||||
|
||||
type CanvasProps = ReactFlowProps & {
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
export const Canvas = ({ children, ...props }: CanvasProps) => (
|
||||
<ReactFlow
|
||||
deleteKeyCode={["Backspace", "Delete"]}
|
||||
fitView
|
||||
panOnDrag={false}
|
||||
panOnScroll
|
||||
selectionOnDrag={true}
|
||||
zoomOnDoubleClick={false}
|
||||
{...props}
|
||||
>
|
||||
<Background bgColor="var(--sidebar)" />
|
||||
{children}
|
||||
</ReactFlow>
|
||||
);
|
||||
@@ -1,239 +1,239 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
isValidElement,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon ?? <BrainIcon className="size-4" />}
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon | React.ReactElement;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: "text-muted-foreground",
|
||||
active: "text-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
statusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
{isValidElement(Icon) ? Icon : <Icon className="size-4" />}
|
||||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
),
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
BrainIcon,
|
||||
ChevronDownIcon,
|
||||
DotIcon,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import {
|
||||
createContext,
|
||||
isValidElement,
|
||||
memo,
|
||||
useContext,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
type ChainOfThoughtContextValue = {
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const ChainOfThoughtContext = createContext<ChainOfThoughtContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useChainOfThought = () => {
|
||||
const context = useContext(ChainOfThoughtContext);
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"ChainOfThought components must be used within ChainOfThought",
|
||||
);
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ChainOfThoughtProps = ComponentProps<"div"> & {
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
};
|
||||
|
||||
export const ChainOfThought = memo(
|
||||
({
|
||||
className,
|
||||
open,
|
||||
defaultOpen = false,
|
||||
onOpenChange,
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
|
||||
const chainOfThoughtContext = useMemo(
|
||||
() => ({ isOpen, setIsOpen }),
|
||||
[isOpen, setIsOpen],
|
||||
);
|
||||
|
||||
return (
|
||||
<ChainOfThoughtContext.Provider value={chainOfThoughtContext}>
|
||||
<div className={cn("not-prose", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
</ChainOfThoughtContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtHeaderProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
icon?: React.ReactElement;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtHeader = memo(
|
||||
({ className, children, icon, ...props }: ChainOfThoughtHeaderProps) => {
|
||||
const { isOpen, setIsOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible onOpenChange={setIsOpen} open={isOpen}>
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{icon ?? <BrainIcon className="size-4" />}
|
||||
<span className="flex-1 text-left">
|
||||
{children ?? "Chain of Thought"}
|
||||
</span>
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtStepProps = ComponentProps<"div"> & {
|
||||
icon?: LucideIcon | React.ReactElement;
|
||||
label: ReactNode;
|
||||
description?: ReactNode;
|
||||
status?: "complete" | "active" | "pending";
|
||||
};
|
||||
|
||||
export const ChainOfThoughtStep = memo(
|
||||
({
|
||||
className,
|
||||
icon: Icon = DotIcon,
|
||||
label,
|
||||
description,
|
||||
status = "complete",
|
||||
children,
|
||||
...props
|
||||
}: ChainOfThoughtStepProps) => {
|
||||
const statusStyles = {
|
||||
complete: "text-muted-foreground",
|
||||
active: "text-foreground",
|
||||
pending: "text-muted-foreground/50",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2 text-sm",
|
||||
statusStyles[status],
|
||||
"fade-in-0 slide-in-from-top-2 animate-in",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative mt-0.5">
|
||||
{isValidElement(Icon) ? Icon : <Icon className="size-4" />}
|
||||
<div className="bg-border absolute top-7 bottom-0 left-1/2 -mx-px w-px" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-2 overflow-hidden">
|
||||
<div>{label}</div>
|
||||
{description && (
|
||||
<div className="text-muted-foreground text-xs">{description}</div>
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultsProps = ComponentProps<"div">;
|
||||
|
||||
export const ChainOfThoughtSearchResults = memo(
|
||||
({ className, ...props }: ChainOfThoughtSearchResultsProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-wrap items-center gap-2 overflow-x-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
);
|
||||
|
||||
export type ChainOfThoughtSearchResultProps = ComponentProps<typeof Badge>;
|
||||
|
||||
export const ChainOfThoughtSearchResult = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtSearchResultProps) => (
|
||||
<Badge
|
||||
className={cn("gap-1 px-2 py-0.5 text-xs font-normal", className)}
|
||||
variant="secondary"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
),
|
||||
);
|
||||
|
||||
export type ChainOfThoughtContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const ChainOfThoughtContent = memo(
|
||||
({ className, children, ...props }: ChainOfThoughtContentProps) => {
|
||||
const { isOpen } = useChainOfThought();
|
||||
|
||||
return (
|
||||
<Collapsible open={isOpen}>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ChainOfThoughtImageProps = ComponentProps<"div"> & {
|
||||
caption?: string;
|
||||
};
|
||||
|
||||
export const ChainOfThoughtImage = memo(
|
||||
({ className, children, caption, ...props }: ChainOfThoughtImageProps) => (
|
||||
<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">
|
||||
{children}
|
||||
</div>
|
||||
{caption && <p className="text-muted-foreground text-xs">{caption}</p>}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
ChainOfThought.displayName = "ChainOfThought";
|
||||
ChainOfThoughtHeader.displayName = "ChainOfThoughtHeader";
|
||||
ChainOfThoughtStep.displayName = "ChainOfThoughtStep";
|
||||
ChainOfThoughtSearchResults.displayName = "ChainOfThoughtSearchResults";
|
||||
ChainOfThoughtSearchResult.displayName = "ChainOfThoughtSearchResult";
|
||||
ChainOfThoughtContent.displayName = "ChainOfThoughtContent";
|
||||
ChainOfThoughtImage.displayName = "ChainOfThoughtImage";
|
||||
@@ -1,71 +1,71 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookmarkIcon, type LucideProps } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-0.5 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookmarkIcon, type LucideProps } from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes } from "react";
|
||||
|
||||
export type CheckpointProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const Checkpoint = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-muted-foreground flex items-center gap-0.5 overflow-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
);
|
||||
|
||||
export type CheckpointIconProps = LucideProps;
|
||||
|
||||
export const CheckpointIcon = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CheckpointIconProps) =>
|
||||
children ?? (
|
||||
<BookmarkIcon className={cn("size-4 shrink-0", className)} {...props} />
|
||||
);
|
||||
|
||||
export type CheckpointTriggerProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const CheckpointTrigger = ({
|
||||
children,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
tooltip,
|
||||
...props
|
||||
}: CheckpointTriggerProps) =>
|
||||
tooltip ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent align="start" side="bottom">
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
);
|
||||
@@ -1,178 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false,
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group bg-background text-foreground relative size-full overflow-hidden rounded-md border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
<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"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<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"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { CheckIcon, CopyIcon } from "lucide-react";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { type BundledLanguage, codeToHtml, type ShikiTransformer } from "shiki";
|
||||
|
||||
type CodeBlockProps = HTMLAttributes<HTMLDivElement> & {
|
||||
code: string;
|
||||
language: BundledLanguage;
|
||||
showLineNumbers?: boolean;
|
||||
};
|
||||
|
||||
type CodeBlockContextType = {
|
||||
code: string;
|
||||
};
|
||||
|
||||
const CodeBlockContext = createContext<CodeBlockContextType>({
|
||||
code: "",
|
||||
});
|
||||
|
||||
const lineNumberTransformer: ShikiTransformer = {
|
||||
name: "line-numbers",
|
||||
line(node, line) {
|
||||
node.children.unshift({
|
||||
type: "element",
|
||||
tagName: "span",
|
||||
properties: {
|
||||
className: [
|
||||
"inline-block",
|
||||
"min-w-10",
|
||||
"mr-4",
|
||||
"text-right",
|
||||
"select-none",
|
||||
"text-muted-foreground",
|
||||
],
|
||||
},
|
||||
children: [{ type: "text", value: String(line) }],
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
export async function highlightCode(
|
||||
code: string,
|
||||
language: BundledLanguage,
|
||||
showLineNumbers = false,
|
||||
) {
|
||||
const transformers: ShikiTransformer[] = showLineNumbers
|
||||
? [lineNumberTransformer]
|
||||
: [];
|
||||
|
||||
return await Promise.all([
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-light",
|
||||
transformers,
|
||||
}),
|
||||
codeToHtml(code, {
|
||||
lang: language,
|
||||
theme: "one-dark-pro",
|
||||
transformers,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
export const CodeBlock = ({
|
||||
code,
|
||||
language,
|
||||
showLineNumbers = false,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: CodeBlockProps) => {
|
||||
const [html, setHtml] = useState<string>("");
|
||||
const [darkHtml, setDarkHtml] = useState<string>("");
|
||||
const mounted = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
highlightCode(code, language, showLineNumbers).then(([light, dark]) => {
|
||||
if (!mounted.current) {
|
||||
setHtml(light);
|
||||
setDarkHtml(dark);
|
||||
mounted.current = true;
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted.current = false;
|
||||
};
|
||||
}, [code, language, showLineNumbers]);
|
||||
|
||||
return (
|
||||
<CodeBlockContext.Provider value={{ code }}>
|
||||
<div
|
||||
className={cn(
|
||||
"group bg-background text-foreground relative size-full overflow-hidden rounded-md border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="relative size-full">
|
||||
<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"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
<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"
|
||||
// biome-ignore lint/security/noDangerouslySetInnerHtml: "this is needed."
|
||||
dangerouslySetInnerHTML={{ __html: darkHtml }}
|
||||
/>
|
||||
{children && (
|
||||
<div className="absolute top-2 right-2 flex items-center gap-2">
|
||||
{children}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</CodeBlockContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type CodeBlockCopyButtonProps = ComponentProps<typeof Button> & {
|
||||
onCopy?: () => void;
|
||||
onError?: (error: Error) => void;
|
||||
timeout?: number;
|
||||
};
|
||||
|
||||
export const CodeBlockCopyButton = ({
|
||||
onCopy,
|
||||
onError,
|
||||
timeout = 2000,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: CodeBlockCopyButtonProps) => {
|
||||
const [isCopied, setIsCopied] = useState(false);
|
||||
const { code } = useContext(CodeBlockContext);
|
||||
|
||||
const copyToClipboard = async () => {
|
||||
if (typeof window === "undefined" || !navigator?.clipboard?.writeText) {
|
||||
onError?.(new Error("Clipboard API not available"));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setIsCopied(true);
|
||||
onCopy?.();
|
||||
setTimeout(() => setIsCopied(false), timeout);
|
||||
} catch (error) {
|
||||
onError?.(error as Error);
|
||||
}
|
||||
};
|
||||
|
||||
const Icon = isCopied ? CheckIcon : CopyIcon;
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0", className)}
|
||||
onClick={copyToClipboard}
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <Icon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,28 +1,28 @@
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
import type { ConnectionLineComponent } from "@xyflow/react";
|
||||
|
||||
const HALF = 0.5;
|
||||
|
||||
export const Connection: ConnectionLineComponent = ({
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
}) => (
|
||||
<g>
|
||||
<path
|
||||
className="animated"
|
||||
d={`M${fromX},${fromY} C ${fromX + (toX - fromX) * HALF},${fromY} ${fromX + (toX - fromX) * HALF},${toY} ${toX},${toY}`}
|
||||
fill="none"
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
<circle
|
||||
cx={toX}
|
||||
cy={toY}
|
||||
fill="#fff"
|
||||
r={3}
|
||||
stroke="var(--color-ring)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
</g>
|
||||
);
|
||||
@@ -1,408 +1,408 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="text-muted-foreground font-mono">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="text-muted-foreground ml-2">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
HoverCard,
|
||||
HoverCardContent,
|
||||
HoverCardTrigger,
|
||||
} from "@/components/ui/hover-card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { LanguageModelUsage } from "ai";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
import { getUsage } from "tokenlens";
|
||||
|
||||
const PERCENT_MAX = 100;
|
||||
const ICON_RADIUS = 10;
|
||||
const ICON_VIEWBOX = 24;
|
||||
const ICON_CENTER = 12;
|
||||
const ICON_STROKE_WIDTH = 2;
|
||||
|
||||
type ModelId = string;
|
||||
|
||||
type ContextSchema = {
|
||||
usedTokens: number;
|
||||
maxTokens: number;
|
||||
usage?: LanguageModelUsage;
|
||||
modelId?: ModelId;
|
||||
};
|
||||
|
||||
const ContextContext = createContext<ContextSchema | null>(null);
|
||||
|
||||
const useContextValue = () => {
|
||||
const context = useContext(ContextContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("Context components must be used within Context");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ContextProps = ComponentProps<typeof HoverCard> & ContextSchema;
|
||||
|
||||
export const Context = ({
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
...props
|
||||
}: ContextProps) => (
|
||||
<ContextContext.Provider
|
||||
value={{
|
||||
usedTokens,
|
||||
maxTokens,
|
||||
usage,
|
||||
modelId,
|
||||
}}
|
||||
>
|
||||
<HoverCard closeDelay={0} openDelay={0} {...props} />
|
||||
</ContextContext.Provider>
|
||||
);
|
||||
|
||||
const ContextIcon = () => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const circumference = 2 * Math.PI * ICON_RADIUS;
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const dashOffset = circumference * (1 - usedPercent);
|
||||
|
||||
return (
|
||||
<svg
|
||||
aria-label="Model context usage"
|
||||
height="20"
|
||||
role="img"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox={`0 0 ${ICON_VIEWBOX} ${ICON_VIEWBOX}`}
|
||||
width="20"
|
||||
>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.25"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
/>
|
||||
<circle
|
||||
cx={ICON_CENTER}
|
||||
cy={ICON_CENTER}
|
||||
fill="none"
|
||||
opacity="0.7"
|
||||
r={ICON_RADIUS}
|
||||
stroke="currentColor"
|
||||
strokeDasharray={`${circumference} ${circumference}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="round"
|
||||
strokeWidth={ICON_STROKE_WIDTH}
|
||||
style={{ transformOrigin: "center", transform: "rotate(-90deg)" }}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextTriggerProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ContextTrigger = ({ children, ...props }: ContextTriggerProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const renderedPercent = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
|
||||
return (
|
||||
<HoverCardTrigger asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="ghost" {...props}>
|
||||
<span className="text-muted-foreground font-medium">
|
||||
{renderedPercent}
|
||||
</span>
|
||||
<ContextIcon />
|
||||
</Button>
|
||||
)}
|
||||
</HoverCardTrigger>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentProps = ComponentProps<typeof HoverCardContent>;
|
||||
|
||||
export const ContextContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ContextContentProps) => (
|
||||
<HoverCardContent
|
||||
className={cn("min-w-60 divide-y overflow-hidden p-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ContextContentHeaderProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentHeader = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentHeaderProps) => {
|
||||
const { usedTokens, maxTokens } = useContextValue();
|
||||
const usedPercent = usedTokens / maxTokens;
|
||||
const displayPct = new Intl.NumberFormat("en-US", {
|
||||
style: "percent",
|
||||
maximumFractionDigits: 1,
|
||||
}).format(usedPercent);
|
||||
const used = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(usedTokens);
|
||||
const total = new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(maxTokens);
|
||||
|
||||
return (
|
||||
<div className={cn("w-full space-y-2 p-3", className)} {...props}>
|
||||
{children ?? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3 text-xs">
|
||||
<p>{displayPct}</p>
|
||||
<p className="text-muted-foreground font-mono">
|
||||
{used} / {total}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Progress className="bg-muted" value={usedPercent * PERCENT_MAX} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextContentBodyProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentBody = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentBodyProps) => (
|
||||
<div className={cn("w-full p-3", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ContextContentFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextContentFooter = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: ContextContentFooterProps) => {
|
||||
const { modelId, usage } = useContextValue();
|
||||
const costUSD = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: {
|
||||
input: usage?.inputTokens ?? 0,
|
||||
output: usage?.outputTokens ?? 0,
|
||||
},
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const totalCost = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(costUSD ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary flex w-full items-center justify-between gap-3 p-3 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<span className="text-muted-foreground">Total cost</span>
|
||||
<span>{totalCost}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextInputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextInputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextInputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const inputTokens = usage?.inputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!inputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const inputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: inputTokens, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const inputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(inputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Input</span>
|
||||
<TokensWithCost costText={inputCostText} tokens={inputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextOutputUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextOutputUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextOutputUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const outputTokens = usage?.outputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!outputTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const outputCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { input: 0, output: outputTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const outputCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(outputCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Output</span>
|
||||
<TokensWithCost costText={outputCostText} tokens={outputTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextReasoningUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextReasoningUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextReasoningUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const reasoningTokens = usage?.reasoningTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!reasoningTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const reasoningCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { reasoningTokens },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const reasoningCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(reasoningCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Reasoning</span>
|
||||
<TokensWithCost costText={reasoningCostText} tokens={reasoningTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ContextCacheUsageProps = ComponentProps<"div">;
|
||||
|
||||
export const ContextCacheUsage = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ContextCacheUsageProps) => {
|
||||
const { usage, modelId } = useContextValue();
|
||||
const cacheTokens = usage?.cachedInputTokens ?? 0;
|
||||
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
if (!cacheTokens) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cacheCost = modelId
|
||||
? getUsage({
|
||||
modelId,
|
||||
usage: { cacheReads: cacheTokens, input: 0, output: 0 },
|
||||
}).costUSD?.totalUSD
|
||||
: undefined;
|
||||
const cacheCostText = new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: "USD",
|
||||
}).format(cacheCost ?? 0);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("flex items-center justify-between text-xs", className)}
|
||||
{...props}
|
||||
>
|
||||
<span className="text-muted-foreground">Cache</span>
|
||||
<TokensWithCost costText={cacheCostText} tokens={cacheTokens} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TokensWithCost = ({
|
||||
tokens,
|
||||
costText,
|
||||
}: {
|
||||
tokens?: number;
|
||||
costText?: string;
|
||||
}) => (
|
||||
<span>
|
||||
{tokens === undefined
|
||||
? "—"
|
||||
: new Intl.NumberFormat("en-US", {
|
||||
notation: "compact",
|
||||
}).format(tokens)}
|
||||
{costText ? (
|
||||
<span className="text-muted-foreground ml-2">• {costText}</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
@@ -1,18 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"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!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Controls as ControlsPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ControlsProps = ComponentProps<typeof ControlsPrimitive>;
|
||||
|
||||
export const Controls = ({ className, ...props }: ControlsProps) => (
|
||||
<ControlsPrimitive
|
||||
className={cn(
|
||||
"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!",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,100 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className,
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-hidden", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content
|
||||
className={cn("flex flex-col gap-8 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-medium">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className,
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
@@ -1,140 +1,140 @@
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-ring stroke-1"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position,
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition,
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
case Position.Left:
|
||||
offsetX = 0;
|
||||
break;
|
||||
case Position.Right:
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
case Position.Top:
|
||||
offsetY = 0;
|
||||
break;
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>,
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode,
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
};
|
||||
import {
|
||||
BaseEdge,
|
||||
type EdgeProps,
|
||||
getBezierPath,
|
||||
getSimpleBezierPath,
|
||||
type InternalNode,
|
||||
type Node,
|
||||
Position,
|
||||
useInternalNode,
|
||||
} from "@xyflow/react";
|
||||
|
||||
const Temporary = ({
|
||||
id,
|
||||
sourceX,
|
||||
sourceY,
|
||||
targetX,
|
||||
targetY,
|
||||
sourcePosition,
|
||||
targetPosition,
|
||||
}: EdgeProps) => {
|
||||
const [edgePath] = getSimpleBezierPath({
|
||||
sourceX,
|
||||
sourceY,
|
||||
sourcePosition,
|
||||
targetX,
|
||||
targetY,
|
||||
targetPosition,
|
||||
});
|
||||
|
||||
return (
|
||||
<BaseEdge
|
||||
className="stroke-ring stroke-1"
|
||||
id={id}
|
||||
path={edgePath}
|
||||
style={{
|
||||
strokeDasharray: "5, 5",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHandleCoordsByPosition = (
|
||||
node: InternalNode<Node>,
|
||||
handlePosition: Position,
|
||||
) => {
|
||||
// Choose the handle type based on position - Left is for target, Right is for source
|
||||
const handleType = handlePosition === Position.Left ? "target" : "source";
|
||||
|
||||
const handle = node.internals.handleBounds?.[handleType]?.find(
|
||||
(h) => h.position === handlePosition,
|
||||
);
|
||||
|
||||
if (!handle) {
|
||||
return [0, 0] as const;
|
||||
}
|
||||
|
||||
let offsetX = handle.width / 2;
|
||||
let offsetY = handle.height / 2;
|
||||
|
||||
// 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
|
||||
// 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) {
|
||||
case Position.Left:
|
||||
offsetX = 0;
|
||||
break;
|
||||
case Position.Right:
|
||||
offsetX = handle.width;
|
||||
break;
|
||||
case Position.Top:
|
||||
offsetY = 0;
|
||||
break;
|
||||
case Position.Bottom:
|
||||
offsetY = handle.height;
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Invalid handle position: ${handlePosition}`);
|
||||
}
|
||||
|
||||
const x = node.internals.positionAbsolute.x + handle.x + offsetX;
|
||||
const y = node.internals.positionAbsolute.y + handle.y + offsetY;
|
||||
|
||||
return [x, y] as const;
|
||||
};
|
||||
|
||||
const getEdgeParams = (
|
||||
source: InternalNode<Node>,
|
||||
target: InternalNode<Node>,
|
||||
) => {
|
||||
const sourcePos = Position.Right;
|
||||
const [sx, sy] = getHandleCoordsByPosition(source, sourcePos);
|
||||
const targetPos = Position.Left;
|
||||
const [tx, ty] = getHandleCoordsByPosition(target, targetPos);
|
||||
|
||||
return {
|
||||
sx,
|
||||
sy,
|
||||
tx,
|
||||
ty,
|
||||
sourcePos,
|
||||
targetPos,
|
||||
};
|
||||
};
|
||||
|
||||
const Animated = ({ id, source, target, markerEnd, style }: EdgeProps) => {
|
||||
const sourceNode = useInternalNode(source);
|
||||
const targetNode = useInternalNode(target);
|
||||
|
||||
if (!(sourceNode && targetNode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { sx, sy, tx, ty, sourcePos, targetPos } = getEdgeParams(
|
||||
sourceNode,
|
||||
targetNode,
|
||||
);
|
||||
|
||||
const [edgePath] = getBezierPath({
|
||||
sourceX: sx,
|
||||
sourceY: sy,
|
||||
sourcePosition: sourcePos,
|
||||
targetX: tx,
|
||||
targetY: ty,
|
||||
targetPosition: targetPos,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<BaseEdge id={id} markerEnd={markerEnd} path={edgePath} style={style} />
|
||||
<circle fill="var(--primary)" r="4">
|
||||
<animateMotion dur="2s" path={edgePath} repeatCount="indefinite" />
|
||||
</circle>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const Edge = {
|
||||
Temporary,
|
||||
Animated,
|
||||
};
|
||||
@@ -1,24 +1,24 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className,
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Experimental_GeneratedImage } from "ai";
|
||||
|
||||
export type ImageProps = Experimental_GeneratedImage & {
|
||||
className?: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
export const Image = ({
|
||||
base64,
|
||||
uint8Array,
|
||||
mediaType,
|
||||
...props
|
||||
}: ImageProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={props.alt}
|
||||
className={cn(
|
||||
"h-auto max-w-full overflow-hidden rounded-md",
|
||||
props.className,
|
||||
)}
|
||||
src={`data:${mediaType};base64,${base64}`}
|
||||
/>
|
||||
);
|
||||
@@ -1,96 +1,96 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
type LoaderIconProps = {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
const LoaderIcon = ({ size = 16 }: LoaderIconProps) => (
|
||||
<svg
|
||||
height={size}
|
||||
strokeLinejoin="round"
|
||||
style={{ color: "currentcolor" }}
|
||||
viewBox="0 0 16 16"
|
||||
width={size}
|
||||
>
|
||||
<title>Loader</title>
|
||||
<g clipPath="url(#clip0_2393_1490)">
|
||||
<path d="M8 0V4" stroke="currentColor" strokeWidth="1.5" />
|
||||
<path
|
||||
d="M8 16V12"
|
||||
opacity="0.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 1.52783L5.64887 4.7639"
|
||||
opacity="0.9"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 1.52783L10.3511 4.7639"
|
||||
opacity="0.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M12.7023 14.472L10.3511 11.236"
|
||||
opacity="0.4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M3.29773 14.472L5.64887 11.236"
|
||||
opacity="0.6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 5.52783L11.8043 6.7639"
|
||||
opacity="0.2"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 10.472L4.19583 9.23598"
|
||||
opacity="0.7"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M15.6085 10.4722L11.8043 9.2361"
|
||||
opacity="0.3"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
<path
|
||||
d="M0.391602 5.52783L4.19583 6.7639"
|
||||
opacity="0.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_2393_1490">
|
||||
<rect fill="white" height="16" width="16" />
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
);
|
||||
|
||||
export type LoaderProps = HTMLAttributes<HTMLDivElement> & {
|
||||
size?: number;
|
||||
};
|
||||
|
||||
export const Loader = ({ className, size = 16, ...props }: LoaderProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex animate-spin items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<LoaderIcon size={size} />
|
||||
</div>
|
||||
);
|
||||
@@ -1,446 +1,446 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"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]: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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden",
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"text-muted-foreground border-none bg-transparent shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ButtonGroup, ButtonGroupText } from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"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]: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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon-sm",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch",
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = Array.isArray(children) ? children : [children];
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden",
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"text-muted-foreground border-none bg-transparent shadow-none",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children,
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="bg-muted text-muted-foreground flex size-full shrink-0 items-center justify-center rounded-lg">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
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"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
@@ -1,208 +1,208 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span
|
||||
className={cn("flex-1 truncate text-left text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3 dark:invert", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span
|
||||
className={cn("flex-1 truncate text-left text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,71 +1,71 @@
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("bg-secondary rounded-b-md border-t p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Handle, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type NodeProps = ComponentProps<typeof Card> & {
|
||||
handles: {
|
||||
target: boolean;
|
||||
source: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export const Node = ({ handles, className, ...props }: NodeProps) => (
|
||||
<Card
|
||||
className={cn(
|
||||
"node-container relative size-full h-auto w-sm gap-0 rounded-md p-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{handles.target && <Handle position={Position.Left} type="target" />}
|
||||
{handles.source && <Handle position={Position.Right} type="source" />}
|
||||
{props.children}
|
||||
</Card>
|
||||
);
|
||||
|
||||
export type NodeHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const NodeHeader = ({ className, ...props }: NodeHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("bg-secondary gap-0.5 rounded-t-md border-b p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type NodeTitleProps = ComponentProps<typeof CardTitle>;
|
||||
|
||||
export const NodeTitle = (props: NodeTitleProps) => <CardTitle {...props} />;
|
||||
|
||||
export type NodeDescriptionProps = ComponentProps<typeof CardDescription>;
|
||||
|
||||
export const NodeDescription = (props: NodeDescriptionProps) => (
|
||||
<CardDescription {...props} />
|
||||
);
|
||||
|
||||
export type NodeActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const NodeAction = (props: NodeActionProps) => <CardAction {...props} />;
|
||||
|
||||
export type NodeContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const NodeContent = ({ className, ...props }: NodeContentProps) => (
|
||||
<CardContent className={cn("p-3", className)} {...props} />
|
||||
);
|
||||
|
||||
export type NodeFooterProps = ComponentProps<typeof CardFooter>;
|
||||
|
||||
export const NodeFooter = ({ className, ...props }: NodeFooterProps) => (
|
||||
<CardFooter
|
||||
className={cn("bg-secondary rounded-b-md border-t p-3!", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,365 +1,365 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: "Open in GitHub",
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: "Open in Scira",
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: "Open in ChatGPT",
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: "Open in Claude",
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: "Open in T3 Chat",
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in v0",
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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="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>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Open in Cursor",
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
ChevronDownIcon,
|
||||
ExternalLinkIcon,
|
||||
MessageCircleIcon,
|
||||
} from "lucide-react";
|
||||
import { type ComponentProps, createContext, useContext } from "react";
|
||||
|
||||
const providers = {
|
||||
github: {
|
||||
title: "Open in GitHub",
|
||||
createUrl: (url: string) => url,
|
||||
icon: (
|
||||
<svg fill="currentColor" role="img" viewBox="0 0 24 24">
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
scira: {
|
||||
title: "Open in Scira",
|
||||
createUrl: (q: string) =>
|
||||
`https://scira.ai/?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="none"
|
||||
height="934"
|
||||
viewBox="0 0 910 934"
|
||||
width="910"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Scira AI</title>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="20"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
stroke="currentColor"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="8"
|
||||
/>
|
||||
<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"
|
||||
stroke="currentColor"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="30"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
chatgpt: {
|
||||
title: "Open in ChatGPT",
|
||||
createUrl: (prompt: string) =>
|
||||
`https://chatgpt.com/?${new URLSearchParams({
|
||||
hints: "search",
|
||||
prompt,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
claude: {
|
||||
title: "Open in Claude",
|
||||
createUrl: (q: string) =>
|
||||
`https://claude.ai/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
role="img"
|
||||
viewBox="0 0 12 12"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Claude</title>
|
||||
<path
|
||||
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"
|
||||
fillRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
t3: {
|
||||
title: "Open in T3 Chat",
|
||||
createUrl: (q: string) =>
|
||||
`https://t3.chat/new?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: <MessageCircleIcon />,
|
||||
},
|
||||
v0: {
|
||||
title: "Open in v0",
|
||||
createUrl: (q: string) =>
|
||||
`https://v0.app?${new URLSearchParams({
|
||||
q,
|
||||
})}`,
|
||||
icon: (
|
||||
<svg
|
||||
fill="currentColor"
|
||||
viewBox="0 0 147 70"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<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="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>
|
||||
),
|
||||
},
|
||||
cursor: {
|
||||
title: "Open in Cursor",
|
||||
createUrl: (text: string) => {
|
||||
const url = new URL("https://cursor.com/link/prompt");
|
||||
url.searchParams.set("text", text);
|
||||
return url.toString();
|
||||
},
|
||||
icon: (
|
||||
<svg
|
||||
version="1.1"
|
||||
viewBox="0 0 466.73 532.09"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<title>Cursor</title>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
};
|
||||
|
||||
const OpenInContext = createContext<{ query: string } | undefined>(undefined);
|
||||
|
||||
const useOpenInContext = () => {
|
||||
const context = useContext(OpenInContext);
|
||||
if (!context) {
|
||||
throw new Error("OpenIn components must be used within an OpenIn provider");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type OpenInProps = ComponentProps<typeof DropdownMenu> & {
|
||||
query: string;
|
||||
};
|
||||
|
||||
export const OpenIn = ({ query, ...props }: OpenInProps) => (
|
||||
<OpenInContext.Provider value={{ query }}>
|
||||
<DropdownMenu {...props} />
|
||||
</OpenInContext.Provider>
|
||||
);
|
||||
|
||||
export type OpenInContentProps = ComponentProps<typeof DropdownMenuContent>;
|
||||
|
||||
export const OpenInContent = ({ className, ...props }: OpenInContentProps) => (
|
||||
<DropdownMenuContent
|
||||
align="start"
|
||||
className={cn("w-[240px]", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type OpenInItemProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInItem = (props: OpenInItemProps) => (
|
||||
<DropdownMenuItem {...props} />
|
||||
);
|
||||
|
||||
export type OpenInLabelProps = ComponentProps<typeof DropdownMenuLabel>;
|
||||
|
||||
export const OpenInLabel = (props: OpenInLabelProps) => (
|
||||
<DropdownMenuLabel {...props} />
|
||||
);
|
||||
|
||||
export type OpenInSeparatorProps = ComponentProps<typeof DropdownMenuSeparator>;
|
||||
|
||||
export const OpenInSeparator = (props: OpenInSeparatorProps) => (
|
||||
<DropdownMenuSeparator {...props} />
|
||||
);
|
||||
|
||||
export type OpenInTriggerProps = ComponentProps<typeof DropdownMenuTrigger>;
|
||||
|
||||
export const OpenInTrigger = ({ children, ...props }: OpenInTriggerProps) => (
|
||||
<DropdownMenuTrigger {...props} asChild>
|
||||
{children ?? (
|
||||
<Button type="button" variant="outline">
|
||||
Open in chat
|
||||
<ChevronDownIcon className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</DropdownMenuTrigger>
|
||||
);
|
||||
|
||||
export type OpenInChatGPTProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInChatGPT = (props: OpenInChatGPTProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.chatgpt.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.chatgpt.icon}</span>
|
||||
<span className="flex-1">{providers.chatgpt.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInClaudeProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInClaude = (props: OpenInClaudeProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.claude.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.claude.icon}</span>
|
||||
<span className="flex-1">{providers.claude.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInT3Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInT3 = (props: OpenInT3Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.t3.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.t3.icon}</span>
|
||||
<span className="flex-1">{providers.t3.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInSciraProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInScira = (props: OpenInSciraProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.scira.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.scira.icon}</span>
|
||||
<span className="flex-1">{providers.scira.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInv0Props = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInv0 = (props: OpenInv0Props) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.v0.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.v0.icon}</span>
|
||||
<span className="flex-1">{providers.v0.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
|
||||
export type OpenInCursorProps = ComponentProps<typeof DropdownMenuItem>;
|
||||
|
||||
export const OpenInCursor = (props: OpenInCursorProps) => {
|
||||
const { query } = useOpenInContext();
|
||||
return (
|
||||
<DropdownMenuItem asChild {...props}>
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={providers.cursor.createUrl(query)}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="shrink-0">{providers.cursor.icon}</span>
|
||||
<span className="flex-1">{providers.cursor.title}</span>
|
||||
<ExternalLinkIcon className="size-4 shrink-0" />
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
};
|
||||
@@ -1,15 +1,15 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"bg-card m-4 overflow-hidden rounded-md border p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Panel as PanelPrimitive } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type PanelProps = ComponentProps<typeof PanelPrimitive>;
|
||||
|
||||
export const Panel = ({ className, ...props }: PanelProps) => (
|
||||
<PanelPrimitive
|
||||
className={cn(
|
||||
"bg-card m-4 overflow-hidden rounded-md border p-1",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,142 +1,142 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardAction,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronsUpDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, useContext } from "react";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type PlanContextValue = {
|
||||
isStreaming: boolean;
|
||||
};
|
||||
|
||||
const PlanContext = createContext<PlanContextValue | null>(null);
|
||||
|
||||
const usePlan = () => {
|
||||
const context = useContext(PlanContext);
|
||||
if (!context) {
|
||||
throw new Error("Plan components must be used within Plan");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type PlanProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
};
|
||||
|
||||
export const Plan = ({
|
||||
className,
|
||||
isStreaming = false,
|
||||
children,
|
||||
...props
|
||||
}: PlanProps) => (
|
||||
<PlanContext.Provider value={{ isStreaming }}>
|
||||
<Collapsible asChild data-slot="plan" {...props}>
|
||||
<Card className={cn("shadow-none", className)}>{children}</Card>
|
||||
</Collapsible>
|
||||
</PlanContext.Provider>
|
||||
);
|
||||
|
||||
export type PlanHeaderProps = ComponentProps<typeof CardHeader>;
|
||||
|
||||
export const PlanHeader = ({ className, ...props }: PlanHeaderProps) => (
|
||||
<CardHeader
|
||||
className={cn("flex items-start justify-between", className)}
|
||||
data-slot="plan-header"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type PlanTitleProps = Omit<
|
||||
ComponentProps<typeof CardTitle>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanTitle = ({ children, ...props }: PlanTitleProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardTitle data-slot="plan-title" {...props}>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardTitle>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanDescriptionProps = Omit<
|
||||
ComponentProps<typeof CardDescription>,
|
||||
"children"
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const PlanDescription = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: PlanDescriptionProps) => {
|
||||
const { isStreaming } = usePlan();
|
||||
|
||||
return (
|
||||
<CardDescription
|
||||
className={cn("text-balance", className)}
|
||||
data-slot="plan-description"
|
||||
{...props}
|
||||
>
|
||||
{isStreaming ? <Shimmer>{children}</Shimmer> : children}
|
||||
</CardDescription>
|
||||
);
|
||||
};
|
||||
|
||||
export type PlanActionProps = ComponentProps<typeof CardAction>;
|
||||
|
||||
export const PlanAction = (props: PlanActionProps) => (
|
||||
<CardAction data-slot="plan-action" {...props} />
|
||||
);
|
||||
|
||||
export type PlanContentProps = ComponentProps<typeof CardContent>;
|
||||
|
||||
export const PlanContent = (props: PlanContentProps) => (
|
||||
<CollapsibleContent asChild>
|
||||
<CardContent data-slot="plan-content" {...props} />
|
||||
</CollapsibleContent>
|
||||
);
|
||||
|
||||
export type PlanFooterProps = ComponentProps<"div">;
|
||||
|
||||
export const PlanFooter = (props: PlanFooterProps) => (
|
||||
<CardFooter data-slot="plan-footer" {...props} />
|
||||
);
|
||||
|
||||
export type PlanTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
export const PlanTrigger = ({ className, ...props }: PlanTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className={cn("size-8", className)}
|
||||
data-slot="plan-trigger"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
<ChevronsUpDownIcon className="size-4" />
|
||||
<span className="sr-only">Toggle plan</span>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,274 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
};
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
};
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, PaperclipIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type QueueMessagePart = {
|
||||
type: string;
|
||||
text?: string;
|
||||
url?: string;
|
||||
filename?: string;
|
||||
mediaType?: string;
|
||||
};
|
||||
|
||||
export type QueueMessage = {
|
||||
id: string;
|
||||
parts: QueueMessagePart[];
|
||||
};
|
||||
|
||||
export type QueueTodo = {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status?: "pending" | "completed";
|
||||
};
|
||||
|
||||
export type QueueItemProps = ComponentProps<"li">;
|
||||
|
||||
export const QueueItem = ({ className, ...props }: QueueItemProps) => (
|
||||
<li
|
||||
className={cn(
|
||||
"group hover:bg-muted flex flex-col gap-1 rounded-md px-3 py-1 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemIndicatorProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemIndicator = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemIndicatorProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"mt-0.5 inline-block size-2.5 rounded-full border",
|
||||
completed
|
||||
? "border-muted-foreground/20 bg-muted-foreground/10"
|
||||
: "border-muted-foreground/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemContentProps = ComponentProps<"span"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemContent = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemContentProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"line-clamp-1 grow break-words",
|
||||
completed
|
||||
? "text-muted-foreground/50 line-through"
|
||||
: "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemDescriptionProps = ComponentProps<"div"> & {
|
||||
completed?: boolean;
|
||||
};
|
||||
|
||||
export const QueueItemDescription = ({
|
||||
completed = false,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemDescriptionProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-6 text-xs",
|
||||
completed
|
||||
? "text-muted-foreground/40 line-through"
|
||||
: "text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemActions = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionsProps) => (
|
||||
<div className={cn("flex gap-1", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemActionProps = Omit<
|
||||
ComponentProps<typeof Button>,
|
||||
"variant" | "size"
|
||||
>;
|
||||
|
||||
export const QueueItemAction = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemActionProps) => (
|
||||
<Button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemAttachmentProps = ComponentProps<"div">;
|
||||
|
||||
export const QueueItemAttachment = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemAttachmentProps) => (
|
||||
<div className={cn("mt-1 flex flex-wrap gap-2", className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueItemImageProps = ComponentProps<"img">;
|
||||
|
||||
export const QueueItemImage = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueItemImageProps) => (
|
||||
<img
|
||||
alt=""
|
||||
className={cn("h-8 w-8 rounded border object-cover", className)}
|
||||
height={32}
|
||||
width={32}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type QueueItemFileProps = ComponentProps<"span">;
|
||||
|
||||
export const QueueItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueItemFileProps) => (
|
||||
<span
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-1 rounded border px-2 py-1 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<PaperclipIcon size={12} />
|
||||
<span className="max-w-[100px] truncate">{children}</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
export type QueueListProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const QueueList = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueListProps) => (
|
||||
<ScrollArea className={cn("mt-2 -mb-1", className)} {...props}>
|
||||
<div className="max-h-40 pr-4">
|
||||
<ul>{children}</ul>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
// QueueSection - collapsible section container
|
||||
export type QueueSectionProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const QueueSection = ({
|
||||
className,
|
||||
defaultOpen = true,
|
||||
...props
|
||||
}: QueueSectionProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
// QueueSectionTrigger - section header/trigger
|
||||
export type QueueSectionTriggerProps = ComponentProps<"button">;
|
||||
|
||||
export const QueueSectionTrigger = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionTriggerProps) => (
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
// QueueSectionLabel - label content with icon and count
|
||||
export type QueueSectionLabelProps = ComponentProps<"span"> & {
|
||||
count?: number;
|
||||
label: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const QueueSectionLabel = ({
|
||||
count,
|
||||
label,
|
||||
icon,
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionLabelProps) => (
|
||||
<span className={cn("flex items-center gap-2", className)} {...props}>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=closed]:-rotate-90" />
|
||||
{icon}
|
||||
<span>
|
||||
{count} {label}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
|
||||
// QueueSectionContent - collapsible content area
|
||||
export type QueueSectionContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
>;
|
||||
|
||||
export const QueueSectionContent = ({
|
||||
className,
|
||||
...props
|
||||
}: QueueSectionContentProps) => (
|
||||
<CollapsibleContent className={cn(className)} {...props} />
|
||||
);
|
||||
|
||||
export type QueueProps = ComponentProps<"div">;
|
||||
|
||||
export const Queue = ({ className, ...props }: QueueProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"border-border bg-background flex flex-col gap-2 rounded-xl border px-3 pt-2 pb-2 shadow-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,188 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <span>Thought for a few seconds</span>;
|
||||
}
|
||||
return <span>Thought for {duration} seconds</span>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { reasoningPlugins } from "@/core/streamdown/plugins";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
export const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<
|
||||
typeof CollapsibleTrigger
|
||||
> & {
|
||||
getThinkingMessage?: (isStreaming: boolean, duration?: number) => ReactNode;
|
||||
};
|
||||
|
||||
const defaultGetThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>Thinking...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <span>Thought for a few seconds</span>;
|
||||
}
|
||||
return <span>Thought for {duration} seconds</span>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({
|
||||
className,
|
||||
children,
|
||||
getThinkingMessage = defaultGetThinkingMessage,
|
||||
...props
|
||||
}: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"text-muted-foreground hover:text-foreground flex w-full items-center gap-2 text-sm transition-colors",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0",
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...reasoningPlugins}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
),
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
@@ -1,64 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"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)))]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements,
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread],
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"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)))]",
|
||||
className,
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
@@ -1,77 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose text-primary mb-4 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BookIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type SourcesProps = ComponentProps<"div">;
|
||||
|
||||
export const Sources = ({ className, ...props }: SourcesProps) => (
|
||||
<Collapsible
|
||||
className={cn("not-prose text-primary mb-4 text-xs", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourcesTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
count: number;
|
||||
};
|
||||
|
||||
export const SourcesTrigger = ({
|
||||
className,
|
||||
count,
|
||||
children,
|
||||
...props
|
||||
}: SourcesTriggerProps) => (
|
||||
<CollapsibleTrigger
|
||||
className={cn("flex items-center gap-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<p className="font-medium">Used {count} sources</p>
|
||||
<ChevronDownIcon className="h-4 w-4" />
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type SourcesContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const SourcesContent = ({
|
||||
className,
|
||||
...props
|
||||
}: SourcesContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type SourceProps = ComponentProps<"a">;
|
||||
|
||||
export const Source = ({ href, title, children, ...props }: SourceProps) => (
|
||||
<a
|
||||
className="flex items-center gap-2"
|
||||
href={href}
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BookIcon className="h-4 w-4" />
|
||||
<span className="block font-medium">{title}</span>
|
||||
</>
|
||||
)}
|
||||
</a>
|
||||
);
|
||||
@@ -1,80 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Children, type ComponentProps } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STAGGER_DELAY_MS = 60;
|
||||
const STAGGER_DELAY_MS_OFFSET = 250;
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="overflow-x-auto whitespace-normal" {...props}>
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2", className)}
|
||||
data-slot="suggestions-list"
|
||||
>
|
||||
{Children.map(children, (child, index) =>
|
||||
child != null ? (
|
||||
<span
|
||||
className="animate-fade-in-up max-w-full opacity-0"
|
||||
style={{
|
||||
animationDelay: `${STAGGER_DELAY_MS_OFFSET + index * STAGGER_DELAY_MS}ms`,
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</span>
|
||||
) : (
|
||||
child
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
icon: Icon,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{children ?? suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { Children, type ComponentProps } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ScrollArea, ScrollBar } from "@/components/ui/scroll-area";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const STAGGER_DELAY_MS = 60;
|
||||
const STAGGER_DELAY_MS_OFFSET = 250;
|
||||
|
||||
export type SuggestionsProps = ComponentProps<typeof ScrollArea>;
|
||||
|
||||
export const Suggestions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: SuggestionsProps) => (
|
||||
<ScrollArea className="overflow-x-auto whitespace-normal" {...props}>
|
||||
<div
|
||||
className={cn("flex w-full flex-wrap items-center gap-2", className)}
|
||||
data-slot="suggestions-list"
|
||||
>
|
||||
{Children.map(children, (child, index) =>
|
||||
child != null ? (
|
||||
<span
|
||||
className="animate-fade-in-up max-w-full opacity-0"
|
||||
style={{
|
||||
animationDelay: `${STAGGER_DELAY_MS_OFFSET + index * STAGGER_DELAY_MS}ms`,
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</span>
|
||||
) : (
|
||||
child
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
<ScrollBar className="hidden" orientation="horizontal" />
|
||||
</ScrollArea>
|
||||
);
|
||||
|
||||
export type SuggestionProps = Omit<ComponentProps<typeof Button>, "onClick"> & {
|
||||
suggestion: React.ReactNode;
|
||||
icon?: LucideIcon;
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
export const Suggestion = ({
|
||||
suggestion,
|
||||
onClick,
|
||||
className,
|
||||
icon: Icon,
|
||||
variant = "outline",
|
||||
size = "sm",
|
||||
children,
|
||||
...props
|
||||
}: SuggestionProps) => {
|
||||
const handleClick = () => {
|
||||
onClick?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
onClick={handleClick}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{Icon && <Icon className="size-4" />}
|
||||
{children ?? suggestion}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
@@ -1,87 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<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" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-muted mt-4 space-y-2 border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon, SearchIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type TaskItemFileProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItemFile = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskItemFileProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"bg-secondary text-foreground inline-flex items-center gap-1 rounded-md border px-1.5 py-0.5 text-xs",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskItemProps = ComponentProps<"div">;
|
||||
|
||||
export const TaskItem = ({ children, className, ...props }: TaskItemProps) => (
|
||||
<div className={cn("text-muted-foreground text-sm", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type TaskProps = ComponentProps<typeof Collapsible>;
|
||||
|
||||
export const Task = ({
|
||||
defaultOpen = true,
|
||||
className,
|
||||
...props
|
||||
}: TaskProps) => (
|
||||
<Collapsible className={cn(className)} defaultOpen={defaultOpen} {...props} />
|
||||
);
|
||||
|
||||
export type TaskTriggerProps = ComponentProps<typeof CollapsibleTrigger> & {
|
||||
title: string;
|
||||
};
|
||||
|
||||
export const TaskTrigger = ({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
...props
|
||||
}: TaskTriggerProps) => (
|
||||
<CollapsibleTrigger asChild className={cn("group", className)} {...props}>
|
||||
{children ?? (
|
||||
<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" />
|
||||
<p className="text-sm">{title}</p>
|
||||
<ChevronDownIcon className="size-4 transition-transform group-data-[state=open]:rotate-180" />
|
||||
</div>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
|
||||
export type TaskContentProps = ComponentProps<typeof CollapsibleContent>;
|
||||
|
||||
export const TaskContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: TaskContentProps) => (
|
||||
<CollapsibleContent
|
||||
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",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="border-muted mt-4 space-y-2 border-l-2 pl-4">
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
);
|
||||
@@ -1,16 +1,16 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"bg-background flex items-center gap-1 rounded-sm border p-1.5",
|
||||
className,
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
import { cn } from "@/lib/utils";
|
||||
import { NodeToolbar, Position } from "@xyflow/react";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
type ToolbarProps = ComponentProps<typeof NodeToolbar>;
|
||||
|
||||
export const Toolbar = ({ className, ...props }: ToolbarProps) => (
|
||||
<NodeToolbar
|
||||
className={cn(
|
||||
"bg-background flex items-center gap-1 rounded-sm border p-1.5",
|
||||
className,
|
||||
)}
|
||||
position={Position.Bottom}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -1,263 +1,263 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex size-full flex-col rounded-lg border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hover:text-foreground h-8 w-8 p-0"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url);
|
||||
}, [url]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: Array<{
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("bg-muted/50 border-t font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground",
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps, ReactNode } from "react";
|
||||
import { createContext, useContext, useEffect, useState } from "react";
|
||||
|
||||
export type WebPreviewContextValue = {
|
||||
url: string;
|
||||
setUrl: (url: string) => void;
|
||||
consoleOpen: boolean;
|
||||
setConsoleOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const WebPreviewContext = createContext<WebPreviewContextValue | null>(null);
|
||||
|
||||
const useWebPreview = () => {
|
||||
const context = useContext(WebPreviewContext);
|
||||
if (!context) {
|
||||
throw new Error("WebPreview components must be used within a WebPreview");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type WebPreviewProps = ComponentProps<"div"> & {
|
||||
defaultUrl?: string;
|
||||
onUrlChange?: (url: string) => void;
|
||||
};
|
||||
|
||||
export const WebPreview = ({
|
||||
className,
|
||||
children,
|
||||
defaultUrl = "",
|
||||
onUrlChange,
|
||||
...props
|
||||
}: WebPreviewProps) => {
|
||||
const [url, setUrl] = useState(defaultUrl);
|
||||
const [consoleOpen, setConsoleOpen] = useState(false);
|
||||
|
||||
const handleUrlChange = (newUrl: string) => {
|
||||
setUrl(newUrl);
|
||||
onUrlChange?.(newUrl);
|
||||
};
|
||||
|
||||
const contextValue: WebPreviewContextValue = {
|
||||
url,
|
||||
setUrl: handleUrlChange,
|
||||
consoleOpen,
|
||||
setConsoleOpen,
|
||||
};
|
||||
|
||||
return (
|
||||
<WebPreviewContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn(
|
||||
"bg-card flex size-full flex-col rounded-lg border",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</WebPreviewContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewNavigationProps = ComponentProps<"div">;
|
||||
|
||||
export const WebPreviewNavigation = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationProps) => (
|
||||
<div
|
||||
className={cn("flex items-center gap-1 border-b p-2", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type WebPreviewNavigationButtonProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
};
|
||||
|
||||
export const WebPreviewNavigationButton = ({
|
||||
onClick,
|
||||
disabled,
|
||||
tooltip,
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewNavigationButtonProps) => (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="hover:text-foreground h-8 w-8 p-0"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
|
||||
export type WebPreviewUrlProps = ComponentProps<typeof Input>;
|
||||
|
||||
export const WebPreviewUrl = ({
|
||||
value,
|
||||
onChange,
|
||||
onKeyDown,
|
||||
...props
|
||||
}: WebPreviewUrlProps) => {
|
||||
const { url, setUrl } = useWebPreview();
|
||||
const [inputValue, setInputValue] = useState(url);
|
||||
|
||||
// Sync input value with context URL when it changes externally
|
||||
useEffect(() => {
|
||||
setInputValue(url);
|
||||
}, [url]);
|
||||
|
||||
const handleChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setInputValue(event.target.value);
|
||||
onChange?.(event);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "Enter") {
|
||||
const target = event.target as HTMLInputElement;
|
||||
setUrl(target.value);
|
||||
}
|
||||
onKeyDown?.(event);
|
||||
};
|
||||
|
||||
return (
|
||||
<Input
|
||||
className="h-8 flex-1 text-sm"
|
||||
onChange={onChange ?? handleChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter URL..."
|
||||
value={value ?? inputValue}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewBodyProps = ComponentProps<"iframe"> & {
|
||||
loading?: ReactNode;
|
||||
};
|
||||
|
||||
export const WebPreviewBody = ({
|
||||
className,
|
||||
loading,
|
||||
src,
|
||||
...props
|
||||
}: WebPreviewBodyProps) => {
|
||||
const { url } = useWebPreview();
|
||||
|
||||
return (
|
||||
<div className="flex-1">
|
||||
<iframe
|
||||
className={cn("size-full", className)}
|
||||
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-presentation"
|
||||
src={(src ?? url) || undefined}
|
||||
title="Preview"
|
||||
{...props}
|
||||
/>
|
||||
{loading}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type WebPreviewConsoleProps = ComponentProps<"div"> & {
|
||||
logs?: Array<{
|
||||
level: "log" | "warn" | "error";
|
||||
message: string;
|
||||
timestamp: Date;
|
||||
}>;
|
||||
};
|
||||
|
||||
export const WebPreviewConsole = ({
|
||||
className,
|
||||
logs = [],
|
||||
children,
|
||||
...props
|
||||
}: WebPreviewConsoleProps) => {
|
||||
const { consoleOpen, setConsoleOpen } = useWebPreview();
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
className={cn("bg-muted/50 border-t font-mono text-sm", className)}
|
||||
onOpenChange={setConsoleOpen}
|
||||
open={consoleOpen}
|
||||
{...props}
|
||||
>
|
||||
<CollapsibleTrigger asChild>
|
||||
<Button
|
||||
className="hover:bg-muted/50 flex w-full items-center justify-between p-4 text-left font-medium"
|
||||
variant="ghost"
|
||||
>
|
||||
Console
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"h-4 w-4 transition-transform duration-200",
|
||||
consoleOpen && "rotate-180",
|
||||
)}
|
||||
/>
|
||||
</Button>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"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",
|
||||
)}
|
||||
>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto">
|
||||
{logs.length === 0 ? (
|
||||
<p className="text-muted-foreground">No console output</p>
|
||||
) : (
|
||||
logs.map((log, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"text-xs",
|
||||
log.level === "error" && "text-destructive",
|
||||
log.level === "warn" && "text-yellow-600",
|
||||
log.level === "log" && "text-foreground",
|
||||
)}
|
||||
key={`${log.timestamp.getTime()}-${index}`}
|
||||
>
|
||||
<span className="text-muted-foreground">
|
||||
{log.timestamp.toLocaleTimeString()}
|
||||
</span>{" "}
|
||||
{log.message}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
{children}
|
||||
</div>
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,19 +1,19 @@
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Footer() {
|
||||
const year = useMemo(() => new Date().getFullYear(), []);
|
||||
return (
|
||||
<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" />
|
||||
<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">
|
||||
"Originated from Open Source, give back to Open Source."
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||
<p>Licensed under MIT License</p>
|
||||
<p>© {year} CM业务中心-问答管理</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
import { useMemo } from "react";
|
||||
|
||||
export function Footer() {
|
||||
const year = useMemo(() => new Date().getFullYear(), []);
|
||||
return (
|
||||
<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" />
|
||||
<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">
|
||||
"Originated from Open Source, give back to Open Source."
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-muted-foreground container mb-8 flex flex-col items-center justify-center text-xs">
|
||||
<p>Licensed under MIT License</p>
|
||||
<p>© {year} CM 助手</p>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -1,127 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import { StarFilledIcon, GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { NumberTicker } from "@/components/ui/number-ticker";
|
||||
import type { Locale } from "@/core/i18n/locale";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeaderProps = {
|
||||
className?: string;
|
||||
homeURL?: string;
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Header({ className, homeURL }: HeaderProps) {
|
||||
const isExternalHome = !homeURL;
|
||||
const { locale: resolvedLocale, t } = useI18n();
|
||||
const lang = resolvedLocale.substring(0, 2);
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href={homeURL ?? "https://github.com/bytedance/deer-flow"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<h1 className="font-serif text-xl">CM业务中心-问答管理</h1>
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||
<Link
|
||||
href={`/${lang}/docs`}
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.docs}
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog/posts"
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
|
||||
import type { Locale } from "@/core/i18n/locale";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export type HeaderProps = {
|
||||
className?: string;
|
||||
homeURL?: string;
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function Header({ className, homeURL }: HeaderProps) {
|
||||
const isExternalHome = !homeURL;
|
||||
const { locale: resolvedLocale, t } = useI18n();
|
||||
const lang = resolvedLocale.substring(0, 2);
|
||||
return (
|
||||
<header
|
||||
className={cn(
|
||||
"container-md fixed top-0 right-0 left-0 z-20 mx-auto flex h-16 items-center justify-between backdrop-blur-xs",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-6">
|
||||
<a
|
||||
href={homeURL ?? "/"}
|
||||
target={isExternalHome ? "_blank" : "_self"}
|
||||
rel={isExternalHome ? "noopener noreferrer" : undefined}
|
||||
>
|
||||
<h1 className="font-serif text-xl">CM 助手</h1>
|
||||
</a>
|
||||
</div>
|
||||
<nav className="mr-8 ml-auto flex items-center gap-8 text-sm font-medium">
|
||||
<Link
|
||||
href={`/${lang}/docs`}
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.docs}
|
||||
</Link>
|
||||
<Link
|
||||
href="/blog/posts"
|
||||
className="text-secondary-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
{t.home.blog}
|
||||
</Link>
|
||||
</nav>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -1,136 +1,136 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Hero({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 z-0 bg-black/40">
|
||||
<Galaxy
|
||||
mouseRepulsion={false}
|
||||
starSpeed={0.2}
|
||||
density={0.6}
|
||||
glowIntensity={0.35}
|
||||
twinkleIntensity={0.3}
|
||||
speed={0.5}
|
||||
/>
|
||||
</div>
|
||||
<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]"
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
maxOpacity={0.3}
|
||||
flickerChance={0.25}
|
||||
/>
|
||||
<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">
|
||||
<WordRotate
|
||||
words={[
|
||||
"Deep Research",
|
||||
"Collect Data",
|
||||
"Analyze Data",
|
||||
"Generate Webpages",
|
||||
"Vibe Coding",
|
||||
"Generate Slides",
|
||||
"Generate Images",
|
||||
"Generate Podcasts",
|
||||
"Generate Videos",
|
||||
"Generate Songs",
|
||||
"Organize Emails",
|
||||
"Do Anything",
|
||||
"Learn Anything",
|
||||
]}
|
||||
/>{" "}
|
||||
<div>CM业务中心-问答管理</div>
|
||||
</h1>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||
<a
|
||||
href="https://byteplus.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<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>
|
||||
<BytePlusIcon className="h-4" />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
<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.
|
||||
With
|
||||
<br />
|
||||
the help of sandboxes, memories, tools, skills and subagents, it
|
||||
handles
|
||||
<br />
|
||||
different levels of tasks that could take minutes to hours.
|
||||
</p>
|
||||
<Link href="/workspace">
|
||||
<Button className="size-lg mt-8 scale-108" size="lg">
|
||||
<span className="text-md">Get Started with 2.0</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={80}
|
||||
height={24}
|
||||
viewBox="0 0 120 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M92.168 3.36719H94.8104V18.8101H92.168V3.36719Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { ChevronRightIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { FlickeringGrid } from "@/components/ui/flickering-grid";
|
||||
import Galaxy from "@/components/ui/galaxy";
|
||||
import { WordRotate } from "@/components/ui/word-rotate";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Hero({ className }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div className="absolute inset-0 z-0 bg-black/40">
|
||||
<Galaxy
|
||||
mouseRepulsion={false}
|
||||
starSpeed={0.2}
|
||||
density={0.6}
|
||||
glowIntensity={0.35}
|
||||
twinkleIntensity={0.3}
|
||||
speed={0.5}
|
||||
/>
|
||||
</div>
|
||||
<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]"
|
||||
squareSize={4}
|
||||
gridGap={4}
|
||||
color={"white"}
|
||||
maxOpacity={0.3}
|
||||
flickerChance={0.25}
|
||||
/>
|
||||
<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">
|
||||
<WordRotate
|
||||
words={[
|
||||
"Deep Research",
|
||||
"Collect Data",
|
||||
"Analyze Data",
|
||||
"Generate Webpages",
|
||||
"Vibe Coding",
|
||||
"Generate Slides",
|
||||
"Generate Images",
|
||||
"Generate Podcasts",
|
||||
"Generate Videos",
|
||||
"Generate Songs",
|
||||
"Organize Emails",
|
||||
"Do Anything",
|
||||
"Learn Anything",
|
||||
]}
|
||||
/>{" "}
|
||||
<div>CM 助手</div>
|
||||
</h1>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY && (
|
||||
<a
|
||||
href="https://byteplus.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<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>
|
||||
<BytePlusIcon className="h-4" />
|
||||
</div>
|
||||
</a>
|
||||
)}
|
||||
<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.
|
||||
With
|
||||
<br />
|
||||
the help of sandboxes, memories, tools, skills and subagents, it
|
||||
handles
|
||||
<br />
|
||||
different levels of tasks that could take minutes to hours.
|
||||
</p>
|
||||
<Link href="/workspace">
|
||||
<Button className="size-lg mt-8 scale-108" size="lg">
|
||||
<span className="text-md">Get Started with 2.0</span>
|
||||
<ChevronRightIcon className="size-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BytePlusIcon(props: React.SVGProps<SVGSVGElement>) {
|
||||
return (
|
||||
<svg
|
||||
width={80}
|
||||
height={24}
|
||||
viewBox="0 0 120 24"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M92.168 3.36719H94.8104V18.8101H92.168V3.36719Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<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"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,163 +1,163 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { getBlogRoute, normalizeTagSlug, type BlogPost } from "@/core/blog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PostListProps = {
|
||||
description?: string;
|
||||
posts: BlogPost[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PostMetaProps = {
|
||||
currentLang?: string;
|
||||
date?: string | null;
|
||||
languages?: string[];
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
function formatDate(date?: string): string | null {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = new Date(date);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function PostMeta({
|
||||
currentLang,
|
||||
date,
|
||||
languages,
|
||||
pathname,
|
||||
}: PostMetaProps) {
|
||||
const formattedDate = formatDate(date ?? undefined);
|
||||
const availableLanguages = Array.isArray(languages)
|
||||
? languages.filter((lang): lang is string => typeof lang === "string")
|
||||
: [];
|
||||
|
||||
if (!formattedDate && availableLanguages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-8 text-sm">
|
||||
{formattedDate ? (
|
||||
<p className="text-muted-foreground">{formattedDate}</p>
|
||||
) : null}
|
||||
|
||||
{pathname && availableLanguages.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-secondary-foreground text-sm">Language:</span>
|
||||
{availableLanguages.map((lang) => {
|
||||
const isActive = lang === currentLang;
|
||||
return (
|
||||
<Link
|
||||
key={lang}
|
||||
href={`${pathname}?lang=${lang}`}
|
||||
className={
|
||||
isActive
|
||||
? "text-foreground text-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostTags({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags?: unknown;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTags = tags.filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
|
||||
if (validTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-3", className)}>
|
||||
<span className="text-secondary-foreground text-sm">Tags:</span>
|
||||
{validTags.map((tag) => (
|
||||
<Link
|
||||
key={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"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostList({ description, posts, title }: PostListProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-12 px-6">
|
||||
<header className="space-y-4">
|
||||
<h2 className="text-foreground text-4xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="text-secondary-foreground">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="space-y-12">
|
||||
{posts.map((post) => {
|
||||
return (
|
||||
<article
|
||||
key={post.slug.join("/")}
|
||||
className="border-border space-y-5 border-b pb-12 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PostMeta
|
||||
currentLang={post.lang}
|
||||
date={post.metadata.date}
|
||||
languages={post.languages}
|
||||
pathname={getBlogRoute(post.slug)}
|
||||
/>
|
||||
<Link
|
||||
href={getBlogRoute(post.slug)}
|
||||
className="text-foreground hover:text-primary block text-2xl font-semibold tracking-tight transition-colors"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{post.metadata.description ? (
|
||||
<p className="text-secondary-foreground leading-10">
|
||||
{post.metadata.description}
|
||||
</p>
|
||||
) : null}
|
||||
<PostTags tags={post.metadata.tags} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import Link from "next/link";
|
||||
|
||||
import { getBlogRoute, normalizeTagSlug, type BlogPost } from "@/core/blog";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type PostListProps = {
|
||||
description?: string;
|
||||
posts: BlogPost[];
|
||||
title: string;
|
||||
};
|
||||
|
||||
type PostMetaProps = {
|
||||
currentLang?: string;
|
||||
date?: string | null;
|
||||
languages?: string[];
|
||||
pathname?: string;
|
||||
};
|
||||
|
||||
function formatDate(date?: string): string | null {
|
||||
if (!date) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const value = new Date(date);
|
||||
if (Number.isNaN(value.getTime())) {
|
||||
return date;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(value);
|
||||
}
|
||||
|
||||
export function PostMeta({
|
||||
currentLang,
|
||||
date,
|
||||
languages,
|
||||
pathname,
|
||||
}: PostMetaProps) {
|
||||
const formattedDate = formatDate(date ?? undefined);
|
||||
const availableLanguages = Array.isArray(languages)
|
||||
? languages.filter((lang): lang is string => typeof lang === "string")
|
||||
: [];
|
||||
|
||||
if (!formattedDate && availableLanguages.length <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-8 text-sm">
|
||||
{formattedDate ? (
|
||||
<p className="text-muted-foreground">{formattedDate}</p>
|
||||
) : null}
|
||||
|
||||
{pathname && availableLanguages.length > 1 ? (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-secondary-foreground text-sm">Language:</span>
|
||||
{availableLanguages.map((lang) => {
|
||||
const isActive = lang === currentLang;
|
||||
return (
|
||||
<Link
|
||||
key={lang}
|
||||
href={`${pathname}?lang=${lang}`}
|
||||
className={
|
||||
isActive
|
||||
? "text-foreground text-sm font-medium"
|
||||
: "text-muted-foreground hover:text-foreground text-sm transition-colors"
|
||||
}
|
||||
>
|
||||
{lang.toUpperCase()}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostTags({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags?: unknown;
|
||||
className?: string;
|
||||
}) {
|
||||
if (!Array.isArray(tags)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const validTags = tags.filter(
|
||||
(tag): tag is string => typeof tag === "string" && tag.length > 0,
|
||||
);
|
||||
|
||||
if (validTags.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-3", className)}>
|
||||
<span className="text-secondary-foreground text-sm">Tags:</span>
|
||||
{validTags.map((tag) => (
|
||||
<Link
|
||||
key={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"
|
||||
>
|
||||
{tag}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PostList({ description, posts, title }: PostListProps) {
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-5xl flex-col gap-12 px-6">
|
||||
<header className="space-y-4">
|
||||
<h2 className="text-foreground text-4xl font-semibold tracking-tight">
|
||||
{title}
|
||||
</h2>
|
||||
{description ? (
|
||||
<p className="text-secondary-foreground">{description}</p>
|
||||
) : null}
|
||||
</header>
|
||||
|
||||
<div className="space-y-12">
|
||||
{posts.map((post) => {
|
||||
return (
|
||||
<article
|
||||
key={post.slug.join("/")}
|
||||
className="border-border space-y-5 border-b pb-12 last:border-b-0 last:pb-0"
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<PostMeta
|
||||
currentLang={post.lang}
|
||||
date={post.metadata.date}
|
||||
languages={post.languages}
|
||||
pathname={getBlogRoute(post.slug)}
|
||||
/>
|
||||
<Link
|
||||
href={getBlogRoute(post.slug)}
|
||||
className="text-foreground hover:text-primary block text-2xl font-semibold tracking-tight transition-colors"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{post.metadata.description ? (
|
||||
<p className="text-secondary-foreground leading-10">
|
||||
{post.metadata.description}
|
||||
</p>
|
||||
) : null}
|
||||
<PostTags tags={post.metadata.tags} />
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,29 +1,29 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Section({
|
||||
className,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("mx-auto flex flex-col py-16", className)}>
|
||||
<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">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-muted-foreground text-center text-xl">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="mt-4">{children}</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function Section({
|
||||
className,
|
||||
title,
|
||||
subtitle,
|
||||
children,
|
||||
}: {
|
||||
className?: string;
|
||||
title: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<section className={cn("mx-auto flex flex-col py-16", className)}>
|
||||
<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">
|
||||
{title}
|
||||
</div>
|
||||
{subtitle && (
|
||||
<div className="text-muted-foreground text-center text-xl">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
<main className="mt-4">{children}</main>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,99 +1,99 @@
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { pathOfThread } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function CaseStudySection({ className }: { className?: string }) {
|
||||
const caseStudies = [
|
||||
{
|
||||
threadId: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
|
||||
title: "Forecast 2026 Agent Trends and Opportunities",
|
||||
description:
|
||||
"Create a webpage with a Deep Research report forecasting the agent technology trends and opportunities in 2026.",
|
||||
},
|
||||
{
|
||||
threadId: "4f3e55ee-f853-43db-bfb3-7d1a411f03cb",
|
||||
title: 'Generate a Video Based On the Novel "Pride and Prejudice"',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
threadId: "21cfea46-34bd-4aa6-9e1f-3009452fbeb9",
|
||||
title: "Doraemon Explains the MOE Architecture",
|
||||
description:
|
||||
"Generate a Doraemon comic strip explaining the MOE architecture to the teenagers who are interested in AI.",
|
||||
},
|
||||
{
|
||||
threadId: "ad76c455-5bf9-4335-8517-fc03834ab828",
|
||||
title: "An Exploratory Data Analysis of the Titanic Dataset",
|
||||
description:
|
||||
"Explore the Titanic dataset and identify the key factors that influenced survival rates with visualizations and insights.",
|
||||
},
|
||||
{
|
||||
threadId: "d3e5adaf-084c-4dd5-9d29-94f1d6bccd98",
|
||||
title: "Watch Y Combinator's Video then Conduct a Deep Research",
|
||||
description:
|
||||
"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",
|
||||
title: "Collect and Summarize Dr. Fei Fei Li's Podcasts",
|
||||
description:
|
||||
"Collect all the podcast appearances of Dr. Fei Fei Li in the last 6 months, then summarize them into a comprehensive report.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Section
|
||||
className={className}
|
||||
title="Case Studies"
|
||||
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">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
<Link
|
||||
key={caseStudy.title}
|
||||
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Card className="group/card relative h-64 overflow-hidden">
|
||||
<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"
|
||||
style={{
|
||||
backgroundImage: `url(/images/${caseStudy.threadId}.jpg)`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center",
|
||||
"transition-all duration-300",
|
||||
"group-hover/card:translate-y-[calc(100%-128px)]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex w-full flex-col p-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="flex h-14 items-center text-xl font-bold text-shadow-black">
|
||||
{caseStudy.title}
|
||||
</h3>
|
||||
<p className="box-shadow-black overflow-hidden text-sm text-white/85 text-shadow-black">
|
||||
{caseStudy.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
import Link from "next/link";
|
||||
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { pathOfThread } from "@/core/threads/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function CaseStudySection({ className }: { className?: string }) {
|
||||
const caseStudies = [
|
||||
{
|
||||
threadId: "7cfa5f8f-a2f8-47ad-acbd-da7137baf990",
|
||||
title: "Forecast 2026 Agent Trends and Opportunities",
|
||||
description:
|
||||
"Create a webpage with a Deep Research report forecasting the agent technology trends and opportunities in 2026.",
|
||||
},
|
||||
{
|
||||
threadId: "4f3e55ee-f853-43db-bfb3-7d1a411f03cb",
|
||||
title: 'Generate a Video Based On the Novel "Pride and Prejudice"',
|
||||
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.',
|
||||
},
|
||||
{
|
||||
threadId: "21cfea46-34bd-4aa6-9e1f-3009452fbeb9",
|
||||
title: "Doraemon Explains the MOE Architecture",
|
||||
description:
|
||||
"Generate a Doraemon comic strip explaining the MOE architecture to the teenagers who are interested in AI.",
|
||||
},
|
||||
{
|
||||
threadId: "ad76c455-5bf9-4335-8517-fc03834ab828",
|
||||
title: "An Exploratory Data Analysis of the Titanic Dataset",
|
||||
description:
|
||||
"Explore the Titanic dataset and identify the key factors that influenced survival rates with visualizations and insights.",
|
||||
},
|
||||
{
|
||||
threadId: "d3e5adaf-084c-4dd5-9d29-94f1d6bccd98",
|
||||
title: "Watch Y Combinator's Video then Conduct a Deep Research",
|
||||
description:
|
||||
"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",
|
||||
title: "Collect and Summarize Dr. Fei Fei Li's Podcasts",
|
||||
description:
|
||||
"Collect all the podcast appearances of Dr. Fei Fei Li in the last 6 months, then summarize them into a comprehensive report.",
|
||||
},
|
||||
];
|
||||
return (
|
||||
<Section
|
||||
className={className}
|
||||
title="Case Studies"
|
||||
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">
|
||||
{caseStudies.map((caseStudy) => (
|
||||
<Link
|
||||
key={caseStudy.title}
|
||||
href={pathOfThread(caseStudy.threadId) + "?mock=true"}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Card className="group/card relative h-64 overflow-hidden">
|
||||
<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"
|
||||
style={{
|
||||
backgroundImage: `url(/images/${caseStudy.threadId}.jpg)`,
|
||||
}}
|
||||
></div>
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-full w-full translate-y-[calc(100%-60px)] flex-col items-center",
|
||||
"transition-all duration-300",
|
||||
"group-hover/card:translate-y-[calc(100%-128px)]",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="flex w-full flex-col p-4"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(to bottom, rgba(0, 0, 0, 0) 0%, rgba(0, 0, 0, 1) 100%)",
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<h3 className="flex h-14 items-center text-xl font-bold text-shadow-black">
|
||||
{caseStudy.title}
|
||||
</h3>
|
||||
<p className="box-shadow-black overflow-hidden text-sm text-white/85 text-shadow-black">
|
||||
{caseStudy.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import { GitHubLogoIcon } from "@radix-ui/react-icons";
|
||||
import Link from "next/link";
|
||||
|
||||
import { AuroraText } from "@/components/ui/aurora-text";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function CommunitySection() {
|
||||
return (
|
||||
<Section
|
||||
title={
|
||||
<AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}>
|
||||
Join the Community
|
||||
</AuroraText>
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { AuroraText } from "@/components/ui/aurora-text";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function CommunitySection() {
|
||||
return (
|
||||
<Section
|
||||
title={
|
||||
<AuroraText colors={["#60A5FA", "#A5FA60", "#A560FA"]}>
|
||||
Join the Community
|
||||
</AuroraText>
|
||||
}
|
||||
subtitle="Contribute brilliant ideas to shape the future of CM Assistant. Collaborate, innovate, and make impacts."
|
||||
>
|
||||
<div />
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,126 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AnimatedSpan,
|
||||
Terminal,
|
||||
TypingAnimation,
|
||||
} from "@/components/ui/terminal";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function SandboxSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={className}
|
||||
title="Agent Runtime Environment"
|
||||
subtitle={
|
||||
<p>
|
||||
We give DeerFlow a "computer", which can execute commands,
|
||||
manage files, and run long tasks — all in a secure Docker-based
|
||||
sandbox
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="mt-8 flex w-full max-w-6xl flex-col items-center gap-12 lg:flex-row lg:gap-16">
|
||||
{/* Left: Terminal */}
|
||||
<div className="w-full flex-1">
|
||||
<Terminal className="h-[360px] w-full">
|
||||
{/* Scene 1: Build a Game */}
|
||||
<TypingAnimation>$ cat requirements.txt</TypingAnimation>
|
||||
<AnimatedSpan delay={800} className="text-zinc-400">
|
||||
pygame==2.5.0
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={1200}>
|
||||
$ pip install -r requirements.txt
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={2000} className="text-green-500">
|
||||
✔ Installed pygame
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={2400}>
|
||||
$ write game.py --lines 156
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={3200} className="text-blue-500">
|
||||
✔ Written 156 lines
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={3600}>
|
||||
$ python game.py --test
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={4200} className="text-green-500">
|
||||
✔ All sprites loaded
|
||||
</AnimatedSpan>
|
||||
<AnimatedSpan delay={4500} className="text-green-500">
|
||||
✔ Physics engine OK
|
||||
</AnimatedSpan>
|
||||
<AnimatedSpan delay={4800} className="text-green-500">
|
||||
✔ 60 FPS stable
|
||||
</AnimatedSpan>
|
||||
|
||||
{/* Scene 2: Data Analysis */}
|
||||
<TypingAnimation delay={5400}>
|
||||
$ curl -O sales-2024.csv
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={6200} className="text-zinc-400">
|
||||
Downloaded 12.4 MB
|
||||
</AnimatedSpan>
|
||||
</Terminal>
|
||||
</div>
|
||||
|
||||
{/* Right: Description */}
|
||||
<div className="w-full flex-1 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium tracking-wider text-purple-400 uppercase">
|
||||
Open-source
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl">
|
||||
<a
|
||||
href="https://github.com/agent-infra/sandbox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
AIO Sandbox
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-lg text-zinc-400">
|
||||
<p>
|
||||
We recommend using{" "}
|
||||
<a
|
||||
href="https://github.com/agent-infra/sandbox"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
All-in-One Sandbox
|
||||
</a>{" "}
|
||||
that combines Browser, Shell, File, MCP and VSCode Server in a
|
||||
single Docker container.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Tags */}
|
||||
<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">
|
||||
Isolated
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Safe
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Persistent
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Mountable FS
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Long-running
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
AnimatedSpan,
|
||||
Terminal,
|
||||
TypingAnimation,
|
||||
} from "@/components/ui/terminal";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
export function SandboxSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={className}
|
||||
title="Agent Runtime Environment"
|
||||
subtitle={
|
||||
<p>
|
||||
We give CM Assistant a "computer", which can execute commands,
|
||||
manage files, and run long tasks — all in a secure Docker-based
|
||||
sandbox
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<div className="mt-8 flex w-full max-w-6xl flex-col items-center gap-12 lg:flex-row lg:gap-16">
|
||||
{/* Left: Terminal */}
|
||||
<div className="w-full flex-1">
|
||||
<Terminal className="h-[360px] w-full">
|
||||
{/* Scene 1: Build a Game */}
|
||||
<TypingAnimation>$ cat requirements.txt</TypingAnimation>
|
||||
<AnimatedSpan delay={800} className="text-zinc-400">
|
||||
pygame==2.5.0
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={1200}>
|
||||
$ pip install -r requirements.txt
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={2000} className="text-green-500">
|
||||
✔ Installed pygame
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={2400}>
|
||||
$ write game.py --lines 156
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={3200} className="text-blue-500">
|
||||
✔ Written 156 lines
|
||||
</AnimatedSpan>
|
||||
|
||||
<TypingAnimation delay={3600}>
|
||||
$ python game.py --test
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={4200} className="text-green-500">
|
||||
✔ All sprites loaded
|
||||
</AnimatedSpan>
|
||||
<AnimatedSpan delay={4500} className="text-green-500">
|
||||
✔ Physics engine OK
|
||||
</AnimatedSpan>
|
||||
<AnimatedSpan delay={4800} className="text-green-500">
|
||||
✔ 60 FPS stable
|
||||
</AnimatedSpan>
|
||||
|
||||
{/* Scene 2: Data Analysis */}
|
||||
<TypingAnimation delay={5400}>
|
||||
$ curl -O sales-2024.csv
|
||||
</TypingAnimation>
|
||||
<AnimatedSpan delay={6200} className="text-zinc-400">
|
||||
Downloaded 12.4 MB
|
||||
</AnimatedSpan>
|
||||
</Terminal>
|
||||
</div>
|
||||
|
||||
{/* Right: Description */}
|
||||
<div className="w-full flex-1 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm font-medium tracking-wider text-purple-400 uppercase">
|
||||
Open-source
|
||||
</p>
|
||||
<h2 className="text-4xl font-bold tracking-tight lg:text-5xl">
|
||||
<a
|
||||
href="https://github.com/agent-infra/sandbox"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
AIO Sandbox
|
||||
</a>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 text-lg text-zinc-400">
|
||||
<p>
|
||||
We recommend using{" "}
|
||||
<a
|
||||
href="https://github.com/agent-infra/sandbox"
|
||||
className="underline"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
All-in-One Sandbox
|
||||
</a>{" "}
|
||||
that combines Browser, Shell, File, MCP and VSCode Server in a
|
||||
single Docker container.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Feature Tags */}
|
||||
<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">
|
||||
Isolated
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Safe
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Persistent
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Mountable FS
|
||||
</span>
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900 px-4 py-2 text-sm text-zinc-300">
|
||||
Long-running
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import ProgressiveSkillsAnimation from "../progressive-skills-animation";
|
||||
import { Section } from "../section";
|
||||
|
||||
export function SkillsSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("h-[calc(100vh-64px)] w-full bg-white/2", className)}
|
||||
title="Agent Skills"
|
||||
subtitle={
|
||||
<div>
|
||||
Agent Skills are loaded progressively — only what's needed, when
|
||||
it's needed.
|
||||
<br />
|
||||
Extend DeerFlow with your own skill files, or use our built-in
|
||||
library.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<ProgressiveSkillsAnimation />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import ProgressiveSkillsAnimation from "../progressive-skills-animation";
|
||||
import { Section } from "../section";
|
||||
|
||||
export function SkillsSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("h-[calc(100vh-64px)] w-full bg-white/2", className)}
|
||||
title="Agent Skills"
|
||||
subtitle={
|
||||
<div>
|
||||
Agent Skills are loaded progressively — only what's needed, when
|
||||
it's needed.
|
||||
<br />
|
||||
Extend CM Assistant with your own skill files, or use our built-in
|
||||
library.
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="relative overflow-hidden">
|
||||
<ProgressiveSkillsAnimation />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -1,63 +1,63 @@
|
||||
"use client";
|
||||
|
||||
import MagicBento, { type BentoCardProps } from "@/components/ui/magic-bento";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
const COLOR = "#0a0a0a";
|
||||
const features: BentoCardProps[] = [
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Context Engineering",
|
||||
title: "Long/Short-term Memory",
|
||||
description: "Now the agent can better understand you",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Long Task Running",
|
||||
title: "Planning and Sub-tasking",
|
||||
description:
|
||||
"Plans ahead, reasons through complexity, then executes sequentially or in parallel",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Extensible",
|
||||
title: "Skills and Tools",
|
||||
description:
|
||||
"Plug, play, or even swap built-in tools. Build the agent you want.",
|
||||
},
|
||||
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Persistent",
|
||||
title: "Sandbox with File System",
|
||||
description: "Read, write, run — like a real computer",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Flexible",
|
||||
title: "Multi-Model Support",
|
||||
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Free",
|
||||
title: "Open Source",
|
||||
description: "MIT License, self-hosted, full control",
|
||||
},
|
||||
];
|
||||
|
||||
export function WhatsNewSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("", className)}
|
||||
title="CM业务中心-问答管理"
|
||||
subtitle="CM业务中心-问答管理"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<MagicBento data={features} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import MagicBento, { type BentoCardProps } from "@/components/ui/magic-bento";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { Section } from "../section";
|
||||
|
||||
const COLOR = "#0a0a0a";
|
||||
const features: BentoCardProps[] = [
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Context Engineering",
|
||||
title: "Long/Short-term Memory",
|
||||
description: "Now the agent can better understand you",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Long Task Running",
|
||||
title: "Planning and Sub-tasking",
|
||||
description:
|
||||
"Plans ahead, reasons through complexity, then executes sequentially or in parallel",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Extensible",
|
||||
title: "Skills and Tools",
|
||||
description:
|
||||
"Plug, play, or even swap built-in tools. Build the agent you want.",
|
||||
},
|
||||
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Persistent",
|
||||
title: "Sandbox with File System",
|
||||
description: "Read, write, run — like a real computer",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Flexible",
|
||||
title: "Multi-Model Support",
|
||||
description: "Doubao, DeepSeek, OpenAI, Gemini, etc.",
|
||||
},
|
||||
{
|
||||
color: COLOR,
|
||||
label: "Free",
|
||||
title: "Open Source",
|
||||
description: "MIT License, self-hosted, full control",
|
||||
},
|
||||
];
|
||||
|
||||
export function WhatsNewSection({ className }: { className?: string }) {
|
||||
return (
|
||||
<Section
|
||||
className={cn("", className)}
|
||||
title="CM 助手"
|
||||
subtitle="CM 助手"
|
||||
>
|
||||
<div className="flex w-full items-center justify-center">
|
||||
<MagicBento data={features} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,397 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
Brain,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CircleHelp,
|
||||
Compass,
|
||||
Database,
|
||||
FileBox,
|
||||
FileOutput,
|
||||
FileText,
|
||||
Folder,
|
||||
FolderOpen,
|
||||
Grid3X3,
|
||||
HelpCircle,
|
||||
History,
|
||||
Layers,
|
||||
Layout,
|
||||
Library,
|
||||
ListTodo,
|
||||
MessageSquare,
|
||||
MoreHorizontal,
|
||||
Network,
|
||||
PlayCircle,
|
||||
Plus,
|
||||
Scale,
|
||||
Settings,
|
||||
Target,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Workflow,
|
||||
} from "lucide-react";
|
||||
|
||||
type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
type PageSidebarProps = {
|
||||
collapsed: boolean;
|
||||
onToggleCollapsed: () => void;
|
||||
};
|
||||
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "新建问答", icon: Plus },
|
||||
{ id: "general-qa", label: "通用问答", icon: CircleHelp, path: "/page/strategy/qa/general" },
|
||||
{ id: "agent-qa", label: "智能体问答", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "多智能体", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "六步对话", icon: Workflow },
|
||||
{ id: "qa-records", label: "问答记录", icon: History },
|
||||
{ id: "template-editor", label: "模板配置", icon: Settings },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "light-app-center",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "规划任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "MNTY任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "机制研究", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "规划任务", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY任务", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "effect-task",
|
||||
label: "效果PG任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "任务PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "行动PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "信息PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "账号PG", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{ id: "task-create", label: "创建任务", icon: Plus },
|
||||
{ id: "task-single", label: "单智能体任务", icon: Bot },
|
||||
{ id: "task-scene", label: "场景任务", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "多智能体协商", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "最终输出", icon: FileOutput },
|
||||
],
|
||||
},
|
||||
{ id: "scene-list", label: "场景管理", icon: Layers },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: "智能体管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
|
||||
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-management",
|
||||
label: "结果管理",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{
|
||||
id: "result-planning",
|
||||
label: "规划结果",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "result-planning-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "目标分析", icon: Target },
|
||||
{ id: "result-planning-brand", label: "品牌策略", icon: Target },
|
||||
{ id: "result-planning-content", label: "内容规划", icon: Target },
|
||||
{ id: "result-planning-channel", label: "渠道规划", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "叙事策略", icon: Target },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-mnty",
|
||||
label: "MNTY结果",
|
||||
icon: Brain,
|
||||
children: [
|
||||
{ id: "result-mnty-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "机制研究", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "规划设计", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY分析", icon: Brain },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-effect-pg",
|
||||
label: "效果PG结果",
|
||||
icon: TrendingUp,
|
||||
children: [
|
||||
{ id: "result-effect-pg-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "行为PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "效果复盘", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "信息PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "账号PG", icon: TrendingUp },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-xk",
|
||||
label: "XK结果",
|
||||
icon: Compass,
|
||||
children: [
|
||||
{ id: "result-xk-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "任务JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "任务调度", icon: Compass },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-dq",
|
||||
label: "DQ结果",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "result-dq-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "受众分析", icon: Database },
|
||||
{ id: "result-dq-kb", label: "知识研究", icon: Database },
|
||||
{ id: "result-dq-trend", label: "趋势研究", icon: Database },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-other",
|
||||
label: "其他结果",
|
||||
icon: MoreHorizontal,
|
||||
children: [
|
||||
{ id: "result-other-all", label: "全部", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "通用分析", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "临时任务", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "其他", icon: MoreHorizontal },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "resource-management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-zz", label: "ZZ资源", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "网络达人", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "知识库", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "评估指标", icon: Scale },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const defaultExpandedIds = new Set([
|
||||
"qa-center",
|
||||
"task-management",
|
||||
"xk-task",
|
||||
"result-management",
|
||||
"result-planning",
|
||||
]);
|
||||
|
||||
export function PageSidebar({
|
||||
collapsed,
|
||||
onToggleCollapsed,
|
||||
}: PageSidebarProps) {
|
||||
const [expandedIds, setExpandedIds] = useState<Set<string>>(defaultExpandedIds);
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
const iconButtonClass =
|
||||
"rounded-lg p-2 text-gray-500 transition-all duration-300 ease-out hover:scale-105 hover:bg-red-50 hover:text-red-600";
|
||||
const panelClass = "ml-4 space-y-1 border-l border-red-100 pl-4";
|
||||
const labelClass = (compact: boolean, small = false) =>
|
||||
`overflow-hidden whitespace-nowrap transition-all duration-300 ease-out ${
|
||||
compact ? "max-w-0 opacity-0" : "max-w-[220px] opacity-100"
|
||||
} ${small ? "text-xs" : "text-sm"} flex-1 font-medium`;
|
||||
|
||||
const toggleExpanded = (id: string) => {
|
||||
setExpandedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const renderLeaf = (item: MenuItem, depth = 0) => {
|
||||
const Icon = item.icon;
|
||||
const isActive = !!item.path && pathname === item.path;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
title={collapsed && depth === 0 ? item.label : undefined}
|
||||
onClick={() => item.path && navigate(item.path)}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left transition-all duration-300 ease-out hover:-translate-y-0.5 ${
|
||||
collapsed && depth === 0 ? "justify-center px-2" : "gap-2 px-3"
|
||||
} ${
|
||||
isActive
|
||||
? "bg-red-50 text-red-600 font-semibold"
|
||||
: "text-gray-600 hover:bg-red-50 hover:text-red-600"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`${depth === 0 ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out`} />
|
||||
{(!collapsed || depth > 0) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBranch = (item: MenuItem, depth = 0) => {
|
||||
const expanded = expandedIds.has(item.id);
|
||||
const isTopLevel = depth === 0;
|
||||
const Icon = depth > 0 && item.icon === Folder && expanded ? FolderOpen : item.icon;
|
||||
|
||||
return (
|
||||
<div key={item.id} className="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (collapsed && isTopLevel) {
|
||||
onToggleCollapsed();
|
||||
return;
|
||||
}
|
||||
toggleExpanded(item.id);
|
||||
}}
|
||||
title={collapsed && isTopLevel ? item.label : undefined}
|
||||
className={`flex w-full items-center rounded-lg py-2 text-left text-gray-600 transition-all duration-300 ease-out hover:-translate-y-0.5 hover:bg-red-50 hover:text-red-600 ${
|
||||
collapsed && isTopLevel ? "justify-center px-2" : "gap-2 px-3"
|
||||
}`}
|
||||
>
|
||||
<Icon
|
||||
className={`${isTopLevel ? "h-5 w-5" : "h-4 w-4"} flex-shrink-0 transition-transform duration-300 ease-out ${
|
||||
expanded && depth > 0 ? "scale-105" : ""
|
||||
}`}
|
||||
/>
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<span className={labelClass(false, depth > 0)}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
{(!collapsed || !isTopLevel) && (
|
||||
<ChevronRight
|
||||
className={`h-4 w-4 transition-transform duration-300 ease-out ${expanded ? "rotate-90" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{(!collapsed || !isTopLevel) && item.children && (
|
||||
<div
|
||||
className={`${panelClass} overflow-hidden transition-all duration-300 ease-out ${
|
||||
expanded ? "max-h-[1200px] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
>
|
||||
{item.children.map((child) =>
|
||||
child.children ? renderBranch(child, depth + 1) : renderLeaf(child, depth + 1),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={`fixed left-0 top-0 z-40 h-screen overflow-y-auto border-r border-red-100 bg-white shadow-sm transition-all duration-300 ${
|
||||
collapsed ? "w-16" : "w-64"
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex h-16 items-center border-b border-red-100 ${
|
||||
collapsed ? "justify-center px-2" : "justify-between px-6"
|
||||
}`}
|
||||
>
|
||||
<div className="flex min-w-0 items-center gap-3">
|
||||
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-lg bg-gradient-to-br from-red-500 to-red-400 transition-transform duration-300 ease-out">
|
||||
<FileText className="h-4 w-4 text-white" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="overflow-hidden whitespace-nowrap animate-in fade-in-0 slide-in-from-left-2 duration-300">
|
||||
<h1 className="text-sm font-semibold text-gray-900">DeerFlow</h1>
|
||||
<p className="text-xs text-red-400/80">Page Navigation</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={iconButtonClass}
|
||||
title="Collapse sidebar"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<nav className={`${collapsed ? "p-2" : "p-4"} space-y-2 transition-all duration-300 ease-out`}>
|
||||
{collapsed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggleCollapsed}
|
||||
className={`flex w-full items-center justify-center ${iconButtonClass}`}
|
||||
title="Expand sidebar"
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{menuItems.map((item) =>
|
||||
item.children ? renderBranch(item) : renderLeaf(item),
|
||||
)}
|
||||
</nav>
|
||||
|
||||
<div className="pointer-events-none absolute right-0 top-0 h-32 w-32 rounded-full bg-red-500/5 blur-3xl" />
|
||||
<div className="pointer-events-none absolute bottom-20 right-0 h-24 w-24 rounded-full bg-red-400/5 blur-3xl" />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,20 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider as TanStackQueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function QueryClientProvider({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<TanStackQueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</TanStackQueryClientProvider>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import {
|
||||
QueryClient,
|
||||
QueryClientProvider as TanStackQueryClientProvider,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
export function QueryClientProvider({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<TanStackQueryClientProvider client={queryClient}>
|
||||
{children}
|
||||
</TanStackQueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,19 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<NextThemesProvider
|
||||
{...props}
|
||||
forcedTheme={pathname === "/landing" ? "dark" : undefined}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
"use client";
|
||||
|
||||
import { usePathname } from "next/navigation";
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof NextThemesProvider>) {
|
||||
const pathname = usePathname();
|
||||
return (
|
||||
<NextThemesProvider
|
||||
{...props}
|
||||
forcedTheme={pathname === "/landing" ? "dark" : undefined}
|
||||
>
|
||||
{children}
|
||||
</NextThemesProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,66 +1,66 @@
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
import * as React from "react";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-card text-card-foreground",
|
||||
destructive:
|
||||
"text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert"
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-title"
|
||||
className={cn(
|
||||
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-description"
|
||||
className={cn(
|
||||
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription };
|
||||
@@ -1,43 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface AuroraTextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
colors?: string[];
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export const AuroraText = memo(
|
||||
({
|
||||
children,
|
||||
className = "",
|
||||
colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
|
||||
speed = 1,
|
||||
}: AuroraTextProps) => {
|
||||
const gradientStyle = {
|
||||
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
|
||||
colors[0]
|
||||
})`,
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
animationDuration: `${10 / speed}s`,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`relative inline-block ${className}`}>
|
||||
<span className="sr-only">{children}</span>
|
||||
<span
|
||||
className="animate-aurora relative bg-size-[200%_auto] bg-clip-text text-transparent"
|
||||
style={gradientStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AuroraText.displayName = "AuroraText";
|
||||
"use client";
|
||||
|
||||
import React, { memo } from "react";
|
||||
|
||||
interface AuroraTextProps {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
colors?: string[];
|
||||
speed?: number;
|
||||
}
|
||||
|
||||
export const AuroraText = memo(
|
||||
({
|
||||
children,
|
||||
className = "",
|
||||
colors = ["#FF0080", "#7928CA", "#0070F3", "#38bdf8"],
|
||||
speed = 1,
|
||||
}: AuroraTextProps) => {
|
||||
const gradientStyle = {
|
||||
backgroundImage: `linear-gradient(135deg, ${colors.join(", ")}, ${
|
||||
colors[0]
|
||||
})`,
|
||||
WebkitBackgroundClip: "text",
|
||||
WebkitTextFillColor: "transparent",
|
||||
animationDuration: `${10 / speed}s`,
|
||||
};
|
||||
|
||||
return (
|
||||
<span className={`relative inline-block ${className}`}>
|
||||
<span className="sr-only">{children}</span>
|
||||
<span
|
||||
className="animate-aurora relative bg-size-[200%_auto] bg-clip-text text-transparent"
|
||||
style={gradientStyle}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
AuroraText.displayName = "AuroraText";
|
||||
@@ -1,53 +1,53 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as AvatarPrimitive from "@radix-ui/react-avatar";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Avatar({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Root>) {
|
||||
return (
|
||||
<AvatarPrimitive.Root
|
||||
data-slot="avatar"
|
||||
className={cn(
|
||||
"relative flex size-8 shrink-0 overflow-hidden rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarImage({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Image>) {
|
||||
return (
|
||||
<AvatarPrimitive.Image
|
||||
data-slot="avatar-image"
|
||||
className={cn("aspect-square size-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AvatarFallback({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AvatarPrimitive.Fallback>) {
|
||||
return (
|
||||
<AvatarPrimitive.Fallback
|
||||
data-slot="avatar-fallback"
|
||||
className={cn(
|
||||
"bg-muted flex size-full items-center justify-center rounded-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Avatar, AvatarImage, AvatarFallback };
|
||||
@@ -1,46 +1,46 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center justify-center rounded-full border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive transition-[color,box-shadow] overflow-hidden",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Badge({
|
||||
className,
|
||||
variant,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"span"> &
|
||||
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "span";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="badge"
|
||||
className={cn(badgeVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants };
|
||||
@@ -1,109 +1,111 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Breadcrumb({ ...props }: React.ComponentProps<"nav">) {
|
||||
return <nav aria-label="breadcrumb" data-slot="breadcrumb" {...props} />;
|
||||
}
|
||||
|
||||
function BreadcrumbList({ className, ...props }: React.ComponentProps<"ol">) {
|
||||
return (
|
||||
<ol
|
||||
data-slot="breadcrumb-list"
|
||||
className={cn(
|
||||
"text-muted-foreground flex flex-wrap items-center gap-1.5 text-sm break-words sm:gap-2.5",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-item"
|
||||
className={cn("inline-flex items-center gap-1.5", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const BreadcrumbLink = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ asChild, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbSeparator({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"li">) {
|
||||
return (
|
||||
<li
|
||||
data-slot="breadcrumb-separator"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("[&>svg]:size-3.5", className)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRight />}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbEllipsis({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="breadcrumb-ellipsis"
|
||||
role="presentation"
|
||||
aria-hidden="true"
|
||||
className={cn("flex size-9 items-center justify-center", className)}
|
||||
{...props}
|
||||
>
|
||||
<MoreHorizontal className="size-4" />
|
||||
<span className="sr-only">More</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Breadcrumb,
|
||||
BreadcrumbList,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
BreadcrumbEllipsis,
|
||||
};
|
||||
@@ -1,83 +1,83 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
const buttonGroupVariants = cva(
|
||||
"flex w-fit items-stretch [&>*]:focus-visible:z-10 [&>*]:focus-visible:relative [&>[data-slot=select-trigger]:not([class*='w-'])]:w-fit [&>input]:flex-1 has-[select[aria-hidden=true]:last-child]:[&>[data-slot=select-trigger]:last-of-type]:rounded-r-md has-[>[data-slot=button-group]]:gap-2",
|
||||
{
|
||||
variants: {
|
||||
orientation: {
|
||||
horizontal:
|
||||
"[&>*:not(:first-child)]:rounded-l-none [&>*:not(:first-child)]:border-l-0 [&>*:not(:last-child)]:rounded-r-none",
|
||||
vertical:
|
||||
"flex-col [&>*:not(:first-child)]:rounded-t-none [&>*:not(:first-child)]:border-t-0 [&>*:not(:last-child)]:rounded-b-none",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
orientation: "horizontal",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function ButtonGroup({
|
||||
className,
|
||||
orientation,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & VariantProps<typeof buttonGroupVariants>) {
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
data-slot="button-group"
|
||||
data-orientation={orientation}
|
||||
className={cn(buttonGroupVariants({ orientation }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupText({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"bg-muted flex items-center gap-2 rounded-md border px-4 text-sm font-medium shadow-xs [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ButtonGroupSeparator({
|
||||
className,
|
||||
orientation = "vertical",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Separator>) {
|
||||
return (
|
||||
<Separator
|
||||
data-slot="button-group-separator"
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"bg-input relative !m-0 self-stretch data-[orientation=vertical]:h-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ButtonGroup,
|
||||
ButtonGroupSeparator,
|
||||
ButtonGroupText,
|
||||
buttonGroupVariants,
|
||||
};
|
||||
@@ -1,56 +1,54 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"cursor-pointer hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "cursor-pointer text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"cursor-pointer bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
destructive:
|
||||
"cursor-pointer bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||
outline:
|
||||
"cursor-pointer border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
|
||||
secondary:
|
||||
"cursor-pointer bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost:
|
||||
"cursor-pointer hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
|
||||
link: "cursor-pointer text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 px-4 py-2 has-[>svg]:px-3",
|
||||
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
|
||||
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
|
||||
icon: "size-9",
|
||||
"icon-sm": "size-8",
|
||||
"icon-lg": "size-10",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
const Button = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}
|
||||
>(({ className, variant = "default", size = "default", asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
@@ -58,6 +56,8 @@ function Button({
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,92 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Card({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card"
|
||||
className={cn(
|
||||
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-header"
|
||||
className={cn(
|
||||
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-title"
|
||||
className={cn("leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-action"
|
||||
className={cn(
|
||||
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-content"
|
||||
className={cn("px-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="card-footer"
|
||||
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardFooter,
|
||||
CardTitle,
|
||||
CardAction,
|
||||
CardDescription,
|
||||
CardContent,
|
||||
};
|
||||
@@ -1,241 +1,241 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import useEmblaCarousel, {
|
||||
type UseEmblaCarouselType,
|
||||
} from "embla-carousel-react";
|
||||
import { ArrowLeft, ArrowRight } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
type CarouselApi = UseEmblaCarouselType[1];
|
||||
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>;
|
||||
type CarouselOptions = UseCarouselParameters[0];
|
||||
type CarouselPlugin = UseCarouselParameters[1];
|
||||
|
||||
type CarouselProps = {
|
||||
opts?: CarouselOptions;
|
||||
plugins?: CarouselPlugin;
|
||||
orientation?: "horizontal" | "vertical";
|
||||
setApi?: (api: CarouselApi) => void;
|
||||
};
|
||||
|
||||
type CarouselContextProps = {
|
||||
carouselRef: ReturnType<typeof useEmblaCarousel>[0];
|
||||
api: ReturnType<typeof useEmblaCarousel>[1];
|
||||
scrollPrev: () => void;
|
||||
scrollNext: () => void;
|
||||
canScrollPrev: boolean;
|
||||
canScrollNext: boolean;
|
||||
} & CarouselProps;
|
||||
|
||||
const CarouselContext = React.createContext<CarouselContextProps | null>(null);
|
||||
|
||||
function useCarousel() {
|
||||
const context = React.useContext(CarouselContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useCarousel must be used within a <Carousel />");
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
function Carousel({
|
||||
orientation = "horizontal",
|
||||
opts,
|
||||
setApi,
|
||||
plugins,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & CarouselProps) {
|
||||
const [carouselRef, api] = useEmblaCarousel(
|
||||
{
|
||||
...opts,
|
||||
axis: orientation === "horizontal" ? "x" : "y",
|
||||
},
|
||||
plugins,
|
||||
);
|
||||
const [canScrollPrev, setCanScrollPrev] = React.useState(false);
|
||||
const [canScrollNext, setCanScrollNext] = React.useState(false);
|
||||
|
||||
const onSelect = React.useCallback((api: CarouselApi) => {
|
||||
if (!api) return;
|
||||
setCanScrollPrev(api.canScrollPrev());
|
||||
setCanScrollNext(api.canScrollNext());
|
||||
}, []);
|
||||
|
||||
const scrollPrev = React.useCallback(() => {
|
||||
api?.scrollPrev();
|
||||
}, [api]);
|
||||
|
||||
const scrollNext = React.useCallback(() => {
|
||||
api?.scrollNext();
|
||||
}, [api]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(event: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
scrollPrev();
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
scrollNext();
|
||||
}
|
||||
},
|
||||
[scrollPrev, scrollNext],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api || !setApi) return;
|
||||
setApi(api);
|
||||
}, [api, setApi]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return;
|
||||
onSelect(api);
|
||||
api.on("reInit", onSelect);
|
||||
api.on("select", onSelect);
|
||||
|
||||
return () => {
|
||||
api?.off("select", onSelect);
|
||||
};
|
||||
}, [api, onSelect]);
|
||||
|
||||
return (
|
||||
<CarouselContext.Provider
|
||||
value={{
|
||||
carouselRef,
|
||||
api: api,
|
||||
opts,
|
||||
orientation:
|
||||
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
|
||||
scrollPrev,
|
||||
scrollNext,
|
||||
canScrollPrev,
|
||||
canScrollNext,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
onKeyDownCapture={handleKeyDown}
|
||||
className={cn("relative", className)}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
data-slot="carousel"
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</CarouselContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { carouselRef, orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={carouselRef}
|
||||
className="overflow-hidden"
|
||||
data-slot="carousel-content"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex",
|
||||
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
|
||||
const { orientation } = useCarousel();
|
||||
|
||||
return (
|
||||
<div
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
data-slot="carousel-item"
|
||||
className={cn(
|
||||
"min-w-0 shrink-0 grow-0 basis-full",
|
||||
orientation === "horizontal" ? "pl-4" : "pt-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselPrevious({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollPrev, canScrollPrev } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-previous"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -left-12 -translate-y-1/2"
|
||||
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollPrev}
|
||||
onClick={scrollPrev}
|
||||
{...props}
|
||||
>
|
||||
<ArrowLeft />
|
||||
<span className="sr-only">Previous slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function CarouselNext({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "icon",
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { orientation, scrollNext, canScrollNext } = useCarousel();
|
||||
|
||||
return (
|
||||
<Button
|
||||
data-slot="carousel-next"
|
||||
variant={variant}
|
||||
size={size}
|
||||
className={cn(
|
||||
"absolute size-8 rounded-full",
|
||||
orientation === "horizontal"
|
||||
? "top-1/2 -right-12 -translate-y-1/2"
|
||||
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
|
||||
className,
|
||||
)}
|
||||
disabled={!canScrollNext}
|
||||
onClick={scrollNext}
|
||||
{...props}
|
||||
>
|
||||
<ArrowRight />
|
||||
<span className="sr-only">Next slide</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
type CarouselApi,
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselPrevious,
|
||||
CarouselNext,
|
||||
};
|
||||
@@ -1,37 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Collapsible({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.Root>) {
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
const CollapsibleTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleTrigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
ref={ref}
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
CollapsibleTrigger.displayName =
|
||||
CollapsiblePrimitive.CollapsibleTrigger.displayName;
|
||||
|
||||
const CollapsibleContent = React.forwardRef<
|
||||
React.ElementRef<typeof CollapsiblePrimitive.CollapsibleContent>,
|
||||
React.ComponentPropsWithoutRef<typeof CollapsiblePrimitive.CollapsibleContent>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
ref={ref}
|
||||
data-slot="collapsible-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
CollapsibleContent.displayName =
|
||||
CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -1,181 +1,181 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { Command as CommandPrimitive } from "cmdk";
|
||||
import { SearchIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
function Command({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive>) {
|
||||
return (
|
||||
<CommandPrimitive
|
||||
data-slot="command"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandDialog({
|
||||
title = "Command Palette",
|
||||
description = "Search for a command to run...",
|
||||
children,
|
||||
className,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Dialog> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dialog {...props}>
|
||||
<DialogContent
|
||||
className={cn("overflow-hidden p-0", className)}
|
||||
showCloseButton={showCloseButton}
|
||||
>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{description}</DialogDescription>
|
||||
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandInput({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
|
||||
return (
|
||||
<div
|
||||
data-slot="command-input-wrapper"
|
||||
className="flex h-9 items-center gap-2 border-b px-3"
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" />
|
||||
<CommandPrimitive.Input
|
||||
data-slot="command-input"
|
||||
className={cn(
|
||||
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandList({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.List>) {
|
||||
return (
|
||||
<CommandPrimitive.List
|
||||
data-slot="command-list"
|
||||
className={cn(
|
||||
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandEmpty({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
|
||||
return (
|
||||
<CommandPrimitive.Empty
|
||||
data-slot="command-empty"
|
||||
className="py-6 text-center text-sm"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandGroup({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
|
||||
return (
|
||||
<CommandPrimitive.Group
|
||||
data-slot="command-group"
|
||||
className={cn(
|
||||
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
|
||||
return (
|
||||
<CommandPrimitive.Separator
|
||||
data-slot="command-separator"
|
||||
className={cn("bg-border -mx-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandItem({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
|
||||
return (
|
||||
<CommandPrimitive.Item
|
||||
data-slot="command-item"
|
||||
className={cn(
|
||||
"data-[selected=true]:bg-accent data-[selected=true]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CommandShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="command-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandInput,
|
||||
CommandList,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandShortcut,
|
||||
CommandSeparator,
|
||||
};
|
||||
Loaded 100 of 1446 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user