Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b34219ce86 | ||
|
|
05548b0fa6 | ||
|
|
e08e7be24c | ||
|
|
1be600155b | ||
|
|
be757b1f2f | ||
|
|
d592e1e535 | ||
|
|
3b951efd13 | ||
|
|
33161c5bad | ||
|
|
8b6236c796 | ||
|
|
a59d80d7f3 | ||
|
|
a5c76591ac | ||
|
|
c2a975af9e | ||
|
|
ed4909a28d | ||
|
|
afc517b3a7 | ||
|
|
de9f6b6711 | ||
|
|
646cb8caeb | ||
|
|
d533ddde88 | ||
|
|
6981fc06ca | ||
|
|
12798c00bc | ||
|
|
ebb6be816b | ||
|
|
ae4e4f67c2 | ||
|
|
050b1ad78e | ||
|
|
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 |
No files matched your search
+15
@@ -60,6 +60,21 @@ 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/*
|
||||
@@ -0,0 +1,95 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
This is a customized offline deployment of **DeerFlow** — a LangGraph-based AI super agent platform. The repo contains two sub-projects:
|
||||
|
||||
- `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/`.
|
||||
|
||||
## Commands
|
||||
|
||||
### Full Application (from repo root)
|
||||
|
||||
```bash
|
||||
./scripts/start-all.sh # Start backend + frontend as background daemons
|
||||
./scripts/stop-all.sh # Stop both
|
||||
./scripts/restart-all.sh # Restart both
|
||||
./scripts/status.sh # Check running status
|
||||
```
|
||||
|
||||
Logs and PID files are written to `.runtime/logs/` and `.runtime/pids/`.
|
||||
|
||||
### Backend Only (from `offline-backend-20260512/backend/`)
|
||||
|
||||
```bash
|
||||
make install # Install Python dependencies (uv)
|
||||
make dev # Run Gateway API with hot-reload on port 8001
|
||||
make gateway # Run Gateway API without reload
|
||||
make test # Run all tests: PYTHONPATH=. uv run pytest tests/ -v
|
||||
make lint # Lint with ruff
|
||||
make format # Format with ruff
|
||||
```
|
||||
|
||||
Run a single test file:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/test_<feature>.py -v
|
||||
```
|
||||
|
||||
### Frontend Only (from `frontend-web/`)
|
||||
|
||||
```bash
|
||||
pnpm install # Install dependencies
|
||||
pnpm dev # Start Vite dev server on port 5174
|
||||
pnpm build # Production build
|
||||
pnpm typecheck # TypeScript type checking (tsc --noEmit)
|
||||
```
|
||||
|
||||
## Service Ports
|
||||
|
||||
| Service | Port | Notes |
|
||||
|---------|------|-------|
|
||||
| Backend (Gateway API) | 8001 | FastAPI + LangGraph runtime |
|
||||
| Frontend (Vite) | 5174 | React SPA |
|
||||
|
||||
Frontend connects to backend via env vars `VITE_BACKEND_BASE_URL` and `VITE_LANGGRAPH_BASE_URL` (injected by `scripts/start-frontend.sh`).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Backend
|
||||
|
||||
The backend (`offline-backend-20260512/backend/`) is split into two layers:
|
||||
|
||||
- **`packages/harness/deerflow/`** — publishable agent framework (`deerflow.*` imports): LangGraph lead agent, middleware chain, sandbox, tools, MCP, memory, models, skills. **Never imports from `app.*`.**
|
||||
- **`app/`** — application layer (`app.*` imports): FastAPI Gateway routers, IM channel integrations (Feishu, Slack, Telegram, DingTalk).
|
||||
|
||||
The **Lead Agent** (`deerflow/agents/lead_agent/`) is the single LangGraph graph entry point. It wraps a middleware chain (~18 middlewares assembled in strict order) around LLM calls, handling: thread isolation, sandbox lifecycle, uploads, memory, plan mode, vision, subagent delegation, and loop detection.
|
||||
|
||||
**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.
|
||||
|
||||
**Harness → App import firewall** is enforced by `tests/test_harness_boundary.py` (runs in CI).
|
||||
|
||||
### Frontend
|
||||
|
||||
The frontend (`frontend-web/`) is a React 19 + Vite SPA:
|
||||
|
||||
- **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)
|
||||
|
||||
### 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.
|
||||
|
||||
## Detailed Backend Reference
|
||||
|
||||
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,6 @@
|
||||
# 复制为 .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
|
||||
VITE_LANGGRAPH_BASE_URL=http://127.0.0.1:8001/api
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,260 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
|
||||
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
|
||||
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
|
||||
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 前端阶段四 | API 适配层 | ✅ 已完成 |
|
||||
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||
|
||||
**当前状态**:全部 9 个阶段已完成 🎉
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端路由接入
|
||||
|
||||
修改了 `frontend-web/src/pages/PageRoutes.tsx`:
|
||||
|
||||
```tsx
|
||||
import OpenCanvasRoutes from "@/open-canvas/routes/OpenCanvasRoutes";
|
||||
// 在 PageLayout 内部添加:
|
||||
<Route path="canvas/*" element={<OpenCanvasRoutes />} />
|
||||
```
|
||||
|
||||
子路由:
|
||||
- `/page/canvas` → `CanvasHomePage`(欢迎页 + 快速开始)
|
||||
- `/page/canvas/:thread_id` → `CanvasThreadPage`(主工作区)
|
||||
|
||||
### 2.2 前端目录结构(已建立,26 个文件)
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
api/
|
||||
client.ts # stub,Phase 5 实现
|
||||
models.ts # stub,Phase 5 实现
|
||||
threads.ts # stub,Phase 5 实现
|
||||
runs.ts # stub,Phase 5 实现
|
||||
components/
|
||||
layout/
|
||||
CanvasShell.tsx # 顶层布局壳,flex-col
|
||||
CanvasHeader.tsx # 顶部栏:返回键、折叠按钮、标题、流式指示
|
||||
CanvasResizableLayout.tsx # 左右可拖拽双栏(react-resizable-panels)
|
||||
chat/
|
||||
CanvasChatPanel.tsx # 左栏:欢迎状态 + 消息列表 + 禁用输入框
|
||||
artifact/
|
||||
CanvasArtifactPanel.tsx # 右栏:空状态或渲染器
|
||||
CanvasArtifactHeader.tsx # 右栏顶部:图标、标题、语言 Badge、版本导航
|
||||
CanvasArtifactVersionNav.tsx # 版本 prev/next 导航
|
||||
CanvasEmptyState.tsx # 空状态:5 种快速开始模板
|
||||
TextArtifactRenderer.tsx # 文本渲染(Phase 6 增强为 Markdown)
|
||||
CodeArtifactRenderer.tsx # 代码渲染(Phase 6 增强为 CodeMirror)
|
||||
contexts/
|
||||
CanvasThreadContext.tsx # threadId、modelName
|
||||
CanvasGraphContext.tsx # messages、artifact、isStreaming
|
||||
CanvasUIContext.tsx # isChatCollapsed、isEditing、selection 等 UI 状态
|
||||
hooks/
|
||||
useCanvasStream.ts # stub,Phase 5 实现
|
||||
useCanvasArtifact.ts # stub,Phase 6 实现
|
||||
useCanvasSelection.ts # stub,Phase 7 实现
|
||||
useCanvasQuickActions.ts # stub,Phase 8 实现
|
||||
pages/
|
||||
CanvasHomePage.tsx # 欢迎页 + 快速开始入口
|
||||
CanvasThreadPage.tsx # 三层 Provider 装配 + CanvasShell
|
||||
routes/
|
||||
OpenCanvasRoutes.tsx # React Router 子路由
|
||||
types/
|
||||
artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
thread.ts # CanvasMessage、CanvasThreadValues、CanvasThreadMetadata
|
||||
model.ts # CanvasModel(含 supports_canvas_* 能力字段)
|
||||
selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
quick-action.ts # QuickAction、QuickActionId
|
||||
utils/
|
||||
artifact.ts # getCurrentArtifactContent、appendArtifactVersion 等工具
|
||||
selection.ts # stub
|
||||
thread.ts # stub
|
||||
stream.ts # stub
|
||||
quick-actions.ts # stub
|
||||
constants/
|
||||
index.ts # CANVAS_AGENT_ID、PROGRAMMING_LANGUAGES、QUICK_ACTIONS_*
|
||||
```
|
||||
|
||||
### 2.3 核心类型定义
|
||||
|
||||
```ts
|
||||
// types/artifact.ts
|
||||
export type CanvasArtifact = {
|
||||
currentIndex: number; // 从 1 开始
|
||||
contents: CanvasArtifactContent[];
|
||||
};
|
||||
|
||||
export type CanvasArtifactText = {
|
||||
index: number; type: "text"; title: string; fullMarkdown: string;
|
||||
};
|
||||
|
||||
export type CanvasArtifactCode = {
|
||||
index: number; type: "code"; title: string;
|
||||
language: ProgrammingLanguageOptions; code: string;
|
||||
};
|
||||
```
|
||||
|
||||
### 2.4 Context 结构
|
||||
|
||||
```tsx
|
||||
// CanvasThreadPage.tsx 的装配顺序
|
||||
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<CanvasShell />
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、已解决的技术难题
|
||||
|
||||
### 3.1 react-resizable-panels v4.6.2 API 差异
|
||||
|
||||
此版本与旧版 API 不同,必须使用:
|
||||
|
||||
```tsx
|
||||
// ✅ 正确写法
|
||||
import type { GroupImperativeHandle } from "react-resizable-panels";
|
||||
const layoutRef = useRef<GroupImperativeHandle>(null);
|
||||
|
||||
<ResizablePanelGroup
|
||||
orientation="horizontal" // ← 不是 direction
|
||||
groupRef={layoutRef} // ← 不是 ref
|
||||
>
|
||||
<ResizablePanel id="chat" ... /> // ← 必须有 id
|
||||
<ResizablePanel id="artifact" ... /> // ← 必须有 id
|
||||
</ResizablePanelGroup>
|
||||
|
||||
// 切换折叠
|
||||
layoutRef.current?.setLayout({ chat: 0, artifact: 100 }); // ← 对象形式,非数组
|
||||
```
|
||||
|
||||
### 3.2 Flex 高度链(防止底部内容被裁剪)
|
||||
|
||||
```
|
||||
PageLayout(h-screen)
|
||||
└── main(flex-1)
|
||||
└── CanvasShell div(h-full flex flex-col)
|
||||
└── CanvasResizableLayout wrapper(flex-1 min-h-0 overflow-hidden)← 关键
|
||||
└── ResizablePanelGroup(h-full)
|
||||
└── 面板内容(h-full)
|
||||
```
|
||||
|
||||
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
|
||||
|
||||
### 3.3 折叠/展开按钮
|
||||
|
||||
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
|
||||
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
|
||||
);
|
||||
}, [isChatCollapsed]);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、下一步:后端 ThreadState 扩展
|
||||
|
||||
**目标文件**:`offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py`
|
||||
|
||||
**任务**:在现有 `ThreadState` 中新增以下 Canvas 专用字段,不影响 `artifacts: list[str]`(文件产物):
|
||||
|
||||
```python
|
||||
# 新增 TypedDict(建议单独定义)
|
||||
class CanvasArtifactText(TypedDict):
|
||||
index: int
|
||||
type: Literal["text"]
|
||||
title: str
|
||||
fullMarkdown: str
|
||||
|
||||
class CanvasArtifactCode(TypedDict):
|
||||
index: int
|
||||
type: Literal["code"]
|
||||
title: str
|
||||
language: str
|
||||
code: str
|
||||
|
||||
class CanvasArtifact(TypedDict):
|
||||
currentIndex: int
|
||||
contents: list[CanvasArtifactText | CanvasArtifactCode]
|
||||
|
||||
# 在 ThreadState 中新增字段
|
||||
artifact: Optional[CanvasArtifact] # Canvas 主文档(区别于 artifacts 文件列表)
|
||||
highlighted_text: Optional[str] # 用户选中的文本片段
|
||||
highlighted_code: Optional[str] # 用户选中的代码片段
|
||||
canvas_mode: Optional[Literal["text", "code"]]
|
||||
quick_action_id: Optional[str] # 快捷动作 id
|
||||
web_search_enabled: bool # 默认 False
|
||||
web_search_results: Optional[list[dict]]
|
||||
```
|
||||
|
||||
**验收标准**:
|
||||
- 旧线程无 `artifact` 字段时不报错
|
||||
- `pnpm typecheck` 无新增错误(前端字段已对齐)
|
||||
- `make test` 全量回归通过
|
||||
|
||||
**之后紧接**:`canvas_agent` 注册 + `factory.py` 修改 + 最小节点闭环
|
||||
|
||||
---
|
||||
|
||||
## 五、关键文件位置
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `frontend-web/docs/openCanvas.md` | 主需求文档 |
|
||||
| `frontend-web/docs/openCanvas-frontend-checklist.md` | 前端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-backend-checklist.md` | 后端详细清单 |
|
||||
| `frontend-web/docs/openCanvas-progress.md` | 本进度文档 |
|
||||
| `F:\react01\openCanvas` | open-canvas 源码参考(本地克隆) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/thread_state.py` | 下一步主改文件 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/factory.py` | canvas_agent 注册位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构决策记录
|
||||
|
||||
| 决策 | 内容 |
|
||||
|------|------|
|
||||
| canvas_agent 方案 | 新增独立 canvas_agent,不改 lead_agent(方案 A)|
|
||||
| 路由形式 | `/page/canvas/*`,挂在 PageLayout 内 |
|
||||
| artifact 字段共存 | `artifact`(Canvas 文档)与 `artifacts: list[str]`(文件列表)独立共存 |
|
||||
| 模型配置真源 | 后端 config.yaml,前端动态加载 /api/models |
|
||||
| 前端 artifact 索引 | 从 1 开始(与 open-canvas 对齐) |
|
||||
| 流式首版策略 | 节点完成后输出完整 artifact 对象,不做 token 级局部流 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在集成 open-canvas 到 DeerFlow 项目。
|
||||
参考进度文档:frontend-web/docs/openCanvas-progress.md
|
||||
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
|
||||
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
|
||||
```
|
||||
@@ -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 架构保持长期兼容。
|
||||
Generated
+538
-6
@@ -48,6 +48,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",
|
||||
@@ -60,8 +61,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",
|
||||
@@ -70,6 +73,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
@@ -87,7 +91,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"
|
||||
}
|
||||
},
|
||||
"node_modules/@ai-sdk/gateway": {
|
||||
@@ -1663,6 +1668,15 @@
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@popperjs/core": {
|
||||
"version": "2.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/popperjs"
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/number": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
|
||||
@@ -4613,6 +4627,11 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prismjs": {
|
||||
"version": "1.26.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz",
|
||||
"integrity": "sha512-vqlvI7qlMvcCBbVe0AKAb4f97//Hy0EBTaiW8AalRnG/xAN5zOiWWyrNqNXeq8+KAuvRewjCVY1+IPxk4RdNYw=="
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "19.2.13",
|
||||
"license": "MIT",
|
||||
@@ -4628,6 +4647,16 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sortablejs": {
|
||||
"version": "1.15.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/sortablejs/-/sortablejs-1.15.9.tgz",
|
||||
"integrity": "sha512-7HP+rZGE2p886PKV9c9OJzLBI6BBJu1O7lJGYnPyG3fS4/duUCcngkNCjsLwIMV+WMqANe3tt4irrXHSIe68OQ=="
|
||||
},
|
||||
"node_modules/@types/tinycolor2": {
|
||||
"version": "1.4.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/tinycolor2/-/tinycolor2-1.4.6.tgz",
|
||||
"integrity": "sha512-iEN8J0BoMnsWBqjVbWH/c0G0Hh7O21lpR2/+PrvAVgWdzL7eexIFm4JN/Wn10PTcmNdtS6U67r499mlWMXOxNw=="
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
@@ -4639,6 +4668,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
"integrity": "sha512-T8L6i7wCuyoK8A/ZeLYt1+q0ty3Zb9+qbSSvrIVitzT3YjZqkTZ40IbRsPanlB4h1QB3JVL1SYCdR6ngtFYcuA=="
|
||||
},
|
||||
"node_modules/@uiw/codemirror-extensions-basic-setup": {
|
||||
"version": "4.25.4",
|
||||
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.4.tgz",
|
||||
@@ -4768,6 +4802,47 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.34.tgz",
|
||||
"integrity": "sha512-s9cLyK5mLcvZ4Agva5QgRsQyLKvts9WbU9DB6NqiZkkGEdwmcEiylj5Jbwkp680drF/NNCV8OlAJSe+yMLxaJw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.3",
|
||||
"@vue/shared": "3.5.34",
|
||||
"entities": "^7.0.1",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core/node_modules/entities": {
|
||||
"version": "7.0.1",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
|
||||
"integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.34.tgz",
|
||||
"integrity": "sha512-EbF/T++k0e2MMZlJsBhzK8Sgwt0HcIPOhzn1CTB/lv6sQcyk+OWf8YeiLxZp3ro7MbbLcAfAJ6sEvjFWuNgUCw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.5.34",
|
||||
"@vue/shared": "3.5.34"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.5.34",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.34.tgz",
|
||||
"integrity": "sha512-24uqU4OIiX29ryC3MeWid/Xf2fa2EFRUVLb77nRhk+UrTVrh/XiGtFAFmJBAtBRbjwNdsPRP+jj/OL27Eg1NDA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@xyflow/react": {
|
||||
"version": "12.10.0",
|
||||
"license": "MIT",
|
||||
@@ -5048,6 +5123,11 @@
|
||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w=="
|
||||
},
|
||||
"node_modules/classnames": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
|
||||
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow=="
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"license": "MIT",
|
||||
@@ -5122,6 +5202,11 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/core-util-is": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
|
||||
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
|
||||
},
|
||||
"node_modules/cose-base": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/cose-base/-/cose-base-1.0.3.tgz",
|
||||
@@ -5696,6 +5781,44 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/docx": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/docx/-/docx-9.6.1.tgz",
|
||||
"integrity": "sha512-ZJja9/KBUuFC109sCMzovoq2GR2wCG/AuxivjA+OHj/q0TEgJIm3S7yrlUxIy3B+bV8YDj/BiHfWyrRFmyWpDQ==",
|
||||
"dependencies": {
|
||||
"@types/node": "^25.2.3",
|
||||
"hash.js": "^1.1.7",
|
||||
"jszip": "^3.10.1",
|
||||
"nanoid": "^5.1.3",
|
||||
"xml": "^1.0.1",
|
||||
"xml-js": "^1.6.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/@types/node": {
|
||||
"version": "25.7.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.7.0.tgz",
|
||||
"integrity": "sha512-z+pdZyxE+RTQE9AcboAZCb4otwcrvgHD+GlBpPgn0emDVt0ohrTMhAwlr2Wd9nZ+nihhYFxO2pThz3C5qSu2Eg==",
|
||||
"dependencies": {
|
||||
"undici-types": "~7.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/docx/node_modules/undici-types": {
|
||||
"version": "7.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.21.0.tgz",
|
||||
"integrity": "sha512-w9IMgQrz4O0YN1LtB7K5P63vhlIOvC7opSmouCJ+ZywlPAlO9gIkJ+otk6LvGpAs2wg4econaCz3TvQ9xPoyuQ=="
|
||||
},
|
||||
"node_modules/dom-helpers": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz",
|
||||
"integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.8.7",
|
||||
"csstype": "^3.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.2",
|
||||
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz",
|
||||
@@ -5839,6 +5962,12 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
|
||||
"integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz",
|
||||
@@ -5857,6 +5986,18 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
"integrity": "sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==",
|
||||
"dependencies": {
|
||||
"format": "^0.2.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -5874,6 +6015,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/format": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/format/-/format-0.2.2.tgz",
|
||||
"integrity": "sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==",
|
||||
"engines": {
|
||||
"node": ">=0.4.x"
|
||||
}
|
||||
},
|
||||
"node_modules/framer-motion": {
|
||||
"version": "12.38.0",
|
||||
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz",
|
||||
@@ -5946,6 +6095,15 @@
|
||||
"resolved": "https://registry.npmjs.org/hachure-fill/-/hachure-fill-0.5.2.tgz",
|
||||
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg=="
|
||||
},
|
||||
"node_modules/hash.js": {
|
||||
"version": "1.1.7",
|
||||
"resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz",
|
||||
"integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"minimalistic-assert": "^1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/hast": {
|
||||
"version": "1.0.0",
|
||||
"license": "MIT"
|
||||
@@ -6172,6 +6330,32 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/highlight.js": {
|
||||
"version": "10.7.3",
|
||||
"resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz",
|
||||
"integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/highlightjs-vue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz",
|
||||
"integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA=="
|
||||
},
|
||||
"node_modules/hoist-non-react-statics": {
|
||||
"version": "3.3.2",
|
||||
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||
"dependencies": {
|
||||
"react-is": "^16.7.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/html-url-attributes": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
|
||||
@@ -6201,6 +6385,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ=="
|
||||
},
|
||||
"node_modules/inherits": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
|
||||
},
|
||||
"node_modules/inline-style-parser": {
|
||||
"version": "0.2.7",
|
||||
"resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
|
||||
@@ -6276,6 +6470,11 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/isarray": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
|
||||
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ=="
|
||||
},
|
||||
"node_modules/jiti": {
|
||||
"version": "2.6.1",
|
||||
"resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz",
|
||||
@@ -6296,8 +6495,7 @@
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
|
||||
"dev": true
|
||||
"integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="
|
||||
},
|
||||
"node_modules/jsesc": {
|
||||
"version": "3.1.0",
|
||||
@@ -6328,6 +6526,17 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jszip": {
|
||||
"version": "3.10.1",
|
||||
"resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
|
||||
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
|
||||
"dependencies": {
|
||||
"lie": "~3.3.0",
|
||||
"pako": "~1.0.2",
|
||||
"readable-stream": "~2.3.6",
|
||||
"setimmediate": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/katex": {
|
||||
"version": "0.16.28",
|
||||
"funding": [
|
||||
@@ -6401,6 +6610,14 @@
|
||||
"resolved": "https://registry.npmjs.org/layout-base/-/layout-base-1.0.2.tgz",
|
||||
"integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg=="
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.3.0",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
|
||||
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
@@ -6664,6 +6881,30 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
"integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
|
||||
"dependencies": {
|
||||
"js-tokens": "^3.0.0 || ^4.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"loose-envify": "cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/lowlight": {
|
||||
"version": "1.20.0",
|
||||
"resolved": "https://registry.npmjs.org/lowlight/-/lowlight-1.20.0.tgz",
|
||||
"integrity": "sha512-8Ktj+prEb1RoCPkEOrPMYUN/nCggB7qAWe3a7OpMjWQkh3l2RD5wKRQ+o8Q8YuI9RG/xs95waaI/E6ym/7NsTw==",
|
||||
"dependencies": {
|
||||
"fault": "^1.0.0",
|
||||
"highlight.js": "~10.7.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/lru-cache": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
|
||||
@@ -6675,7 +6916,8 @@
|
||||
},
|
||||
"node_modules/lucide-react": {
|
||||
"version": "0.562.0",
|
||||
"license": "ISC",
|
||||
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.562.0.tgz",
|
||||
"integrity": "sha512-82hOAu7y0dbVuFfmO4bYF1XEwYk/mEbM5E+b1jgci/udUBEE/R7LF5Ip0CCEmXe8AybRM8L+04eP+LGZeDvkiw==",
|
||||
"peerDependencies": {
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
@@ -7575,6 +7817,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"node_modules/minimalistic-assert": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
||||
},
|
||||
"node_modules/mitt": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
|
||||
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw=="
|
||||
},
|
||||
"node_modules/mlly": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/mlly/-/mlly-1.8.2.tgz",
|
||||
@@ -7666,6 +7918,14 @@
|
||||
"integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/object-assign": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
|
||||
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ogl": {
|
||||
"version": "1.0.11",
|
||||
"license": "Unlicense"
|
||||
@@ -7738,6 +7998,11 @@
|
||||
"resolved": "https://registry.npmjs.org/package-manager-detector/-/package-manager-detector-1.6.0.tgz",
|
||||
"integrity": "sha512-61A5ThoTiDG/C8s8UMZwSorAGwMJ0ERVGj2OjoW5pAalsNOg15+iQiPzrLJ4jhZ1HJzmC2PIHT2oEiH3R5fzNA=="
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "1.0.11",
|
||||
"resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
|
||||
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="
|
||||
},
|
||||
"node_modules/parse-entities": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
|
||||
@@ -7782,6 +8047,11 @@
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -7869,6 +8139,34 @@
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prismjs": {
|
||||
"version": "1.30.0",
|
||||
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag=="
|
||||
},
|
||||
"node_modules/prop-types": {
|
||||
"version": "15.8.1",
|
||||
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
|
||||
"integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==",
|
||||
"dependencies": {
|
||||
"loose-envify": "^1.4.0",
|
||||
"object-assign": "^4.1.1",
|
||||
"react-is": "^16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/prop-types/node_modules/react-is": {
|
||||
"version": "16.13.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
|
||||
},
|
||||
"node_modules/property-information": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz",
|
||||
@@ -7878,6 +8176,14 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.4",
|
||||
"license": "MIT",
|
||||
@@ -7895,6 +8201,16 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
"integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ=="
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz",
|
||||
"integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg=="
|
||||
},
|
||||
"node_modules/react-markdown": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
|
||||
@@ -8039,6 +8355,74 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-syntax-highlighter": {
|
||||
"version": "16.1.1",
|
||||
"resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz",
|
||||
"integrity": "sha512-PjVawBGy80C6YbC5DDZJeUjBmC7skaoEUdvfFQediQHgCL7aKyVHe57SaJGfQsloGDac+gCpTfRdtxzWWKmCXA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.4",
|
||||
"highlight.js": "^10.4.1",
|
||||
"highlightjs-vue": "^1.0.0",
|
||||
"lowlight": "^1.17.0",
|
||||
"prismjs": "^1.30.0",
|
||||
"refractor": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16.20.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 0.14.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-transition-group": {
|
||||
"version": "4.4.5",
|
||||
"resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz",
|
||||
"integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.5.5",
|
||||
"dom-helpers": "^5.0.1",
|
||||
"loose-envify": "^1.4.0",
|
||||
"prop-types": "^15.6.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.6.0",
|
||||
"react-dom": ">=16.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/readable-stream": {
|
||||
"version": "2.3.8",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.3",
|
||||
"isarray": "~1.0.0",
|
||||
"process-nextick-args": "~2.0.0",
|
||||
"safe-buffer": "~5.1.1",
|
||||
"string_decoder": "~1.1.1",
|
||||
"util-deprecate": "~1.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/refractor": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/refractor/-/refractor-5.0.0.tgz",
|
||||
"integrity": "sha512-QXOrHQF5jOpjjLfiNk5GFnWhRXvxjUVnlFxkeDmewR5sXkr3iM46Zo+CnRR8B+MDVqkULW4EcLVcRBNOPXHosw==",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"@types/prismjs": "^1.0.0",
|
||||
"hastscript": "^9.0.0",
|
||||
"parse-entities": "^4.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.14.1",
|
||||
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
|
||||
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw=="
|
||||
},
|
||||
"node_modules/regex": {
|
||||
"version": "6.1.0",
|
||||
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
|
||||
@@ -8087,7 +8471,8 @@
|
||||
},
|
||||
"node_modules/rehype-raw": {
|
||||
"version": "7.0.0",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz",
|
||||
"integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==",
|
||||
"dependencies": {
|
||||
"@types/hast": "^3.0.0",
|
||||
"hast-util-raw": "^9.0.0",
|
||||
@@ -8100,7 +8485,8 @@
|
||||
},
|
||||
"node_modules/remark-gfm": {
|
||||
"version": "4.0.1",
|
||||
"license": "MIT",
|
||||
"resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
|
||||
"integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
|
||||
"dependencies": {
|
||||
"@types/mdast": "^4.0.0",
|
||||
"mdast-util-gfm": "^3.0.0",
|
||||
@@ -8238,11 +8624,24 @@
|
||||
"resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz",
|
||||
"integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ=="
|
||||
},
|
||||
"node_modules/safe-buffer": {
|
||||
"version": "5.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
"integrity": "sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==",
|
||||
"engines": {
|
||||
"node": ">=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/scheduler": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@@ -8262,6 +8661,11 @@
|
||||
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
|
||||
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="
|
||||
},
|
||||
"node_modules/setimmediate": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
|
||||
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA=="
|
||||
},
|
||||
"node_modules/shiki": {
|
||||
"version": "3.15.0",
|
||||
"license": "MIT",
|
||||
@@ -8284,6 +8688,11 @@
|
||||
"react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc"
|
||||
}
|
||||
},
|
||||
"node_modules/sortablejs": {
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.15.7.tgz",
|
||||
"integrity": "sha512-Kk8wLQPlS+yi1ZEf48a4+fzHa4yxjC30M/Sr2AnQu+f/MPwvvX9XjZ6OWejiz8crBsLwSq8GHqaxaET7u6ux0A=="
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@@ -8332,6 +8741,14 @@
|
||||
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string_decoder": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/stringify-entities": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
|
||||
@@ -8397,6 +8814,75 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tdesign-icons-react": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/tdesign-icons-react/-/tdesign-icons-react-0.5.0.tgz",
|
||||
"integrity": "sha512-Gpl1Kxkb8+YXYjMsSW5W3kWy/drVB/huFLIHWhmJdUnEwUAW0Il+nDyb/BsRi51jLnalBwjYbOELEggH2qp6FQ==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.16.5",
|
||||
"classnames": "^2.2.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1",
|
||||
"react-dom": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tdesign-react": {
|
||||
"version": "1.12.3",
|
||||
"resolved": "https://registry.npmjs.org/tdesign-react/-/tdesign-react-1.12.3.tgz",
|
||||
"integrity": "sha512-zpGPUrxoGvK3ZMFIAM1GmFI2LEYzPfQSALK0egR/RSGuVaQmNSqgonGATz0N1/quAqlZjdRqVH5w6IOJ5ruyKA==",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "~7.26.7",
|
||||
"@popperjs/core": "~2.11.2",
|
||||
"@types/sortablejs": "^1.10.7",
|
||||
"@types/tinycolor2": "^1.4.3",
|
||||
"@types/validator": "^13.1.3",
|
||||
"classnames": "~2.5.1",
|
||||
"dayjs": "1.11.10",
|
||||
"hoist-non-react-statics": "~3.3.2",
|
||||
"lodash-es": "^4.17.21",
|
||||
"mitt": "^3.0.0",
|
||||
"raf": "~3.4.1",
|
||||
"react-fast-compare": "^3.2.2",
|
||||
"react-is": "^18.2.0",
|
||||
"react-transition-group": "~4.4.1",
|
||||
"sortablejs": "^1.15.0",
|
||||
"tdesign-icons-react": "0.5.0",
|
||||
"tinycolor2": "^1.4.2",
|
||||
"tslib": "~2.3.1",
|
||||
"validator": "~13.15.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.13.1",
|
||||
"react-dom": ">=16.13.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tdesign-react/node_modules/@babel/runtime": {
|
||||
"version": "7.26.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.10.tgz",
|
||||
"integrity": "sha512-2WJMeRQPHKSPemqk/awGrAiuFfzBmOIPXKizAsVhWH9YJqLZ0H+HS4c8loHGgW6utJ3E/ejXQUsiGaQy2NZ9Fw==",
|
||||
"dependencies": {
|
||||
"regenerator-runtime": "^0.14.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tdesign-react/node_modules/dayjs": {
|
||||
"version": "1.11.10",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.10.tgz",
|
||||
"integrity": "sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ=="
|
||||
},
|
||||
"node_modules/tdesign-react/node_modules/tslib": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.3.1.tgz",
|
||||
"integrity": "sha512-77EbyPPpMz+FRFRuAFlWMtmgUWGe9UOG2Z25NqCwiIjRhOf5iKGuzSe5P2w1laq+FkRy4p+PCuVkJSGkzTEKVw=="
|
||||
},
|
||||
"node_modules/tinycolor2": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz",
|
||||
"integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw=="
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
|
||||
@@ -8685,6 +9171,11 @@
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "14.0.0",
|
||||
"funding": [
|
||||
@@ -8696,6 +9187,14 @@
|
||||
"uuid": "dist-node/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/validator": {
|
||||
"version": "13.15.35",
|
||||
"resolved": "https://registry.npmjs.org/validator/-/validator-13.15.35.tgz",
|
||||
"integrity": "sha512-TQ5pAGhd5whStmqWvYF4OjQROlmv9SMFVt37qoCBdqRffuuklWYQlCNnEs2ZaIBD1kZRNnikiZOS1eqgkar0iw==",
|
||||
"engines": {
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/vfile": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
|
||||
@@ -8810,6 +9309,23 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vite-plugin-source-identifier": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/vite-plugin-source-identifier/-/vite-plugin-source-identifier-1.1.2.tgz",
|
||||
"integrity": "sha512-Q6zN9G2yJUHcA2c1tlysKyDwBJPlKFbgwLQV7HHva9Yg64V4j6s8NSbfFJB5TiTisu+Xd/VyPUIy0A5zWEmBaA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@babel/core": "^7.24.0",
|
||||
"@babel/parser": "^7.24.0",
|
||||
"@babel/traverse": "^7.28.0",
|
||||
"@babel/types": "^7.24.0",
|
||||
"@vue/compiler-dom": "^3.4.0",
|
||||
"magic-string": "^0.30.7"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/vscode-jsonrpc": {
|
||||
"version": "8.2.0",
|
||||
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
|
||||
@@ -8867,6 +9383,22 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/xml": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/xml/-/xml-1.0.1.tgz",
|
||||
"integrity": "sha512-huCv9IH9Tcf95zuYCsQraZtWnJvBtLVE0QHMOs8bWyZAFZNDcYjsPq1nEx8jKA9y+Beo9v+7OBPRisQTjinQMw=="
|
||||
},
|
||||
"node_modules/xml-js": {
|
||||
"version": "1.6.11",
|
||||
"resolved": "https://registry.npmjs.org/xml-js/-/xml-js-1.6.11.tgz",
|
||||
"integrity": "sha512-7rVi2KMfwfWFl+GpPg6m80IVMWXLRjO+PxTq7V2CDhoGak0wzYzFgUY2m4XJ47OGdXd8eLE8EmwfAmdjw7lC1g==",
|
||||
"dependencies": {
|
||||
"sax": "^1.2.4"
|
||||
},
|
||||
"bin": {
|
||||
"xml-js": "bin/cli.js"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@@ -63,8 +63,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",
|
||||
@@ -73,6 +75,7 @@
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
@@ -90,7 +93,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"
|
||||
}
|
||||
@@ -5,6 +5,8 @@ import { ThemeProvider as NextThemesProvider } from "next-themes";
|
||||
import { QueryClientProvider } from "@/components/query-client-provider";
|
||||
import { I18nProvider } from "@/core/i18n/context";
|
||||
import { detectLocale } from "@/core/i18n/locale";
|
||||
import { ChatProvider } from "@/contexts/ChatContext";
|
||||
import { ThemeProvider } from "@/contexts/ThemeContext";
|
||||
|
||||
import { HashRouter, useLocation } from "react-router-dom";
|
||||
|
||||
@@ -26,7 +28,11 @@ function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
forcedTheme={forcedTheme}
|
||||
>
|
||||
<I18nProvider initialLocale={locale}>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
<ThemeProvider>
|
||||
<ChatProvider>
|
||||
<QueryClientProvider>{children}</QueryClientProvider>
|
||||
</ChatProvider>
|
||||
</ThemeProvider>
|
||||
</I18nProvider>
|
||||
</NextThemesProvider>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import type { ElementType } from "react";
|
||||
import { useNavigate, useLocation } from "react-router-dom";
|
||||
import {
|
||||
BarChart3,
|
||||
Bot,
|
||||
@@ -38,6 +39,7 @@ type MenuItem = {
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ElementType;
|
||||
path?: string;
|
||||
children?: MenuItem[];
|
||||
};
|
||||
|
||||
@@ -49,173 +51,173 @@ type PageSidebarProps = {
|
||||
const menuItems: MenuItem[] = [
|
||||
{
|
||||
id: "qa-center",
|
||||
label: "QA Center",
|
||||
label: "问答中心",
|
||||
icon: HelpCircle,
|
||||
children: [
|
||||
{ id: "new-qa", label: "New QA", icon: Plus },
|
||||
{ id: "general-qa", label: "General QA", icon: CircleHelp },
|
||||
{ id: "agent-qa", label: "Agent QA", icon: Bot },
|
||||
{ id: "multi-agent-qa", label: "Multi Agent", icon: Layers },
|
||||
{ id: "sixstep-chat", label: "Six Step Chat", icon: Workflow },
|
||||
{ id: "qa-records", label: "QA Records", icon: History },
|
||||
{ id: "template-editor", label: "Template Config", icon: Settings },
|
||||
{ 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: "Light Apps",
|
||||
label: "轻应用",
|
||||
icon: Grid3X3,
|
||||
children: [
|
||||
{ id: "light-app-square", label: "App Square", icon: Layout },
|
||||
{ id: "light-app-records", label: "Run Records", icon: History },
|
||||
{ id: "light-app-square", label: "应用广场", icon: Layout },
|
||||
{ id: "light-app-records", label: "运行记录", icon: History },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "task-management",
|
||||
label: "Task Management",
|
||||
label: "任务管理",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "dashboard", label: "Dashboard", icon: BarChart3 },
|
||||
{ id: "dashboard", label: "数据看板", icon: BarChart3 },
|
||||
{
|
||||
id: "xk-task",
|
||||
label: "XK Tasks",
|
||||
label: "XK任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "xk-task-jc", label: "Task JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "Task Dispatch", icon: ListTodo },
|
||||
{ id: "xk-task-jc", label: "任务JC", icon: ListTodo },
|
||||
{ id: "xk-task-dispatch", label: "任务调度", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "plan-task",
|
||||
label: "Planning Tasks",
|
||||
label: "规划任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "plan-task-fx", label: "Task Analysis", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "Action Strategy", icon: ListTodo },
|
||||
{ id: "plan-task-fx", label: "任务分析", icon: ListTodo },
|
||||
{ id: "plan-task-action", label: "行动策略", icon: ListTodo },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "mnty-task",
|
||||
label: "MNTY Tasks",
|
||||
label: "MNTY任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "mnty-task-mechanism", label: "Mechanism Study", icon: ListTodo },
|
||||
{ id: "mnty-task-plan", label: "Plan Task", icon: ListTodo },
|
||||
{ id: "mnty-task-zsty", label: "ZSTY Task", icon: ListTodo },
|
||||
{ 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: "Effect PG Tasks",
|
||||
label: "效果PG任务",
|
||||
icon: Folder,
|
||||
children: [
|
||||
{ id: "effect-task-pg", label: "Task PG", icon: ListTodo },
|
||||
{ id: "effect-action-pg", label: "Action PG", icon: ListTodo },
|
||||
{ id: "effect-info-pg", label: "Info PG", icon: ListTodo },
|
||||
{ id: "effect-account-pg", label: "Account PG", icon: ListTodo },
|
||||
{ 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: "Create Task", icon: Plus },
|
||||
{ id: "task-single", label: "Single Agent Task", icon: Bot },
|
||||
{ id: "task-scene", label: "Scene Task", icon: Workflow },
|
||||
{ id: "task-consult-create", label: "Multi Agent Consult", icon: MessageSquare },
|
||||
{ id: "final-plan", label: "Final Output", icon: FileOutput },
|
||||
{ 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: "Scene Management", icon: Layers },
|
||||
{ id: "scene-list", label: "场景管理", icon: Layers },
|
||||
{
|
||||
id: "agent-list",
|
||||
label: "Agent Management",
|
||||
label: "智能体管理",
|
||||
icon: Bot,
|
||||
children: [
|
||||
{ id: "agent-overview", label: "Agent List", icon: Users },
|
||||
{ id: "agent-trial", label: "Single Agent Trial", icon: PlayCircle },
|
||||
{ id: "agent-overview", label: "智能体列表", icon: Users, path: "/page/strategy/agents" },
|
||||
{ id: "agent-trial", label: "单智能体试用", icon: PlayCircle },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "result-management",
|
||||
label: "Result Management",
|
||||
label: "结果管理",
|
||||
icon: BarChart3,
|
||||
children: [
|
||||
{
|
||||
id: "result-planning",
|
||||
label: "Planning Results",
|
||||
label: "规划结果",
|
||||
icon: Target,
|
||||
children: [
|
||||
{ id: "result-planning-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-planning-goal", label: "Goal Analysis", icon: Target },
|
||||
{ id: "result-planning-brand", label: "Brand Strategy", icon: Target },
|
||||
{ id: "result-planning-content", label: "Content Planning", icon: Target },
|
||||
{ id: "result-planning-channel", label: "Channel Planning", icon: Target },
|
||||
{ id: "result-planning-narrative", label: "Narrative Strategy", icon: Target },
|
||||
{ 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 Results",
|
||||
label: "MNTY结果",
|
||||
icon: Brain,
|
||||
children: [
|
||||
{ id: "result-mnty-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-mnty-mechanism", label: "Mechanism Research", icon: Brain },
|
||||
{ id: "result-mnty-plan", label: "Plan Design", icon: Brain },
|
||||
{ id: "result-mnty-zsty", label: "ZSTY Analysis", icon: Brain },
|
||||
{ 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: "Effect PG Results",
|
||||
label: "效果PG结果",
|
||||
icon: TrendingUp,
|
||||
children: [
|
||||
{ id: "result-effect-pg-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-effect-pg-behavior", label: "Behavior PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-evaluation", label: "Effect Review", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-info", label: "Info PG", icon: TrendingUp },
|
||||
{ id: "result-effect-pg-account", label: "Account PG", icon: TrendingUp },
|
||||
{ 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 Results",
|
||||
label: "XK结果",
|
||||
icon: Compass,
|
||||
children: [
|
||||
{ id: "result-xk-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-xk-jc", label: "Task JC", icon: Compass },
|
||||
{ id: "result-xk-dispatch", label: "Task Dispatch", icon: Compass },
|
||||
{ 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 Results",
|
||||
label: "DQ结果",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "result-dq-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-dq-audience", label: "Audience Analysis", icon: Database },
|
||||
{ id: "result-dq-kb", label: "Knowledge Research", icon: Database },
|
||||
{ id: "result-dq-trend", label: "Trend Research", icon: Database },
|
||||
{ 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: "Other Results",
|
||||
label: "其他结果",
|
||||
icon: MoreHorizontal,
|
||||
children: [
|
||||
{ id: "result-other-all", label: "All", icon: BarChart3 },
|
||||
{ id: "result-other-general", label: "General Analysis", icon: MoreHorizontal },
|
||||
{ id: "result-other-temp", label: "Temp Tasks", icon: MoreHorizontal },
|
||||
{ id: "result-other-other", label: "Other", icon: MoreHorizontal },
|
||||
{ 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: "Resource Management",
|
||||
label: "资源管理",
|
||||
icon: Database,
|
||||
children: [
|
||||
{ id: "resource-zz", label: "ZZ Resources", icon: FileBox },
|
||||
{ id: "resource-network-actor", label: "Network Actor", icon: Network },
|
||||
{ id: "resource-knowledge-base", label: "Knowledge Base", icon: Library },
|
||||
{ id: "resource-evaluation-index", label: "Evaluation Index", icon: Scale },
|
||||
{ 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 },
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -233,6 +235,8 @@ export function PageSidebar({
|
||||
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";
|
||||
@@ -256,14 +260,20 @@ export function PageSidebar({
|
||||
|
||||
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}
|
||||
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 ${
|
||||
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`} />
|
||||
|
||||
@@ -31,23 +31,25 @@ function BreadcrumbItem({ className, ...props }: React.ComponentProps<"li">) {
|
||||
);
|
||||
}
|
||||
|
||||
function BreadcrumbLink({
|
||||
asChild,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="breadcrumb-link"
|
||||
className={cn("hover:text-foreground transition-colors", 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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
BreadcrumbLink.displayName = "BreadcrumbLink";
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
return (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
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";
|
||||
|
||||
@@ -37,27 +37,27 @@ const buttonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function Button({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> &
|
||||
VariantProps<typeof buttonVariants> & {
|
||||
asChild?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="button"
|
||||
{...(variant !== undefined && { "data-variant": variant })}
|
||||
{...(size !== undefined && { "data-size": size })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Button, buttonVariants };
|
||||
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 })}
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
Button.displayName = "Button";
|
||||
|
||||
export { Button, buttonVariants };
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as CollapsiblePrimitive from "@radix-ui/react-collapsible";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -10,28 +11,37 @@ function Collapsible({
|
||||
return <CollapsiblePrimitive.Root data-slot="collapsible" {...props} />;
|
||||
}
|
||||
|
||||
function CollapsibleTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleTrigger>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleTrigger
|
||||
data-slot="collapsible-trigger"
|
||||
className={cn("cursor-pointer", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleContent({
|
||||
...props
|
||||
}: React.ComponentProps<typeof CollapsiblePrimitive.CollapsibleContent>) {
|
||||
return (
|
||||
<CollapsiblePrimitive.CollapsibleContent
|
||||
data-slot="collapsible-content"
|
||||
{...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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
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}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CollapsibleContent.displayName =
|
||||
CollapsiblePrimitive.CollapsibleContent.displayName;
|
||||
|
||||
export { Collapsible, CollapsibleTrigger, CollapsibleContent };
|
||||
@@ -12,11 +12,20 @@ function Dialog({
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
const DialogTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dialog-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogTrigger.displayName = DialogPrimitive.Trigger.displayName;
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
@@ -24,61 +33,68 @@ function DialogPortal({
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
const DialogClose = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <DialogPrimitive.Close ref={ref} data-slot="dialog-close" {...props} />;
|
||||
});
|
||||
|
||||
DialogClose.displayName = DialogPrimitive.Close.displayName;
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName;
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
showCloseButton = true,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
showCloseButton?: boolean;
|
||||
}
|
||||
>(({ className, children, showCloseButton = true, ...props }, ref) => {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{showCloseButton && (
|
||||
<DialogPrimitive.Close
|
||||
data-slot="dialog-close"
|
||||
className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
)}
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
});
|
||||
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName;
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -20,36 +20,42 @@ function DropdownMenuPortal({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuTrigger.displayName = DropdownMenuPrimitive.Trigger.displayName;
|
||||
|
||||
function DropdownMenuContent({
|
||||
className,
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const DropdownMenuContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName;
|
||||
|
||||
function DropdownMenuGroup({
|
||||
...props
|
||||
@@ -59,28 +65,29 @@ function DropdownMenuGroup({
|
||||
);
|
||||
}
|
||||
|
||||
function DropdownMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuItem = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}
|
||||
>(({ className, inset, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_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]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName;
|
||||
|
||||
function DropdownMenuCheckboxItem({
|
||||
className,
|
||||
@@ -198,45 +205,51 @@ function DropdownMenuSub({
|
||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function DropdownMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}
|
||||
>(({ className, inset, children, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubTrigger
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto size-4" />
|
||||
</DropdownMenuPrimitive.SubTrigger>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubTrigger.displayName =
|
||||
DropdownMenuPrimitive.SubTrigger.displayName;
|
||||
|
||||
function DropdownMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const DropdownMenuSubContent = React.forwardRef<
|
||||
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
|
||||
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
ref={ref}
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
DropdownMenuSubContent.displayName =
|
||||
DropdownMenuPrimitive.SubContent.displayName;
|
||||
|
||||
export {
|
||||
DropdownMenu,
|
||||
|
||||
@@ -11,34 +11,42 @@ function HoverCard({
|
||||
return <HoverCardPrimitive.Root data-slot="hover-card" {...props} />;
|
||||
}
|
||||
|
||||
function HoverCardTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Trigger>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger data-slot="hover-card-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function HoverCardContent({
|
||||
className,
|
||||
align = "center",
|
||||
sideOffset = 4,
|
||||
...props
|
||||
}: React.ComponentProps<typeof HoverCardPrimitive.Content>) {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const HoverCardTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="hover-card-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardTrigger.displayName = HoverCardPrimitive.Trigger.displayName;
|
||||
|
||||
const HoverCardContent = React.forwardRef<
|
||||
React.ElementRef<typeof HoverCardPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof HoverCardPrimitive.Content>
|
||||
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => {
|
||||
return (
|
||||
<HoverCardPrimitive.Portal data-slot="hover-card-portal">
|
||||
<HoverCardPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="hover-card-content"
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 z-50 w-64 origin-(--radix-hover-card-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</HoverCardPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
HoverCardContent.displayName = HoverCardPrimitive.Content.displayName;
|
||||
|
||||
export { HoverCard, HoverCardTrigger, HoverCardContent };
|
||||
@@ -24,68 +24,69 @@ function SelectValue({
|
||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||
}
|
||||
|
||||
function SelectTrigger({
|
||||
className,
|
||||
size = "default",
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}) {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
}
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
|
||||
size?: "sm" | "default";
|
||||
}
|
||||
>(({ className, size = "default", children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="select-trigger"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDownIcon className="size-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
);
|
||||
});
|
||||
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName;
|
||||
|
||||
function SelectContent({
|
||||
className,
|
||||
children,
|
||||
position = "item-aligned",
|
||||
align = "center",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = "item-aligned", align = "center", ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out 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 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
align={align}
|
||||
{...props}
|
||||
>
|
||||
<SelectScrollUpButton />
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
"p-1",
|
||||
position === "popper" &&
|
||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
<SelectScrollDownButton />
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName;
|
||||
|
||||
function SelectLabel({
|
||||
className,
|
||||
@@ -100,32 +101,34 @@ function SelectLabel({
|
||||
);
|
||||
}
|
||||
|
||||
function SelectItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
}
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
return (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
data-slot="select-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span
|
||||
data-slot="select-item-indicator"
|
||||
className="absolute right-2 flex size-3.5 items-center justify-center"
|
||||
>
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
);
|
||||
});
|
||||
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName;
|
||||
|
||||
function SelectSeparator({
|
||||
className,
|
||||
|
||||
@@ -10,17 +10,23 @@ function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
|
||||
return <SheetPrimitive.Root data-slot="sheet" {...props} />;
|
||||
}
|
||||
|
||||
function SheetTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
|
||||
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />;
|
||||
}
|
||||
const SheetTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Trigger ref={ref} data-slot="sheet-trigger" {...props} />;
|
||||
});
|
||||
|
||||
SheetTrigger.displayName = SheetPrimitive.Trigger.displayName;
|
||||
|
||||
function SheetClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
|
||||
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />;
|
||||
}
|
||||
const SheetClose = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Close>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Close>
|
||||
>(({ ...props }, ref) => {
|
||||
return <SheetPrimitive.Close ref={ref} data-slot="sheet-close" {...props} />;
|
||||
});
|
||||
|
||||
SheetClose.displayName = SheetPrimitive.Close.displayName;
|
||||
|
||||
function SheetPortal({
|
||||
...props
|
||||
@@ -28,58 +34,62 @@ function SheetPortal({
|
||||
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />;
|
||||
}
|
||||
|
||||
function SheetOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SheetOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<SheetPrimitive.Overlay
|
||||
ref={ref}
|
||||
data-slot="sheet-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName;
|
||||
|
||||
function SheetContent({
|
||||
className,
|
||||
children,
|
||||
side = "right",
|
||||
...props
|
||||
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}) {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
}
|
||||
const SheetContent = React.forwardRef<
|
||||
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content> & {
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
}
|
||||
>(({ className, children, side = "right", ...props }, ref) => {
|
||||
return (
|
||||
<SheetPortal>
|
||||
<SheetOverlay />
|
||||
<SheetPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="sheet-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500",
|
||||
side === "right" &&
|
||||
"data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm",
|
||||
side === "left" &&
|
||||
"data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm",
|
||||
side === "top" &&
|
||||
"data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b",
|
||||
side === "bottom" &&
|
||||
"data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SheetPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-secondary absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none">
|
||||
<XIcon className="size-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</SheetPrimitive.Close>
|
||||
</SheetPrimitive.Content>
|
||||
</SheetPortal>
|
||||
);
|
||||
});
|
||||
|
||||
SheetContent.displayName = SheetPrimitive.Content.displayName;
|
||||
|
||||
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
|
||||
@@ -53,6 +53,10 @@ function useSidebar() {
|
||||
return context;
|
||||
}
|
||||
|
||||
function useSidebarSafe() {
|
||||
return React.useContext(SidebarContext);
|
||||
}
|
||||
|
||||
function SidebarProvider({
|
||||
defaultOpen = true,
|
||||
open: openProp,
|
||||
@@ -139,7 +143,7 @@ function SidebarProvider({
|
||||
} as React.CSSProperties
|
||||
}
|
||||
className={cn(
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex min-h-svh w-full",
|
||||
"group/sidebar-wrapper has-data-[variant=inset]:bg-sidebar flex h-full w-full",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -229,7 +233,7 @@ function Sidebar({
|
||||
<div
|
||||
data-slot="sidebar-container"
|
||||
className={cn(
|
||||
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
|
||||
"fixed bottom-0 z-10 hidden w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex [top:var(--header-height,4rem)]",
|
||||
side === "left"
|
||||
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
|
||||
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
|
||||
@@ -309,7 +313,7 @@ function SidebarInset({ className, ...props }: React.ComponentProps<"main">) {
|
||||
<main
|
||||
data-slot="sidebar-inset"
|
||||
className={cn(
|
||||
"bg-[#fffdfa] relative flex w-full flex-1 flex-col",
|
||||
"bg-background relative flex w-full flex-1 flex-col",
|
||||
"md:peer-data-[variant=inset]:m-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow-sm md:peer-data-[variant=inset]:peer-data-[state=collapsed]:ml-2",
|
||||
className,
|
||||
)}
|
||||
@@ -393,49 +397,53 @@ function SidebarGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupLabel({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
const SidebarGroupLabel = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<"div"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "div";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupLabel.displayName = "SidebarGroupLabel";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-label"
|
||||
data-sidebar="group-label"
|
||||
className={cn(
|
||||
"text-sidebar-foreground/70 ring-sidebar-ring flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium outline-hidden transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarGroupAction({
|
||||
className,
|
||||
asChild = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & { asChild?: boolean }) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarGroupAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & { asChild?: boolean }
|
||||
>(({ className, asChild = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-group-action"
|
||||
data-sidebar="group-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-3.5 right-3 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarGroupAction.displayName = "SidebarGroupAction";
|
||||
|
||||
function SidebarGroupContent({
|
||||
className,
|
||||
@@ -495,87 +503,99 @@ const sidebarMenuButtonVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function SidebarMenuButton({
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
const SidebarMenuButton = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
isActive?: boolean;
|
||||
tooltip?: string | React.ComponentProps<typeof TooltipContent>;
|
||||
} & VariantProps<typeof sidebarMenuButtonVariants>
|
||||
>(
|
||||
(
|
||||
{
|
||||
asChild = false,
|
||||
isActive = false,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
tooltip,
|
||||
className,
|
||||
...props
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
const { isMobile, state } = useSidebar();
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
SidebarMenuButton.displayName = "SidebarMenuButton";
|
||||
|
||||
const button = (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-button"
|
||||
data-sidebar="menu-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(sidebarMenuButtonVariants({ variant, size }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
|
||||
if (typeof tooltip === "string") {
|
||||
tooltip = {
|
||||
children: tooltip,
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
hidden={state !== "collapsed" || isMobile}
|
||||
{...tooltip}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuAction({
|
||||
className,
|
||||
asChild = false,
|
||||
showOnHover = false,
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuAction = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
showOnHover?: boolean;
|
||||
}
|
||||
>(({ className, asChild = false, showOnHover = false, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-action"
|
||||
data-sidebar="menu-action"
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground peer-hover/menu-button:text-sidebar-accent-foreground absolute top-1.5 right-1 flex aspect-square w-5 items-center justify-center rounded-md p-0 outline-hidden transition-transform focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
// Increases the hit area of the button on mobile.
|
||||
"after:absolute after:-inset-2 md:after:hidden",
|
||||
"peer-data-[size=sm]/menu-button:top-1",
|
||||
"peer-data-[size=default]/menu-button:top-1.5",
|
||||
"peer-data-[size=lg]/menu-button:top-2.5",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
showOnHover &&
|
||||
"peer-data-[active=true]/menu-button:text-sidebar-accent-foreground group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 data-[state=open]:opacity-100 md:opacity-0",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuAction.displayName = "SidebarMenuAction";
|
||||
|
||||
function SidebarMenuBadge({
|
||||
className,
|
||||
@@ -666,37 +686,37 @@ function SidebarMenuSubItem({
|
||||
);
|
||||
}
|
||||
|
||||
function SidebarMenuSubButton({
|
||||
asChild = false,
|
||||
size = "md",
|
||||
isActive = false,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}) {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const SidebarMenuSubButton = React.forwardRef<
|
||||
HTMLAnchorElement,
|
||||
React.ComponentProps<"a"> & {
|
||||
asChild?: boolean;
|
||||
size?: "sm" | "md";
|
||||
isActive?: boolean;
|
||||
}
|
||||
>(({ asChild = false, size = "md", isActive = false, className, ...props }, ref) => {
|
||||
const Comp = asChild ? Slot : "a";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
ref={ref}
|
||||
data-slot="sidebar-menu-sub-button"
|
||||
data-sidebar="menu-sub-button"
|
||||
data-size={size}
|
||||
data-active={isActive}
|
||||
className={cn(
|
||||
"text-sidebar-foreground ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground active:bg-sidebar-accent active:text-sidebar-accent-foreground [&>svg]:text-sidebar-accent-foreground flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 outline-hidden focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
|
||||
"data-[active=true]:bg-sidebar-accent data-[active=true]:text-sidebar-accent-foreground",
|
||||
size === "sm" && "text-xs",
|
||||
size === "md" && "text-sm",
|
||||
"group-data-[collapsible=icon]:hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
SidebarMenuSubButton.displayName = "SidebarMenuSubButton";
|
||||
|
||||
export {
|
||||
Sidebar,
|
||||
@@ -723,4 +743,5 @@ export {
|
||||
SidebarSeparator,
|
||||
SidebarTrigger,
|
||||
useSidebar,
|
||||
useSidebarSafe,
|
||||
};
|
||||
@@ -40,52 +40,60 @@ const tabsListVariants = cva(
|
||||
},
|
||||
);
|
||||
|
||||
function TabsList({
|
||||
className,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>) {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List> &
|
||||
VariantProps<typeof tabsListVariants>
|
||||
>(({ className, variant = "default", ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
data-slot="tabs-list"
|
||||
data-variant={variant}
|
||||
className={cn(tabsListVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
function TabsTrigger({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tabs-trigger"
|
||||
className={cn(
|
||||
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring text-foreground/60 hover:text-foreground dark:text-muted-foreground dark:hover:text-foreground relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 group-data-[variant=default]/tabs-list:data-[state=active]:shadow-sm group-data-[variant=line]/tabs-list:data-[state=active]:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:border-transparent dark:group-data-[variant=line]/tabs-list:data-[state=active]:bg-transparent",
|
||||
"data-[state=active]:bg-background dark:data-[state=active]:text-foreground dark:data-[state=active]:border-input dark:data-[state=active]:bg-input/30 data-[state=active]:text-foreground",
|
||||
"after:bg-foreground after:absolute after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-[state=active]:after:opacity-100",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
function TabsContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TabsPrimitive.Content>) {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tabs-content"
|
||||
className={cn("flex-1 outline-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants };
|
||||
@@ -28,33 +28,43 @@ function Tooltip({
|
||||
);
|
||||
}
|
||||
|
||||
function TooltipTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
|
||||
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function TooltipContent({
|
||||
className,
|
||||
sideOffset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
const TooltipTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Trigger>
|
||||
>(({ ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Trigger
|
||||
ref={ref}
|
||||
data-slot="tooltip-trigger"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipTrigger.displayName = TooltipPrimitive.Trigger.displayName;
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset, children, ...props }, ref) => {
|
||||
return (
|
||||
<TooltipPrimitive.Portal>
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset ?? 4}
|
||||
className={cn(
|
||||
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-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 bg-foreground text-background dark:text-foreground z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md border px-3 py-1.5 text-xs text-balance shadow-xs dark:border-white/18 dark:bg-[#050504]",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
});
|
||||
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName;
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
|
||||
@@ -0,0 +1,293 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { TagChip } from "@/components/workspace/tags/tag-chip";
|
||||
import { TAG_COLORS } from "@/components/workspace/tags/tag-colors";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useCreateTag,
|
||||
useDeleteTag,
|
||||
useTags,
|
||||
useUpdateTag,
|
||||
type Tag,
|
||||
type TagType,
|
||||
} from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function TagFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
tagType,
|
||||
editing,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
tagType: TagType;
|
||||
editing: Tag | null;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const createTag = useCreateTag();
|
||||
const updateTag = useUpdateTag();
|
||||
const [name, setName] = useState("");
|
||||
const [color, setColor] = useState("gray");
|
||||
const [description, setDescription] = useState("");
|
||||
|
||||
// Sync form state whenever the dialog (re)opens.
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${editing?.id ?? "new"}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setName(editing?.name ?? "");
|
||||
setColor(editing?.color ?? "gray");
|
||||
setDescription(editing?.description ?? "");
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
const pending = createTag.isPending || updateTag.isPending;
|
||||
|
||||
const handleSave = async () => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
try {
|
||||
if (editing) {
|
||||
await updateTag.mutateAsync({
|
||||
id: editing.id,
|
||||
input: { name: trimmed, color, description },
|
||||
});
|
||||
} else {
|
||||
await createTag.mutateAsync({
|
||||
name: trimmed,
|
||||
tag_type: tagType,
|
||||
color,
|
||||
description,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? t.adminTags.editTitle : t.adminTags.createTitle}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.name}</label>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t.adminTags.namePlaceholder}
|
||||
maxLength={256}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">{t.adminTags.color}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{TAG_COLORS.map((c) => (
|
||||
<button
|
||||
key={c.name}
|
||||
type="button"
|
||||
onClick={() => setColor(c.name)}
|
||||
aria-label={c.name}
|
||||
className={cn(
|
||||
"size-6 rounded-full border-2",
|
||||
c.swatch,
|
||||
color === c.name
|
||||
? "border-foreground"
|
||||
: "border-transparent",
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className="text-sm font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</label>
|
||||
<Textarea
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder={t.adminTags.descriptionPlaceholder}
|
||||
maxLength={1024}
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
{name.trim() && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-muted-foreground text-xs">Preview:</span>
|
||||
<TagChip tag={{ name: name.trim(), color }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.cancel}
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={pending || !name.trim()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||||
{t.adminTags.save}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function TagManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const [activeType, setActiveType] = useState<TagType>("agent");
|
||||
const { data: tags, isLoading, error } = useTags(activeType);
|
||||
const deleteTag = useDeleteTag();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Tag | null>(null);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const openEdit = (tag: Tag) => {
|
||||
setEditing(tag);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
const handleDelete = async (tag: Tag) => {
|
||||
if (!window.confirm(t.adminTags.deleteConfirm)) return;
|
||||
try {
|
||||
await deleteTag.mutateAsync(tag.id);
|
||||
} catch (e) {
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminTags.title}
|
||||
description={t.adminTags.description}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Tabs
|
||||
value={activeType}
|
||||
onValueChange={(v) => setActiveType(v as TagType)}
|
||||
>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="agent">{t.adminTags.typeAgent}</TabsTrigger>
|
||||
<TabsTrigger value="skill">{t.adminTags.typeSkill}</TabsTrigger>
|
||||
<TabsTrigger value="scheduled_task">
|
||||
{t.adminTags.typeScheduledTask}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button size="sm" onClick={openCreate}>
|
||||
<PlusIcon className="size-4" />
|
||||
{t.adminTags.createButton}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-8">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="text-destructive py-4 text-sm">{String(error)}</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length === 0 && (
|
||||
<div className="text-muted-foreground py-8 text-center text-sm">
|
||||
{t.adminTags.empty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && !error && tags && tags.length > 0 && (
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.name}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-left font-medium">
|
||||
{t.adminTags.tagDescription}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.usageCount}
|
||||
</th>
|
||||
<th className="text-muted-foreground px-4 py-3 text-right font-medium">
|
||||
{t.adminTags.actions}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{tags.map((tag, i) => (
|
||||
<tr
|
||||
key={tag.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<TagChip tag={tag} />
|
||||
</td>
|
||||
<td className="text-muted-foreground px-4 py-3">
|
||||
{tag.description || "—"}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">
|
||||
{tag.usage_count}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEdit(tag)}
|
||||
aria-label={t.adminTags.edit}
|
||||
>
|
||||
<PencilIcon className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleDelete(tag)}
|
||||
aria-label={t.adminTags.delete}
|
||||
>
|
||||
<Trash2Icon className="text-destructive size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
|
||||
<TagFormDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
tagType={activeType}
|
||||
editing={editing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { SettingsSection } from "@/components/workspace/settings/settings-section";
|
||||
import { useAllUsers } from "@/core/auth/hooks";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
export function UserManagementPage() {
|
||||
const { t } = useI18n();
|
||||
const { data: users, isLoading, error } = useAllUsers(true);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<SettingsSection
|
||||
title={t.adminUsers.title}
|
||||
description={t.adminUsers.description}
|
||||
>
|
||||
{isLoading && (
|
||||
<div className="flex items-center gap-2 text-muted-foreground py-8 justify-center">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-destructive text-sm py-4">
|
||||
{String(error)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length === 0 && (
|
||||
<div className="text-muted-foreground text-sm py-8 text-center">
|
||||
{t.adminUsers.empty}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isLoading && !error && users && users.length > 0 && (
|
||||
<div className="rounded-md border overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-muted/50">
|
||||
<tr>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.email}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.role}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.skillCount}</th>
|
||||
<th className="text-right px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.agentCount}</th>
|
||||
<th className="text-left px-4 py-3 font-medium text-muted-foreground">{t.adminUsers.userId}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((user, i) => (
|
||||
<tr
|
||||
key={user.id}
|
||||
className={i % 2 === 0 ? "bg-background" : "bg-muted/20"}
|
||||
>
|
||||
<td className="px-4 py-3">{user.email}</td>
|
||||
<td className="px-4 py-3">
|
||||
{user.system_role === "admin" ? (
|
||||
<Badge variant="default">{t.adminUsers.roleAdmin}</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">{t.adminUsers.roleUser}</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.skill_count}</td>
|
||||
<td className="px-4 py-3 text-right tabular-nums">{user.agent_count}</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[160px]">
|
||||
{user.id}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
EyeOffIcon,
|
||||
MessageSquareIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||
import type { Agent } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
@@ -51,6 +54,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const { skills, isLoading: isSkillsLoading } = useSkills();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [editName, setEditName] = useState(agent.name);
|
||||
const [editDescription, setEditDescription] = useState(agent.description ?? "");
|
||||
const [editSoul, setEditSoul] = useState(agent.soul ?? "");
|
||||
@@ -66,10 +70,11 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const isOwned =
|
||||
!isBuiltin && agent.user_id !== null && agent.user_id === currentUserId;
|
||||
const isFromOthers = !isBuiltin && !isOwned;
|
||||
// System admins are allowed to edit built-in agents in place. Delete and
|
||||
// publish-toggle stay owner-only — built-ins shouldn't be removable, and
|
||||
// they are inherently visible to everyone so toggling published is moot.
|
||||
// System admins are allowed to edit and delete built-in agents.
|
||||
// Publish-toggle stays owner-only — built-ins are inherently visible to
|
||||
// everyone so toggling published is moot.
|
||||
const canEdit = isOwned || (isBuiltin && isAdmin);
|
||||
const canDelete = isOwned || (isBuiltin && isAdmin);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editOpen) {
|
||||
@@ -90,7 +95,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
]);
|
||||
|
||||
function handleChat() {
|
||||
router.push(`/workspace/agents/${agent.id}/chats/new`);
|
||||
router.push(`/page/workspace/agents/${agent.id}/chats/new`);
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
@@ -211,6 +216,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
{(agent.tags?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-2">
|
||||
<TagChipList tags={agent.tags!} />
|
||||
</CardContent>
|
||||
)}
|
||||
|
||||
{(agent.tool_groups?.length ?? agent.skills?.length ?? 0) > 0 && (
|
||||
<CardContent className="pt-0 pb-3">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
@@ -241,8 +252,19 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<MessageSquareIcon className="mr-1.5 h-3.5 w-3.5" />
|
||||
{t.agents.chat}
|
||||
</Button>
|
||||
{(canEdit || isOwned) && (
|
||||
{(canEdit || canDelete || isOwned || isAdmin) && (
|
||||
<div className="flex gap-1">
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Button
|
||||
size="icon"
|
||||
@@ -270,7 +292,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<SquarePenIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{isOwned && (
|
||||
{canDelete && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@@ -413,6 +435,16 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="agent"
|
||||
targetId={agent.id}
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -2,22 +2,69 @@
|
||||
|
||||
import { BotIcon, PlusIcon } from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { useAgents } from "@/core/agents";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
|
||||
import { AgentCard } from "./agent-card";
|
||||
|
||||
export function AgentGallery() {
|
||||
const { t } = useI18n();
|
||||
const { agents, isLoading } = useAgents();
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
|
||||
const allAgents = useMemo(
|
||||
() => agents.filter((a) => a.builtin || a.published),
|
||||
[agents],
|
||||
);
|
||||
|
||||
const myAgents = useMemo(
|
||||
() => agents.filter((a) => a.user_id !== null && a.user_id === currentUserId),
|
||||
[agents, currentUserId],
|
||||
);
|
||||
|
||||
const handleNewAgent = () => {
|
||||
router.push("/page/workspace/agents/new");
|
||||
};
|
||||
|
||||
const renderGrid = (list: typeof agents) => (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{list.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderEmpty = (description: string, showNewButton = false) => (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">{description}</p>
|
||||
</div>
|
||||
{showNewButton && (
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col">
|
||||
{/* Page header */}
|
||||
@@ -34,36 +81,50 @@ export function AgentGallery() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<div className="flex h-64 flex-col items-center justify-center gap-3 text-center">
|
||||
<div className="bg-muted flex h-14 w-14 items-center justify-center rounded-full">
|
||||
<BotIcon className="text-muted-foreground h-7 w-7" />
|
||||
{/* Tabs */}
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as "all" | "mine")}
|
||||
className="flex flex-1 flex-col overflow-hidden"
|
||||
>
|
||||
<div className="mx-6 mt-4 flex items-center justify-between gap-4">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
</TabsList>
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="w-[420px] max-w-[50%]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="all" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{t.agents.emptyTitle}</p>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{t.agents.emptyDescription}
|
||||
</p>
|
||||
) : allAgents.length === 0 ? (
|
||||
renderEmpty(t.agents.emptyDescription)
|
||||
) : (
|
||||
renderGrid(allAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="mine" className="flex-1 overflow-y-auto p-6 mt-0">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex h-40 items-center justify-center text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
<Button variant="outline" className="mt-2" onClick={handleNewAgent}>
|
||||
<PlusIcon className="mr-1.5 h-4 w-4" />
|
||||
{t.agents.newAgent}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
|
||||
{agents.map((agent) => (
|
||||
<AgentCard key={agent.id} agent={agent} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : myAgents.length === 0 ? (
|
||||
renderEmpty(t.agents.emptyDescription, true)
|
||||
) : (
|
||||
renderGrid(myAgents)
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,15 +2,16 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -33,11 +34,14 @@ import {
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { CodeEditor } from "@/components/workspace/code-editor";
|
||||
import { apiFetch } from "@/core/api";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import { useArtifactContent } from "@/core/artifacts/hooks";
|
||||
import {
|
||||
downloadArtifactFile,
|
||||
openArtifactInNewTab,
|
||||
} from "@/core/artifacts/download";
|
||||
import {
|
||||
useArtifactContent,
|
||||
useUpdateArtifactContent,
|
||||
} from "@/core/artifacts/hooks";
|
||||
import { urlOfArtifact } from "@/core/artifacts/utils";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
@@ -113,13 +117,18 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
const { content } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const displayContent = content ?? "";
|
||||
const { content, isApplyingEdit } = useArtifactContent({
|
||||
threadId,
|
||||
filepath: filepathFromProps,
|
||||
enabled: isCodeFile && !isWriteFile,
|
||||
});
|
||||
|
||||
const canEditArtifact =
|
||||
language === "markdown" && !isWriteFile && !isSkillFile && !isMock;
|
||||
const [draftContent, setDraftContent] = useState("");
|
||||
const [hasLocalEdits, setHasLocalEdits] = useState(false);
|
||||
const updateArtifact = useUpdateArtifactContent();
|
||||
const displayContent = canEditArtifact ? draftContent : (content ?? "");
|
||||
const isHtmlStreaming = language === "html" && thread.isLoading;
|
||||
const hasMultipleArtifacts = (artifacts?.length ?? 0) > 1;
|
||||
const fileTypeLabel = useMemo(() => {
|
||||
@@ -139,8 +148,20 @@ export function ArtifactFileDetail({
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | null>(
|
||||
null,
|
||||
);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
const artifactContentRef = useRef<HTMLDivElement | null>(null);
|
||||
const resumeButtonTimeoutRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftContent(content ?? "");
|
||||
setHasLocalEdits(false);
|
||||
}, [filepathFromProps]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canEditArtifact || hasLocalEdits) {
|
||||
return;
|
||||
}
|
||||
setDraftContent(content ?? "");
|
||||
}, [canEditArtifact, content, hasLocalEdits]);
|
||||
|
||||
useEffect(() => {
|
||||
setAllowAutoViewSwitch(true);
|
||||
@@ -308,7 +329,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -328,7 +349,33 @@ export function ArtifactFileDetail({
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
}, [threadId, filepath, isInstalling]);
|
||||
|
||||
const handleSaveArtifact = useCallback(async () => {
|
||||
if (!canEditArtifact || updateArtifact.isPending) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await updateArtifact.mutateAsync({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
content: draftContent,
|
||||
});
|
||||
setHasLocalEdits(false);
|
||||
toast.success("Markdown 已保存");
|
||||
} catch (error) {
|
||||
console.error("Failed to save artifact:", error);
|
||||
toast.error(error instanceof Error ? error.message : "Markdown 保存失败");
|
||||
}
|
||||
}, [
|
||||
canEditArtifact,
|
||||
draftContent,
|
||||
filepath,
|
||||
isMock,
|
||||
threadId,
|
||||
updateArtifact,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
@@ -405,10 +452,19 @@ export function ArtifactFileDetail({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<ArtifactActions className="bg-muted/45 rounded-full border border-black/6 p-1 dark:border-white/8">
|
||||
{canEditArtifact && (
|
||||
<ArtifactAction
|
||||
icon={updateArtifact.isPending ? LoaderIcon : SaveIcon}
|
||||
label="保存 Markdown"
|
||||
tooltip={hasLocalEdits ? "保存 Markdown 修改" : "Markdown 已保存"}
|
||||
disabled={!hasLocalEdits || updateArtifact.isPending}
|
||||
onClick={handleSaveArtifact}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && filepath.endsWith(".skill") && (
|
||||
<Tooltip content={t.toolCalls.skillInstallTooltip}>
|
||||
<ArtifactAction
|
||||
icon={isInstalling ? LoaderIcon : PackageIcon}
|
||||
label={t.common.install}
|
||||
@@ -494,13 +550,19 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
<ArtifactContent
|
||||
ref={artifactContentRef}
|
||||
className="relative p-0"
|
||||
onWheelCapture={handlePauseAutoScroll}
|
||||
>
|
||||
{isApplyingEdit && (
|
||||
<div className="absolute right-4 bottom-4 z-20 inline-flex items-center gap-2 rounded-full border border-[#d8b878] bg-[#fff3cf]/95 px-3 py-1.5 text-xs text-[#6d5625] shadow-sm">
|
||||
<span className="h-3.5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
正在改写 Markdown
|
||||
</div>
|
||||
)}
|
||||
{showResumeAutoScroll && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResumeAutoScroll}
|
||||
className="animate-[sandbox-scroll-button-in_220ms_ease-out] absolute inset-x-0 top-1/2 z-20 mx-auto flex w-fit -translate-y-1/2 items-center gap-2 rounded-full border border-white/35 bg-black/28 px-4 py-2 text-sm font-medium text-white shadow-[0_20px_40px_-24px_rgba(15,23,42,0.7)] backdrop-blur-md transition hover:bg-black/40"
|
||||
@@ -512,21 +574,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
/>
|
||||
)}
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
{isCodeFile && viewMode === "code" && (
|
||||
<div className="size-full">
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<CodeEditor
|
||||
className="size-full resize-none rounded-none border-none bg-transparent"
|
||||
value={displayContent ?? ""}
|
||||
readonly={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!isCodeFile &&
|
||||
(binaryPreviewUrl ? (
|
||||
<iframe className="size-full bg-white" src={binaryPreviewUrl} />
|
||||
@@ -541,15 +613,17 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
}) {
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -580,12 +654,15 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (language === "html") {
|
||||
return (
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
|
||||
import { useSidebar } from "@/components/ui/sidebar";
|
||||
import { useSidebarSafe } from "@/components/ui/sidebar";
|
||||
import { env } from "@/env";
|
||||
|
||||
export interface ArtifactsContextType {
|
||||
@@ -39,19 +39,19 @@ export function ArtifactsProvider({ children }: ArtifactsProviderProps) {
|
||||
env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true",
|
||||
);
|
||||
const [autoOpen, setAutoOpen] = useState(true);
|
||||
const { setOpen: setSidebarOpen } = useSidebar();
|
||||
const sidebar = useSidebarSafe();
|
||||
|
||||
const select = useCallback(
|
||||
(artifact: string, autoSelect = false) => {
|
||||
setSelectedArtifact(artifact);
|
||||
if (env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true") {
|
||||
setSidebarOpen(false);
|
||||
sidebar?.setOpen(false);
|
||||
}
|
||||
if (!autoSelect) {
|
||||
setAutoSelect(false);
|
||||
}
|
||||
},
|
||||
[setSidebarOpen, setSelectedArtifact, setAutoSelect],
|
||||
[sidebar, setSelectedArtifact, setAutoSelect],
|
||||
);
|
||||
|
||||
const deselect = useCallback(() => {
|
||||
|
||||
@@ -38,18 +38,22 @@ export function CodeEditor({
|
||||
className,
|
||||
placeholder,
|
||||
value,
|
||||
onChange,
|
||||
readonly,
|
||||
disabled,
|
||||
autoFocus,
|
||||
settings,
|
||||
streamingReadonlyFallback = true,
|
||||
}: {
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readonly?: boolean;
|
||||
disabled?: boolean;
|
||||
autoFocus?: boolean;
|
||||
settings?: unknown;
|
||||
streamingReadonlyFallback?: boolean;
|
||||
}) {
|
||||
const {
|
||||
thread: { isLoading },
|
||||
@@ -77,7 +81,7 @@ export function CodeEditor({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{isLoading ? (
|
||||
{isLoading && streamingReadonlyFallback ? (
|
||||
<Textarea
|
||||
className={cn(
|
||||
"h-full overflow-auto font-mono [&_.cm-editor]:h-full [&_.cm-focused]:outline-none!",
|
||||
@@ -107,6 +111,7 @@ export function CodeEditor({
|
||||
}}
|
||||
autoFocus={autoFocus}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import type { ChatStatus } from "ai";
|
||||
import {
|
||||
CheckIcon,
|
||||
FileTextIcon,
|
||||
GraduationCapIcon,
|
||||
LightbulbIcon,
|
||||
PaperclipIcon,
|
||||
@@ -80,12 +81,16 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "../ui/dropdown-menu";
|
||||
|
||||
import { useRecommendedQuestions } from "@/core/recommended-questions/hooks";
|
||||
|
||||
|
||||
import { useThread } from "./messages/context";
|
||||
import { ModeHoverGuide } from "./mode-hover-guide";
|
||||
import { Tooltip } from "./tooltip";
|
||||
|
||||
type InputMode = "flash" | "thinking" | "pro" | "ultra";
|
||||
|
||||
|
||||
function getResolvedMode(
|
||||
mode: InputMode | undefined,
|
||||
supportsThinking: boolean,
|
||||
@@ -113,6 +118,7 @@ export function InputBox({
|
||||
onFollowupsVisibilityChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
promptPrefix,
|
||||
...props
|
||||
}: Omit<ComponentProps<typeof PromptInput>, "onSubmit"> & {
|
||||
assistantId?: string | null;
|
||||
@@ -124,6 +130,7 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
};
|
||||
extraHeader?: React.ReactNode;
|
||||
isNewThread?: boolean;
|
||||
@@ -136,11 +143,22 @@ export function InputBox({
|
||||
> & {
|
||||
mode: "flash" | "thinking" | "pro" | "ultra" | undefined;
|
||||
reasoning_effort?: "minimal" | "low" | "medium" | "high";
|
||||
writing_mode?: boolean;
|
||||
},
|
||||
) => void;
|
||||
onFollowupsVisibilityChange?: (visible: boolean) => void;
|
||||
onSubmit?: (message: PromptInputMessage) => void;
|
||||
onStop?: () => void;
|
||||
/**
|
||||
* Optional toggle rendered next to the model selector. Used for the
|
||||
* admin-configurable prompt-prefix switch on the chat input. Omitting the
|
||||
* prop hides the button entirely.
|
||||
*/
|
||||
promptPrefix?: {
|
||||
label: string;
|
||||
on: boolean;
|
||||
onToggle: () => void;
|
||||
};
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -161,6 +179,8 @@ export function InputBox({
|
||||
null,
|
||||
);
|
||||
|
||||
const { questions: recommendedQuestions } = useRecommendedQuestions();
|
||||
|
||||
useEffect(() => {
|
||||
if (models.length === 0) {
|
||||
return;
|
||||
@@ -246,6 +266,13 @@ export function InputBox({
|
||||
[onContextChange, context],
|
||||
);
|
||||
|
||||
const handleWritingModeToggle = useCallback(() => {
|
||||
onContextChange?.({
|
||||
...context,
|
||||
writing_mode: !context.writing_mode,
|
||||
});
|
||||
}, [context, onContextChange]);
|
||||
|
||||
const handleSubmit = useCallback(
|
||||
async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") {
|
||||
@@ -430,7 +457,32 @@ export function InputBox({
|
||||
}, [context.model_name, disabled, isMock, status, thread.messages, threadId]);
|
||||
|
||||
return (
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-4">
|
||||
<div ref={promptRootRef} className="relative flex flex-col gap-6">
|
||||
{extraHeader && (
|
||||
<div className="flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && recommendedQuestions.length > 0 && (
|
||||
<div className="flex flex-wrap justify-center gap-2.5 px-4 mb-[10px]">
|
||||
{recommendedQuestions.map((q) => (
|
||||
<button
|
||||
key={q.id}
|
||||
type="button"
|
||||
className="rounded-full border border-[#e4e4e7] bg-white px-4 py-2 text-sm text-[#18181b] shadow-none transition-colors hover:bg-[#f4f4f5] cursor-pointer dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700"
|
||||
onClick={() => {
|
||||
textInput.setInput(q.content);
|
||||
setFollowupsHidden(true);
|
||||
setTimeout(() => requestFormSubmit(), 0);
|
||||
}}
|
||||
>
|
||||
{q.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showFollowups && (
|
||||
<div className="flex items-center justify-center pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -464,7 +516,7 @@ export function InputBox({
|
||||
)}
|
||||
<PromptInput
|
||||
className={cn(
|
||||
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem]",
|
||||
"rounded-[1.85rem] border border-[#eadfce] bg-white shadow-[0_24px_70px_-34px_rgba(120,83,42,0.32)] ring-1 ring-[#f6efe5] transition-all duration-300 ease-out *:data-[slot='input-group']:rounded-[1.85rem] dark:bg-slate-800 dark:border-slate-700 dark:ring-slate-700 dark:shadow-none",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled}
|
||||
@@ -473,20 +525,13 @@ export function InputBox({
|
||||
onSubmit={handleSubmit}
|
||||
{...props}
|
||||
>
|
||||
{extraHeader && (
|
||||
<div className="absolute top-0 right-0 left-0 z-10">
|
||||
<div className="absolute right-0 bottom-0 left-0 flex items-center justify-center">
|
||||
{extraHeader}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
<PromptInputBody className="absolute top-0 right-0 left-0 z-3">
|
||||
<PromptInputTextarea
|
||||
className={cn(
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a]",
|
||||
"size-full px-2 py-1 text-[15px] leading-7 placeholder:text-[#b7ab9a] dark:placeholder:text-slate-500",
|
||||
)}
|
||||
disabled={disabled}
|
||||
placeholder={t.inputBox.placeholder}
|
||||
@@ -494,7 +539,7 @@ export function InputBox({
|
||||
defaultValue={initialValue}
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] px-2 pt-2.5 pb-1.5">
|
||||
<PromptInputFooter className="flex items-center border-t border-[#f4ede4] dark:border-slate-700 px-2 pt-2.5 pb-1.5">
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{/* TODO: Add more connectors here
|
||||
<PromptInputActionMenu>
|
||||
@@ -505,7 +550,27 @@ export function InputBox({
|
||||
/>
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu> */}
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white" />
|
||||
<AddAttachmentsButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-2.5! shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700" />
|
||||
<Tooltip
|
||||
content={
|
||||
context.writing_mode
|
||||
? "写作模式已开启:最终输出会强制保存为 Markdown 文件"
|
||||
: "开启写作模式:最终输出保存为 Markdown 文件并进入画布"
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
className={cn(
|
||||
"gap-1.5! rounded-full border px-3! shadow-none",
|
||||
context.writing_mode
|
||||
? "border-[#d8b878] bg-[#fff3cf] text-[#6d5625] hover:bg-[#ffedbd]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#7e6a57] hover:bg-white",
|
||||
)}
|
||||
onClick={handleWritingModeToggle}
|
||||
>
|
||||
<FileTextIcon className="size-3" />
|
||||
<span className="text-xs font-normal">写作</span>
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
<PromptInputActionMenu>
|
||||
<ModeHoverGuide
|
||||
mode={
|
||||
@@ -517,7 +582,7 @@ export function InputBox({
|
||||
: "flash"
|
||||
}
|
||||
>
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white">
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<div>
|
||||
{context.mode === "flash" && <ZapIcon className="size-3" />}
|
||||
{context.mode === "thinking" && (
|
||||
@@ -679,7 +744,7 @@ export function InputBox({
|
||||
</PromptInputActionMenu>
|
||||
{supportReasoningEffort && context.mode !== "flash" && (
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white">
|
||||
<PromptInputActionMenuTrigger className="gap-1.5! rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3! text-[#7e6a57] shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<div className="text-xs font-normal">
|
||||
{t.inputBox.reasoningEffort}:
|
||||
{context.reasoning_effort === "minimal" &&
|
||||
@@ -795,12 +860,40 @@ export function InputBox({
|
||||
)}
|
||||
</PromptInputTools>
|
||||
<PromptInputTools className="gap-1.5">
|
||||
{promptPrefix && (
|
||||
<Tooltip
|
||||
content={
|
||||
promptPrefix.on
|
||||
? `已开启:${promptPrefix.label}`
|
||||
: `点击开启:${promptPrefix.label}`
|
||||
}
|
||||
>
|
||||
<PromptInputButton
|
||||
type="button"
|
||||
onClick={promptPrefix.onToggle}
|
||||
aria-pressed={promptPrefix.on}
|
||||
className={cn(
|
||||
"rounded-full border px-3 shadow-none transition-colors",
|
||||
promptPrefix.on
|
||||
? "border-[#d6a868] bg-[#fff3df] text-[#7a5417] hover:bg-[#ffe8c4]"
|
||||
: "border-[#eee3d5] bg-[#fffaf5] text-[#8a7558] hover:bg-white",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-[160px] truncate text-xs font-normal">
|
||||
{promptPrefix.label}
|
||||
</span>
|
||||
{promptPrefix.on && (
|
||||
<CheckIcon className="ml-1 size-3.5 text-[#7a5417]" />
|
||||
)}
|
||||
</PromptInputButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
<ModelSelector
|
||||
open={modelDialogOpen}
|
||||
onOpenChange={setModelDialogOpen}
|
||||
>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white">
|
||||
<PromptInputButton className="rounded-full border border-[#eee3d5] bg-[#fffaf5] px-3 shadow-none hover:bg-white dark:border-slate-600 dark:bg-slate-700/50 dark:text-slate-300 dark:hover:bg-slate-700">
|
||||
<div className="flex min-w-0 flex-col items-start text-left">
|
||||
<ModelSelectorName className="text-xs font-normal">
|
||||
{selectedModel?.display_name}
|
||||
@@ -834,7 +927,7 @@ export function InputBox({
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
<PromptInputSubmit
|
||||
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)]"
|
||||
className="rounded-full border-[#e7d9c7] bg-white shadow-[0_10px_24px_-18px_rgba(120,83,42,0.34)] dark:bg-blue-500 dark:border-blue-500 dark:text-white dark:hover:bg-blue-400 dark:shadow-none"
|
||||
disabled={disabled}
|
||||
variant="outline"
|
||||
status={status}
|
||||
@@ -842,16 +935,10 @@ export function InputBox({
|
||||
</PromptInputTools>
|
||||
</PromptInputFooter>
|
||||
{!isNewThread && (
|
||||
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa]"></div>
|
||||
<div className="absolute right-0 -bottom-[17px] left-0 z-0 h-4 bg-[#fffdfa] dark:bg-background"></div>
|
||||
)}
|
||||
</PromptInput>
|
||||
|
||||
{isNewThread && searchParams.get("mode") !== "skill" && (
|
||||
<div className="flex items-center justify-center pt-2">
|
||||
<SuggestionList />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={confirmOpen} onOpenChange={setConfirmOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
||||
@@ -329,41 +329,43 @@ function ToolCall({
|
||||
)}
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
const url = new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString();
|
||||
if (selectedArtifact === url) {
|
||||
return;
|
||||
}
|
||||
select(url, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
} else if (name === "write_file" || name === "str_replace") {
|
||||
let description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
if (!description) {
|
||||
description = t.toolCalls.writeFile;
|
||||
}
|
||||
const path: string | undefined = (args as { path: string })?.path;
|
||||
const artifactPath =
|
||||
name === "write_file" && path
|
||||
? new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString()
|
||||
: path;
|
||||
if (isLoading && isLast && autoOpen && autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath, true);
|
||||
setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
select(
|
||||
new URL(
|
||||
`write-file:${path}?message_id=${messageId}&tool_call_id=${id}`,
|
||||
).toString(),
|
||||
);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
|
||||
@@ -31,7 +31,10 @@ import { StreamingIndicator } from "../streaming-indicator";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageGroup } from "./message-group";
|
||||
import { MessageListItem } from "./message-list-item";
|
||||
import { MessageTokenUsageList } from "./message-token-usage";
|
||||
import {
|
||||
MessageMetricsProvider,
|
||||
MessageTokenUsageList,
|
||||
} from "./message-token-usage";
|
||||
import { MessageListSkeleton } from "./skeleton";
|
||||
import { SubtaskCard } from "./subtask-card";
|
||||
|
||||
@@ -68,7 +71,8 @@ export function MessageList({
|
||||
<Conversation
|
||||
className={cn("flex size-full flex-col justify-center", className)}
|
||||
>
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-md) gap-8 pt-12">
|
||||
<ConversationContent className="mx-auto w-full max-w-(--container-width-lg) gap-8 pt-12">
|
||||
<MessageMetricsProvider messages={messages} isLoading={thread.isLoading}>
|
||||
{groupMessages(messages, (group) => {
|
||||
if (group.type === "human" || group.type === "assistant") {
|
||||
return group.messages.map((msg) => {
|
||||
@@ -265,6 +269,7 @@ export function MessageList({
|
||||
})}
|
||||
{thread.isLoading && <StreamingIndicator className="my-4" />}
|
||||
<div style={{ height: `${paddingBottom}px` }} />
|
||||
</MessageMetricsProvider>
|
||||
</ConversationContent>
|
||||
</Conversation>
|
||||
);
|
||||
@@ -353,9 +358,9 @@ function SetupAgentApprovalCard({
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="text-muted-foreground text-xs font-medium">
|
||||
创建专家审批
|
||||
创建智能体审批
|
||||
</div>
|
||||
<div className="text-sm font-medium">请确认是否写入这个专家配置。</div>
|
||||
<div className="text-sm font-medium">请确认是否写入这个智能体配置。</div>
|
||||
</div>
|
||||
|
||||
{approvalPayload.name ? (
|
||||
@@ -454,6 +459,7 @@ function ClarificationCard({
|
||||
const payload = getClarificationPayload(message);
|
||||
const [customAnswer, setCustomAnswer] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
@@ -473,16 +479,17 @@ function ClarificationCard({
|
||||
);
|
||||
}
|
||||
|
||||
const disabled = isLoading || submitting || !onSubmit;
|
||||
const disabled = false;
|
||||
|
||||
async function submitAnswer(answer: string) {
|
||||
const trimmed = answer.trim();
|
||||
if (!trimmed || disabled) {
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSubmit?.(trimmed);
|
||||
setSubmitted(true);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -1,28 +1,98 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { CoinsIcon } from "lucide-react";
|
||||
import { CoinsIcon, GaugeIcon } from "lucide-react";
|
||||
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { formatTokenCount, getUsageMetadata } from "@/core/messages/usage";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import {
|
||||
MessageMetricsContext,
|
||||
useMessageMetric,
|
||||
useMessageMetrics,
|
||||
type MessageMetric,
|
||||
} from "./use-message-metrics";
|
||||
|
||||
const FULL_SCALE_TOKENS_PER_SEC = 80;
|
||||
|
||||
function classifySpeed(tps: number): { color: string; label: string } {
|
||||
if (tps >= 50) return { color: "#16a34a", label: "极快" };
|
||||
if (tps >= 30) return { color: "#65a30d", label: "流畅" };
|
||||
if (tps >= 15) return { color: "#eab308", label: "一般" };
|
||||
if (tps >= 5) return { color: "#f97316", label: "偏慢" };
|
||||
return { color: "#ef4444", label: "缓慢" };
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
if (ms < 1000) return `${Math.round(ms)} ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)} s`;
|
||||
const mins = Math.floor(ms / 60_000);
|
||||
const secs = Math.round((ms % 60_000) / 1000);
|
||||
return `${mins}m ${secs}s`;
|
||||
}
|
||||
|
||||
function SpeedPill({ metric }: { metric: MessageMetric }) {
|
||||
const fill = Math.max(0, Math.min(1, metric.tokensPerSec / FULL_SCALE_TOKENS_PER_SEC));
|
||||
const { color, label } = classifySpeed(metric.tokensPerSec);
|
||||
const display =
|
||||
metric.tokensPerSec >= 100
|
||||
? Math.round(metric.tokensPerSec).toString()
|
||||
: metric.tokensPerSec.toFixed(1);
|
||||
const prefix = metric.fromUsage ? "" : "~";
|
||||
|
||||
return (
|
||||
<span
|
||||
className="inline-flex items-center gap-1.5"
|
||||
title={`${label} · ${metric.fromUsage ? "按 output_tokens 计算" : "字符估算 ≈ 2 字符 / token"}`}
|
||||
>
|
||||
<GaugeIcon className="size-3" style={{ color }} />
|
||||
<span className="font-mono tabular-nums" style={{ color }}>
|
||||
{prefix}
|
||||
{display}
|
||||
</span>
|
||||
<span className="text-muted-foreground">tok/s</span>
|
||||
<span className="relative inline-block h-1 w-12 overflow-hidden rounded-full bg-current/15 align-middle">
|
||||
<span
|
||||
className="absolute inset-y-0 left-0 rounded-full transition-[width,background-color] duration-200 ease-out"
|
||||
style={{ width: `${fill * 100}%`, backgroundColor: color }}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageTokenUsage({
|
||||
className,
|
||||
enabled = false,
|
||||
isLoading = false,
|
||||
message,
|
||||
metric,
|
||||
}: {
|
||||
className?: string;
|
||||
enabled?: boolean;
|
||||
isLoading?: boolean;
|
||||
message: Message;
|
||||
metric?: MessageMetric;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [settings] = useLocalSettings();
|
||||
const speedEnabled = settings.display.streamSpeedMeterEnabled;
|
||||
const contextMetric = useMessageMetric(message.id as string | undefined);
|
||||
const resolvedMetric = metric ?? contextMetric;
|
||||
|
||||
if (!enabled || isLoading || message.type !== "ai") {
|
||||
if (message.type !== "ai") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Show speed-only line while streaming (no usage yet) if the user opted in,
|
||||
// otherwise wait until usage arrives so the bar doesn't render on its own.
|
||||
const usage = getUsageMetadata(message);
|
||||
const showUsage = enabled && !isLoading && Boolean(usage);
|
||||
const showSpeed = speedEnabled && resolvedMetric !== undefined && resolvedMetric.durationMs > 0;
|
||||
|
||||
if (!showUsage && !showSpeed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -31,12 +101,12 @@ export function MessageTokenUsage({
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<CoinsIcon className="size-3" />
|
||||
{t.tokenUsage.label}
|
||||
</span>
|
||||
{usage ? (
|
||||
{showUsage && usage ? (
|
||||
<>
|
||||
<span className="inline-flex items-center gap-1 font-medium">
|
||||
<CoinsIcon className="size-3" />
|
||||
{t.tokenUsage.label}
|
||||
</span>
|
||||
<span>
|
||||
{t.tokenUsage.input}: {formatTokenCount(usage.inputTokens)}
|
||||
</span>
|
||||
@@ -47,9 +117,17 @@ export function MessageTokenUsage({
|
||||
{t.tokenUsage.total}: {formatTokenCount(usage.totalTokens)}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>{t.tokenUsage.unavailableShort}</span>
|
||||
)}
|
||||
) : null}
|
||||
{showSpeed && resolvedMetric ? (
|
||||
<>
|
||||
<SpeedPill metric={resolvedMetric} />
|
||||
{resolvedMetric.durationMs > 0 && (
|
||||
<span className="font-mono tabular-nums">
|
||||
{formatDuration(resolvedMetric.durationMs)}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -65,12 +143,14 @@ export function MessageTokenUsageList({
|
||||
isLoading?: boolean;
|
||||
messages: Message[];
|
||||
}) {
|
||||
if (!enabled || isLoading) {
|
||||
const [settings] = useLocalSettings();
|
||||
const speedEnabled = settings.display.streamSpeedMeterEnabled;
|
||||
|
||||
if (!enabled && !speedEnabled) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const aiMessages = messages.filter((message) => message.type === "ai");
|
||||
|
||||
if (aiMessages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
@@ -89,3 +169,25 @@ export function MessageTokenUsageList({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hoist the metrics map computation to the top of the message list so
|
||||
* descendant {@link MessageTokenUsage} components can read it via context
|
||||
* instead of each running its own hook.
|
||||
*/
|
||||
export function MessageMetricsProvider({
|
||||
messages,
|
||||
isLoading,
|
||||
children,
|
||||
}: {
|
||||
messages: Message[];
|
||||
isLoading: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const metrics = useMessageMetrics(messages, isLoading);
|
||||
return (
|
||||
<MessageMetricsContext.Provider value={metrics}>
|
||||
{children}
|
||||
</MessageMetricsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -24,7 +24,7 @@ function SkeletonBar({
|
||||
export function MessageListSkeleton() {
|
||||
let index = 0;
|
||||
return (
|
||||
<div className="flex w-full max-w-(--container-width-md) flex-col gap-12 p-8 pt-16">
|
||||
<div className="flex w-full max-w-(--container-width-lg) flex-col gap-12 p-8 pt-16">
|
||||
<div
|
||||
role="human-message"
|
||||
className="flex w-[50%] flex-col items-end gap-2 self-end"
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
"use client";
|
||||
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { extractReasoningContentFromMessage } from "@/core/messages/utils";
|
||||
import { getUsageMetadata } from "@/core/messages/usage";
|
||||
import { textOfMessage } from "@/core/threads/utils";
|
||||
|
||||
/**
|
||||
* Two characters ≈ one token. Same heuristic we use elsewhere; gives a stable
|
||||
* speed indicator for mixed Chinese/English replies without dragging in a
|
||||
* real tokeniser.
|
||||
*/
|
||||
const CHARS_PER_TOKEN = 2;
|
||||
|
||||
/** Sample throttle while a message is streaming. */
|
||||
const SAMPLE_THROTTLE_MS = 60;
|
||||
|
||||
/**
|
||||
* Per-AI-message metric: real-time during streaming, frozen once the run ends.
|
||||
*/
|
||||
export interface MessageMetric {
|
||||
/** Rolling tokens/sec, or 0 when we cannot estimate yet. */
|
||||
tokensPerSec: number;
|
||||
/**
|
||||
* Wall-clock duration from when the message first appeared with text to
|
||||
* either *now* (active) or to when the stream ended (frozen).
|
||||
*/
|
||||
durationMs: number;
|
||||
/** True while this is the currently streaming message. */
|
||||
isActive: boolean;
|
||||
/**
|
||||
* Whether the speed value was computed from real usage_metadata output
|
||||
* tokens (``true``) vs. a char-count fallback (``false``). UI uses this
|
||||
* only to add a "~" prefix when the value is approximate.
|
||||
*/
|
||||
fromUsage: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
|
||||
type TimingEntry = {
|
||||
firstSeenAt: number;
|
||||
endedAt: number | null;
|
||||
samples: Sample[];
|
||||
lastChars: number;
|
||||
/** True iff we *have already* observed `usage_metadata.output_tokens`. */
|
||||
hadUsage: boolean;
|
||||
};
|
||||
|
||||
function isAiMessage(message: Message): boolean {
|
||||
return message.type === "ai";
|
||||
}
|
||||
|
||||
function getMessageId(message: Message, fallbackIndex: number): string {
|
||||
const id = (message.id as string | undefined) ?? undefined;
|
||||
return id ?? `idx-${fallbackIndex}`;
|
||||
}
|
||||
|
||||
function messageText(message: Message): string {
|
||||
// Count both the visible answer text *and* the reasoning trace so the speed
|
||||
// indicator stays meaningful during the thinking phase — for reasoning
|
||||
// models the answer body can be empty for tens of seconds while the model
|
||||
// streams reasoning tokens, and ignoring those would make the meter sit at
|
||||
// zero. We treat the two as one continuous stream of generated characters.
|
||||
const body = textOfMessage(message);
|
||||
const reasoning = extractReasoningContentFromMessage(message) ?? "";
|
||||
const bodyText = typeof body === "string" ? body : "";
|
||||
return bodyText + reasoning;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a Map keyed by AI message id → live metric snapshot.
|
||||
*
|
||||
* Recording strategy:
|
||||
* - Whenever a message renders with text, we anchor `firstSeenAt`.
|
||||
* - On every text-length change we push a sample and recompute the rate.
|
||||
* - When `isLoading` flips to false we mark *all* still-active messages as
|
||||
* ended so subsequent renders return a frozen duration.
|
||||
* - Token totals come from usage_metadata when present (most accurate);
|
||||
* otherwise we fall back to char-count / 2.
|
||||
*/
|
||||
export function useMessageMetrics(
|
||||
messages: Message[],
|
||||
isLoading: boolean,
|
||||
): Map<string, MessageMetric> {
|
||||
const timingRef = useRef<Map<string, TimingEntry>>(new Map());
|
||||
const lastSampleAtRef = useRef(0);
|
||||
// Bump a counter to force recomputation when samples push but message refs
|
||||
// didn't change — needed because the timing map mutation alone wouldn't
|
||||
// re-render anything.
|
||||
const [, setTick] = useState(0);
|
||||
const lastActiveIdRef = useRef<string | null>(null);
|
||||
|
||||
// Walk messages and update timings. We intentionally read/write the ref
|
||||
// synchronously inside an effect so React isn't tracking the map identity.
|
||||
useEffect(() => {
|
||||
const now = performance.now();
|
||||
let activeId: string | null = null;
|
||||
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message || !isAiMessage(message)) continue;
|
||||
const id = getMessageId(message, i);
|
||||
const text = messageText(message);
|
||||
if (!text) continue;
|
||||
|
||||
let entry = timingRef.current.get(id);
|
||||
if (!entry) {
|
||||
entry = {
|
||||
firstSeenAt: now,
|
||||
endedAt: null,
|
||||
samples: [{ at: now, chars: text.length }],
|
||||
lastChars: text.length,
|
||||
hadUsage: false,
|
||||
};
|
||||
timingRef.current.set(id, entry);
|
||||
}
|
||||
|
||||
// Track the most recent AI message as the active one (while streaming).
|
||||
activeId = id;
|
||||
|
||||
// Sample only when actively streaming OR when content actually grew —
|
||||
// throttle to keep arrays bounded.
|
||||
const grew = text.length !== entry.lastChars;
|
||||
if (grew && now - lastSampleAtRef.current >= SAMPLE_THROTTLE_MS) {
|
||||
entry.samples.push({ at: now, chars: text.length });
|
||||
entry.lastChars = text.length;
|
||||
if (entry.samples.length > 256) {
|
||||
entry.samples.splice(0, entry.samples.length - 256);
|
||||
}
|
||||
lastSampleAtRef.current = now;
|
||||
} else if (grew) {
|
||||
// Still update lastChars so the next render captures it eventually.
|
||||
entry.lastChars = text.length;
|
||||
}
|
||||
|
||||
if (getUsageMetadata(message)) {
|
||||
entry.hadUsage = true;
|
||||
}
|
||||
}
|
||||
|
||||
// When streaming stops, freeze any non-ended entry to the current time.
|
||||
if (!isLoading) {
|
||||
for (const entry of timingRef.current.values()) {
|
||||
if (entry.endedAt === null) {
|
||||
entry.endedAt = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
lastActiveIdRef.current = activeId;
|
||||
setTick((v) => v + 1);
|
||||
}, [messages, isLoading]);
|
||||
|
||||
// Drive a slow tick while streaming so live durations refresh smoothly.
|
||||
useEffect(() => {
|
||||
if (!isLoading) return;
|
||||
const interval = window.setInterval(() => setTick((v) => v + 1), 300);
|
||||
return () => window.clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
// Build the snapshot map. We compute on every render so consumers see the
|
||||
// freshest duration; the underlying timing storage is the source of truth.
|
||||
const out = new Map<string, MessageMetric>();
|
||||
const now = performance.now();
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message || !isAiMessage(message)) continue;
|
||||
const id = getMessageId(message, i);
|
||||
const entry = timingRef.current.get(id);
|
||||
if (!entry) continue;
|
||||
|
||||
const endedAt = entry.endedAt ?? now;
|
||||
const durationMs = Math.max(0, endedAt - entry.firstSeenAt);
|
||||
const isActive = isLoading && id === lastActiveIdRef.current;
|
||||
|
||||
let tokensPerSec = 0;
|
||||
let fromUsage = false;
|
||||
const usage = getUsageMetadata(message);
|
||||
if (usage && usage.outputTokens > 0 && durationMs > 0) {
|
||||
tokensPerSec = (usage.outputTokens / durationMs) * 1000;
|
||||
fromUsage = true;
|
||||
} else {
|
||||
// Char-count fallback. Uses the rolling window during streaming so a
|
||||
// mid-stream snapshot reflects current speed rather than average since
|
||||
// the start.
|
||||
if (isActive && entry.samples.length >= 2) {
|
||||
const samples = entry.samples;
|
||||
let start = samples.findIndex((s) => s.at >= now - 1500);
|
||||
if (start === -1) start = samples.length - 2;
|
||||
if (start < 0) start = 0;
|
||||
const head = samples[start];
|
||||
const tail = samples[samples.length - 1];
|
||||
if (head && tail) {
|
||||
const elapsedMs = tail.at - head.at;
|
||||
const chars = Math.max(0, tail.chars - head.chars);
|
||||
if (elapsedMs > 0) {
|
||||
tokensPerSec = (chars / elapsedMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
} else if (durationMs > 0) {
|
||||
// Finished message without usage data: average over full duration.
|
||||
const chars = entry.lastChars;
|
||||
tokensPerSec = (chars / durationMs) * 1000 / CHARS_PER_TOKEN;
|
||||
}
|
||||
}
|
||||
|
||||
out.set(id, {
|
||||
tokensPerSec,
|
||||
durationMs,
|
||||
isActive,
|
||||
fromUsage,
|
||||
});
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
const EMPTY_METRICS: Map<string, MessageMetric> = new Map();
|
||||
|
||||
/**
|
||||
* Context for sharing the metrics map computed by ``useMessageMetrics`` from
|
||||
* MessageList down to per-message renderers like MessageListItem, without
|
||||
* threading a prop through every grouping branch.
|
||||
*/
|
||||
export const MessageMetricsContext = createContext<Map<string, MessageMetric>>(
|
||||
EMPTY_METRICS,
|
||||
);
|
||||
|
||||
export function useMessageMetric(messageId: string | undefined): MessageMetric | undefined {
|
||||
const metrics = useContext(MessageMetricsContext);
|
||||
return messageId ? metrics.get(messageId) : undefined;
|
||||
}
|
||||
@@ -0,0 +1,329 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CheckCheckIcon,
|
||||
ChevronLeftIcon,
|
||||
Loader2,
|
||||
SettingsIcon,
|
||||
Trash2Icon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import {
|
||||
useDeleteNotification,
|
||||
useMarkAllNotificationsRead,
|
||||
useMarkNotificationRead,
|
||||
useNotifications,
|
||||
useUnreadCount,
|
||||
} from "@/core/notifications";
|
||||
import type { NotificationItem } from "@/core/notifications";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { formatTimeAgo } from "@/core/utils/datetime";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const BELL_LIMIT = 20;
|
||||
|
||||
type NotificationView = "list" | "settings";
|
||||
|
||||
export function NotificationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const [view, setView] = useState<NotificationView>("list");
|
||||
|
||||
const {
|
||||
data: notifications,
|
||||
isLoading,
|
||||
isFetching,
|
||||
} = useNotifications({ limit: BELL_LIMIT });
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
const markRead = useMarkNotificationRead();
|
||||
const markAllRead = useMarkAllNotificationsRead();
|
||||
const removeNotification = useDeleteNotification();
|
||||
|
||||
const hasUnread = unreadCount > 0;
|
||||
const items = notifications ?? [];
|
||||
|
||||
function handleOpenChange(next: boolean) {
|
||||
onOpenChange(next);
|
||||
// 关闭弹窗时回到列表视图,下次打开默认看到通知列表。
|
||||
if (!next) setView("list");
|
||||
}
|
||||
|
||||
async function handleClickItem(notification: NotificationItem) {
|
||||
if (notification.read) return;
|
||||
try {
|
||||
await markRead.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleMarkAllRead() {
|
||||
if (!hasUnread) return;
|
||||
try {
|
||||
await markAllRead.mutateAsync();
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(notification: NotificationItem) {
|
||||
try {
|
||||
await removeNotification.mutateAsync(notification.id);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex max-h-[80vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
{view === "list" ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellIcon className="size-4" />
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.title}
|
||||
</DialogTitle>
|
||||
{isFetching && !isLoading && (
|
||||
<Loader2 className="text-muted-foreground size-3 animate-spin" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="h-7 px-2 text-xs"
|
||||
disabled={!hasUnread || markAllRead.isPending}
|
||||
onClick={() => void handleMarkAllRead()}
|
||||
>
|
||||
<CheckCheckIcon className="mr-1 size-3.5" />
|
||||
{t.notifications.markAllRead}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.settingsAria}
|
||||
onClick={() => setView("settings")}
|
||||
>
|
||||
<SettingsIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="min-h-0 flex-1">
|
||||
<div className="w-full px-3 py-3">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-20 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : items.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-24 text-center">
|
||||
<div className="bg-muted flex size-14 items-center justify-center rounded-full">
|
||||
<BellIcon className="text-muted-foreground size-6" />
|
||||
</div>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.notifications.empty}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{items.map((notification) => (
|
||||
<li
|
||||
key={notification.id}
|
||||
className={cn(
|
||||
"group cursor-pointer rounded-lg border px-4 py-3 transition-colors hover:bg-accent/50",
|
||||
!notification.read
|
||||
? "border-blue-500/30 bg-blue-500/5"
|
||||
: "border-border",
|
||||
)}
|
||||
onClick={() => void handleClickItem(notification)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<span
|
||||
className={cn(
|
||||
"mt-1.5 size-2 shrink-0 rounded-full",
|
||||
notification.read ? "bg-transparent" : "bg-blue-500",
|
||||
)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline justify-between gap-3">
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-sm",
|
||||
notification.read
|
||||
? "font-medium"
|
||||
: "font-semibold",
|
||||
)}
|
||||
title={notification.title}
|
||||
>
|
||||
{notification.title || notification.type}
|
||||
</p>
|
||||
{notification.created_at && (
|
||||
<span className="text-muted-foreground shrink-0 text-xs">
|
||||
{formatTimeAgo(notification.created_at)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{notification.body && (
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-xs leading-relaxed">
|
||||
{notification.body}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-destructive size-7 shrink-0 opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
void handleDelete(notification);
|
||||
}}
|
||||
aria-label={t.notifications.deleteAria}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</>
|
||||
) : (
|
||||
<NotificationSettingsView onBack={() => setView("list")} />
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function NotificationSettingsView({ onBack }: { onBack: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const { permission, isSupported, requestPermission, showNotification } =
|
||||
useNotification();
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestPermission();
|
||||
};
|
||||
|
||||
const handleTestNotification = () => {
|
||||
showNotification(t.settings.notification.testTitle, {
|
||||
body: t.settings.notification.testBody,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnableNotification = (enabled: boolean) => {
|
||||
setSettings("notification", { enabled });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-2 border-b px-3 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.notifications.backAria}
|
||||
onClick={onBack}
|
||||
>
|
||||
<ChevronLeftIcon className="size-4" />
|
||||
</Button>
|
||||
<DialogTitle className="text-base font-semibold">
|
||||
{t.notifications.settingsTitle}
|
||||
</DialogTitle>
|
||||
</div>
|
||||
<DialogClose asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground size-7"
|
||||
aria-label={t.common.close}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</div>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col gap-4 px-3 py-6">
|
||||
{!isSupported ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.notSupported}
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.description}
|
||||
</p>
|
||||
<Switch
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
}
|
||||
onCheckedChange={handleEnableNotification}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{permission === "default" && (
|
||||
<Button onClick={handleRequestPermission} variant="default">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.requestPermission}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permission === "denied" && (
|
||||
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
|
||||
{t.settings.notification.deniedHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === "granted" && settings.notification.enabled && (
|
||||
<Button onClick={handleTestNotification} variant="outline">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.testButton}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -173,91 +173,89 @@ export function RecentChatList() {
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
{visibleThreads.map((thread) => {
|
||||
const isActive = pathOfThread(thread) === pathname;
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<div>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleShare(thread)}
|
||||
>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "markdown")
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "json")
|
||||
}
|
||||
>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<SidebarMenuItem
|
||||
key={thread.thread_id}
|
||||
className="group/side-menu-item"
|
||||
>
|
||||
<SidebarMenuButton isActive={isActive} asChild>
|
||||
<Link
|
||||
className="text-muted-foreground block w-full whitespace-nowrap group-hover/side-menu-item:overflow-hidden"
|
||||
href={pathOfThread(thread)}
|
||||
>
|
||||
{titleOfThread(thread)}
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
{env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY !== "true" && (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<SidebarMenuAction
|
||||
showOnHover
|
||||
className="bg-background/50 hover:bg-background"
|
||||
>
|
||||
<MoreHorizontal />
|
||||
<span className="sr-only">{t.common.more}</span>
|
||||
</SidebarMenuAction>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="w-48 rounded-lg"
|
||||
side={"right"}
|
||||
align={"start"}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleRenameClick(
|
||||
thread.thread_id,
|
||||
titleOfThread(thread),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Pencil className="text-muted-foreground" />
|
||||
<span>{t.common.rename}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleShare(thread)}
|
||||
>
|
||||
<Share2 className="text-muted-foreground" />
|
||||
<span>{t.common.share}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSub>
|
||||
<DropdownMenuSubTrigger>
|
||||
<Download className="text-muted-foreground" />
|
||||
<span>{t.common.export}</span>
|
||||
</DropdownMenuSubTrigger>
|
||||
<DropdownMenuSubContent>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "markdown")
|
||||
}
|
||||
>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() =>
|
||||
handleExport(thread, "json")
|
||||
}
|
||||
>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuSubContent>
|
||||
</DropdownMenuSub>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={() => handleDelete(thread.thread_id)}
|
||||
>
|
||||
<Trash2 className="text-muted-foreground" />
|
||||
<span>{t.common.delete}</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)}
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { CopyIcon, LogOutIcon, UserIcon } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clearStoredAuth, getStoredAuth } from "@/core/auth";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
function roleLabel(role?: string) {
|
||||
if (role === "admin") return "管理员";
|
||||
if (role === "user") return "普通用户";
|
||||
return role || "未知角色";
|
||||
}
|
||||
|
||||
function copy(text: string, hint = "已复制") {
|
||||
if (!text) return;
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => toast.success(hint))
|
||||
.catch(() => toast.error("复制失败"));
|
||||
}
|
||||
|
||||
export function AccountSettingsPage() {
|
||||
const auth = useMemo(() => getStoredAuth(), []);
|
||||
|
||||
if (!auth) {
|
||||
return (
|
||||
<SettingsSection title="个人信息" description="当前未登录或登录信息无法读取。">
|
||||
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
|
||||
没有可显示的账号信息。
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
const usernameFromEmail = (auth.email ?? "").split("@")[0] ?? "";
|
||||
const displayName = usernameFromEmail || auth.user_id;
|
||||
|
||||
const handleLogout = () => {
|
||||
clearStoredAuth();
|
||||
// Match the rest of the app's logout flow: blow away the cookie session
|
||||
// by hitting the gateway logout endpoint and then forcing a full reload
|
||||
// so any cached query state goes too.
|
||||
void fetch("/api/v1/auth/logout", { method: "POST", credentials: "include" })
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
window.location.assign("/");
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection title="个人信息" description="当前登录账号的基本信息。">
|
||||
<div className="flex flex-col gap-4 rounded-xl border bg-white p-5">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="bg-primary/10 text-primary flex size-10 items-center justify-center rounded-full">
|
||||
<UserIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium">{displayName}</div>
|
||||
<div className="text-muted-foreground truncate text-xs">
|
||||
{roleLabel(auth.system_role)}
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant={auth.system_role === "admin" ? "default" : "secondary"}>
|
||||
{roleLabel(auth.system_role)}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<InfoRow
|
||||
label="用户名"
|
||||
value={displayName}
|
||||
onCopy={() => copy(displayName, "用户名已复制")}
|
||||
/>
|
||||
<InfoRow
|
||||
label="用户 ID"
|
||||
value={auth.user_id}
|
||||
onCopy={() => copy(auth.user_id, "用户 ID 已复制")}
|
||||
/>
|
||||
<InfoRow label="账号角色" value={roleLabel(auth.system_role)} />
|
||||
<InfoRow
|
||||
label="账号状态"
|
||||
value={auth.needs_setup ? "待初始化" : "已就绪"}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end pt-2">
|
||||
<Button variant="outline" size="sm" onClick={handleLogout}>
|
||||
<LogOutIcon className="size-4" />
|
||||
退出登录
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({
|
||||
label,
|
||||
value,
|
||||
onCopy,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onCopy?: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-t pt-3 first:border-t-0 first:pt-0">
|
||||
<div className="text-muted-foreground w-20 shrink-0 text-xs">{label}</div>
|
||||
<div className="min-w-0 flex-1 truncate text-sm">{value || "—"}</div>
|
||||
{onCopy && value && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-7"
|
||||
onClick={onCopy}
|
||||
aria-label={`复制${label}`}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,8 +12,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { enUS, isLocale, zhCN, type Locale } from "@/core/i18n";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
@@ -27,6 +29,7 @@ export function AppearanceSettingsPage() {
|
||||
const { t, locale, changeLocale } = useI18n();
|
||||
const { theme, setTheme, systemTheme } = useTheme();
|
||||
const currentTheme = (theme ?? "system") as "system" | "light" | "dark";
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const themeOptions = useMemo(
|
||||
() => [
|
||||
@@ -107,6 +110,30 @@ export function AppearanceSettingsPage() {
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</SettingsSection>
|
||||
|
||||
<Separator />
|
||||
|
||||
<SettingsSection
|
||||
title="对话体验"
|
||||
description="问答过程中的辅助显示。"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4 rounded-lg border p-4">
|
||||
<div>
|
||||
<div className="text-sm font-medium">回答速度指示器</div>
|
||||
<p className="text-muted-foreground mt-1 max-w-md text-xs leading-relaxed">
|
||||
在生成回答时显示一个小胶囊,实时估算当前 token/s
|
||||
并用绿到红的渐变条直观反映快慢。默认关闭。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.display.streamSpeedMeterEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSettings("display", { streamSpeedMeterEnabled: checked })
|
||||
}
|
||||
aria-label="回答速度指示器"
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { useUpsertMCPServer } from "@/core/mcp/hooks";
|
||||
import type {
|
||||
MCPServerConfig,
|
||||
MCPServerTransport,
|
||||
} from "@/core/mcp/types";
|
||||
|
||||
type EditMode = "create" | "edit";
|
||||
|
||||
export interface MCPServerEditDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: EditMode;
|
||||
originalName?: string;
|
||||
initialServer?: MCPServerConfig;
|
||||
existingNames: string[];
|
||||
}
|
||||
|
||||
const TRANSPORT_OPTIONS: { value: MCPServerTransport; label: string }[] = [
|
||||
{ value: "stdio", label: "stdio(本地进程)" },
|
||||
{ value: "http", label: "http(远端 HTTP)" },
|
||||
{ value: "sse", label: "sse(远端 SSE)" },
|
||||
];
|
||||
|
||||
const DEFAULT_SERVER: MCPServerConfig = {
|
||||
enabled: true,
|
||||
type: "stdio",
|
||||
command: "",
|
||||
args: [],
|
||||
env: {},
|
||||
url: "",
|
||||
headers: {},
|
||||
description: "",
|
||||
disabled_tools: [],
|
||||
};
|
||||
|
||||
function argsToText(args?: string[]): string {
|
||||
return (args ?? []).join("\n");
|
||||
}
|
||||
|
||||
function textToArgs(text: string): string[] {
|
||||
return text
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function kvToText(map?: Record<string, string>): string {
|
||||
if (!map) return "";
|
||||
return Object.entries(map)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function textToKV(text: string): { value: Record<string, string>; error?: string } {
|
||||
const result: Record<string, string> = {};
|
||||
const lines = text.split("\n");
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const eq = line.indexOf("=");
|
||||
if (eq <= 0) {
|
||||
return { value: {}, error: `每行需为 KEY=VALUE 格式:${line}` };
|
||||
}
|
||||
const key = line.slice(0, eq).trim();
|
||||
const value = line.slice(eq + 1);
|
||||
if (!key) {
|
||||
return { value: {}, error: `键名不能为空:${line}` };
|
||||
}
|
||||
result[key] = value;
|
||||
}
|
||||
return { value: result };
|
||||
}
|
||||
|
||||
export function MCPServerEditDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
originalName,
|
||||
initialServer,
|
||||
existingNames,
|
||||
}: MCPServerEditDialogProps) {
|
||||
const upsert = useUpsertMCPServer();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [type, setType] = useState<MCPServerTransport>("stdio");
|
||||
const [enabled, setEnabled] = useState(true);
|
||||
const [description, setDescription] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
const [argsText, setArgsText] = useState("");
|
||||
const [envText, setEnvText] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [headersText, setHeadersText] = useState("");
|
||||
const [disabledToolsText, setDisabledToolsText] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const base = initialServer ?? DEFAULT_SERVER;
|
||||
setName(originalName ?? "");
|
||||
setType((base.type ?? "stdio") as MCPServerTransport);
|
||||
setEnabled(base.enabled);
|
||||
setDescription(base.description ?? "");
|
||||
setCommand(base.command ?? "");
|
||||
setArgsText(argsToText(base.args));
|
||||
setEnvText(kvToText(base.env));
|
||||
setUrl(base.url ?? "");
|
||||
setHeadersText(kvToText(base.headers));
|
||||
setDisabledToolsText((base.disabled_tools ?? []).join("\n"));
|
||||
}, [open, initialServer, originalName]);
|
||||
|
||||
const nameConflict = useMemo(() => {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return false;
|
||||
if (mode === "edit" && trimmed === originalName) return false;
|
||||
return existingNames.includes(trimmed);
|
||||
}, [name, existingNames, mode, originalName]);
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
toast.error("请填写 MCP 服务器名称");
|
||||
return;
|
||||
}
|
||||
if (nameConflict) {
|
||||
toast.error(`已存在同名 MCP 服务器:${trimmedName}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const env = textToKV(envText);
|
||||
if (env.error) {
|
||||
toast.error(`环境变量格式错误:${env.error}`);
|
||||
return;
|
||||
}
|
||||
const headers = textToKV(headersText);
|
||||
if (headers.error) {
|
||||
toast.error(`Headers 格式错误:${headers.error}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const args = textToArgs(argsText);
|
||||
const disabledTools = textToArgs(disabledToolsText);
|
||||
|
||||
if (type === "stdio" && !command.trim()) {
|
||||
toast.error("stdio 传输需要填写启动命令");
|
||||
return;
|
||||
}
|
||||
if ((type === "http" || type === "sse") && !url.trim()) {
|
||||
toast.error(`${type} 传输需要填写服务器 URL`);
|
||||
return;
|
||||
}
|
||||
|
||||
const server: MCPServerConfig = {
|
||||
enabled,
|
||||
type,
|
||||
description: description.trim(),
|
||||
command: type === "stdio" ? command.trim() : null,
|
||||
args: type === "stdio" ? args : [],
|
||||
env: type === "stdio" ? env.value : {},
|
||||
url: type === "stdio" ? null : url.trim(),
|
||||
headers: type === "stdio" ? {} : headers.value,
|
||||
disabled_tools: disabledTools,
|
||||
};
|
||||
|
||||
try {
|
||||
await upsert.mutateAsync({
|
||||
serverName: trimmedName,
|
||||
server,
|
||||
originalName: mode === "edit" ? originalName : undefined,
|
||||
});
|
||||
toast.success(mode === "create" ? "MCP 服务器已创建" : "MCP 服务器已更新");
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-h-[85vh] overflow-y-auto sm:max-w-2xl"
|
||||
aria-describedby={undefined}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{mode === "create" ? "新增 MCP 服务器" : `编辑 MCP 服务器`}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex flex-col gap-4 py-2">
|
||||
<Field label="名称" hint="作为 MCP 服务器的唯一标识,用于工具命名前缀">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="例如 github、filesystem"
|
||||
aria-invalid={nameConflict || undefined}
|
||||
/>
|
||||
{nameConflict && (
|
||||
<div className="text-destructive text-xs">已存在同名服务器</div>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="传输类型">
|
||||
<Select
|
||||
value={type}
|
||||
onValueChange={(v) => setType(v as MCPServerTransport)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSPORT_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="启用">
|
||||
<div className="flex h-9 items-center">
|
||||
<Switch
|
||||
checked={enabled}
|
||||
onCheckedChange={setEnabled}
|
||||
/>
|
||||
<span className="text-muted-foreground ml-2 text-xs">
|
||||
{enabled ? "已启用" : "已停用"}
|
||||
</span>
|
||||
</div>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="描述">
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="给这个 MCP 服务器写一段说明,会展示在工具列表里"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{type === "stdio" && (
|
||||
<>
|
||||
<Field
|
||||
label="启动命令"
|
||||
hint="例如 npx、uvx、python,配合参数启动 MCP server 进程"
|
||||
>
|
||||
<Input
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="npx"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="参数" hint="每行一个参数">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={argsText}
|
||||
onChange={(e) => setArgsText(e.target.value)}
|
||||
placeholder={"-y\n@modelcontextprotocol/server-github"}
|
||||
/>
|
||||
</Field>
|
||||
<Field
|
||||
label="环境变量"
|
||||
hint="每行 KEY=VALUE;值以 $ 开头会在加载时解析为对应环境变量"
|
||||
>
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={envText}
|
||||
onChange={(e) => setEnvText(e.target.value)}
|
||||
placeholder={"GITHUB_TOKEN=$GITHUB_TOKEN"}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(type === "http" || type === "sse") && (
|
||||
<>
|
||||
<Field label="服务器 URL">
|
||||
<Input
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
placeholder="https://example.com/mcp"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Headers" hint="每行 KEY=VALUE">
|
||||
<Textarea
|
||||
rows={3}
|
||||
value={headersText}
|
||||
onChange={(e) => setHeadersText(e.target.value)}
|
||||
placeholder={"Authorization=Bearer xxx"}
|
||||
/>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Field
|
||||
label="禁用的工具"
|
||||
hint="每行一个工具名(不带服务器前缀),加载时会被过滤掉"
|
||||
>
|
||||
<Textarea
|
||||
rows={2}
|
||||
value={disabledToolsText}
|
||||
onChange={(e) => setDisabledToolsText(e.target.value)}
|
||||
placeholder={"some_dangerous_tool"}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={upsert.isPending}>
|
||||
{upsert.isPending
|
||||
? "保存中..."
|
||||
: mode === "create"
|
||||
? "创建"
|
||||
: "保存"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
hint,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
hint?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{hint && <div className="text-muted-foreground text-xs">{hint}</div>}
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { BellIcon } from "lucide-react";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useNotification } from "@/core/notification/hooks";
|
||||
import { useLocalSettings } from "@/core/settings";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function NotificationSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { permission, isSupported, requestPermission, showNotification } =
|
||||
useNotification();
|
||||
|
||||
const [settings, setSettings] = useLocalSettings();
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
await requestPermission();
|
||||
};
|
||||
|
||||
const handleTestNotification = () => {
|
||||
showNotification(t.settings.notification.testTitle, {
|
||||
body: t.settings.notification.testBody,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEnableNotification = async (enabled: boolean) => {
|
||||
setSettings("notification", {
|
||||
enabled,
|
||||
});
|
||||
};
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.notification.title}
|
||||
description={t.settings.notification.description}
|
||||
>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
{t.settings.notification.notSupported}
|
||||
</p>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.notification.title}
|
||||
description={
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{t.settings.notification.description}</div>
|
||||
<div>
|
||||
<Switch
|
||||
disabled={permission !== "granted"}
|
||||
checked={
|
||||
permission === "granted" && settings.notification.enabled
|
||||
}
|
||||
onCheckedChange={handleEnableNotification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
{permission === "default" && (
|
||||
<Button onClick={handleRequestPermission} variant="default">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.requestPermission}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permission === "denied" && (
|
||||
<p className="text-muted-foreground rounded-md border border-amber-200 bg-amber-50 p-3 text-sm dark:border-amber-800 dark:bg-amber-950/50">
|
||||
{t.settings.notification.deniedHint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{permission === "granted" && settings.notification.enabled && (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Button onClick={handleTestNotification} variant="outline">
|
||||
<BellIcon className="mr-2 size-4" />
|
||||
{t.settings.notification.testButton}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
InfoIcon,
|
||||
BrainIcon,
|
||||
PaletteIcon,
|
||||
SparklesIcon,
|
||||
WrenchIcon,
|
||||
} from "lucide-react";
|
||||
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -18,20 +11,18 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { AboutSettingsPage } from "@/components/workspace/settings/about-settings-page";
|
||||
import { AccountSettingsPage } from "@/components/workspace/settings/account-settings-page";
|
||||
import { AppearanceSettingsPage } from "@/components/workspace/settings/appearance-settings-page";
|
||||
import { MemorySettingsPage } from "@/components/workspace/settings/memory-settings-page";
|
||||
import { NotificationSettingsPage } from "@/components/workspace/settings/notification-settings-page";
|
||||
import { SkillSettingsPage } from "@/components/workspace/settings/skill-settings-page";
|
||||
import { ToolSettingsPage } from "@/components/workspace/settings/tool-settings-page";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type SettingsSection =
|
||||
| "account"
|
||||
| "appearance"
|
||||
| "memory"
|
||||
| "tools"
|
||||
| "skills"
|
||||
| "notification"
|
||||
| "about";
|
||||
|
||||
type SettingsDialogProps = React.ComponentProps<typeof Dialog> & {
|
||||
@@ -59,26 +50,23 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
label: t.settings.sections.appearance,
|
||||
icon: PaletteIcon,
|
||||
},
|
||||
{
|
||||
id: "notification",
|
||||
label: t.settings.sections.notification,
|
||||
icon: BellIcon,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: t.settings.sections.memory,
|
||||
icon: BrainIcon,
|
||||
},
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{ id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
{
|
||||
id: "account",
|
||||
label: "个人信息",
|
||||
icon: UserIcon,
|
||||
},
|
||||
// { id: "about", label: t.settings.sections.about, icon: InfoIcon },
|
||||
],
|
||||
[
|
||||
t.settings.sections.appearance,
|
||||
t.settings.sections.memory,
|
||||
t.settings.sections.tools,
|
||||
t.settings.sections.skills,
|
||||
t.settings.sections.notification,
|
||||
t.settings.sections.about,
|
||||
],
|
||||
);
|
||||
@@ -124,15 +112,10 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
</nav>
|
||||
<ScrollArea className="h-full min-h-0 min-w-0 rounded-lg border">
|
||||
<div className="min-w-0 space-y-8 p-6">
|
||||
{activeSection === "account" && <AccountSettingsPage />}
|
||||
{activeSection === "appearance" && <AppearanceSettingsPage />}
|
||||
{activeSection === "memory" && <MemorySettingsPage />}
|
||||
{activeSection === "tools" && <ToolSettingsPage />}
|
||||
{activeSection === "skills" && (
|
||||
<SkillSettingsPage
|
||||
onClose={() => props.onOpenChange?.(false)}
|
||||
/>
|
||||
)}
|
||||
{activeSection === "notification" && <NotificationSettingsPage />}
|
||||
{activeSection === "about" && <AboutSettingsPage />}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -1,6 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { AlertCircle, CheckCircle2, FileArchive, Loader2, SparklesIcon, Upload } from "lucide-react";
|
||||
import {
|
||||
AlertCircle,
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
Loader2,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
TagsIcon,
|
||||
Trash2Icon,
|
||||
TrendingUpIcon,
|
||||
Upload,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
@@ -10,6 +26,7 @@ import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -29,13 +46,19 @@ import {
|
||||
ItemContent,
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { TagSearchBar } from "@/components/workspace/tags/tag-search-bar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type { ValidateSkillUploadResponse } from "@/core/skills/api";
|
||||
import {
|
||||
useEnableSkill,
|
||||
useDeleteCustomSkill,
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkillContent,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -44,58 +67,129 @@ import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { SettingsSection } from "./settings-section";
|
||||
import { SkillStateSheet } from "./skill-state-sheet";
|
||||
|
||||
export function SkillSettingsPage({ onClose }: { onClose?: () => void } = {}) {
|
||||
const { t } = useI18n();
|
||||
const { skills, isLoading, error } = useSkills();
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.skills.title}
|
||||
description={t.settings.skills.description}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
) : (
|
||||
<SkillSettingsList skills={skills} onClose={onClose} />
|
||||
)}
|
||||
<SkillSettingsList onClose={onClose} />
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillSettingsList({
|
||||
skills,
|
||||
onClose,
|
||||
}: {
|
||||
skills: Skill[];
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const [filter, setFilter] = useState<string>("public");
|
||||
const [filter, setFilter] = useState<string>("mine");
|
||||
const [installOpen, setInstallOpen] = useState(false);
|
||||
const { mutate: enableSkill } = useEnableSkill();
|
||||
const filteredSkills = useMemo(
|
||||
() => skills.filter((skill) => skill.category === filter),
|
||||
[skills, filter],
|
||||
);
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagId });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
const isAdmin = storedAuth?.system_role === "admin";
|
||||
|
||||
// 把可见技能分类成 UI 关心的几个桶。
|
||||
// "All" = 内置 + 已发布的技能(仅 active 状态);自己的技能也要自己发布了才出现。
|
||||
// "Mine" = 严格属于我自己的自定义技能(仅 active 状态)。
|
||||
const { activeAll, activeMine, staleVisible, archivedVisible } = useMemo(() => {
|
||||
const all: Skill[] = [];
|
||||
const mine: Skill[] = [];
|
||||
const stale: Skill[] = [];
|
||||
const archived: Skill[] = [];
|
||||
for (const skill of skills) {
|
||||
const state = skill.state ?? "active";
|
||||
const isCustom = skill.category === "custom";
|
||||
const isOwnedByMe =
|
||||
isCustom && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||
|
||||
if (state === "stale") {
|
||||
stale.push(skill);
|
||||
continue;
|
||||
}
|
||||
if (state === "archived") {
|
||||
archived.push(skill);
|
||||
continue;
|
||||
}
|
||||
// state === active
|
||||
if (isBuiltin || !!skill.published) {
|
||||
all.push(skill);
|
||||
}
|
||||
if (isOwnedByMe) {
|
||||
mine.push(skill);
|
||||
}
|
||||
}
|
||||
return {
|
||||
activeAll: all,
|
||||
activeMine: mine,
|
||||
staleVisible: stale,
|
||||
archivedVisible: archived,
|
||||
};
|
||||
}, [skills, currentUserId]);
|
||||
|
||||
const filteredSkills = filter === "mine" ? activeMine : activeAll;
|
||||
const staleCount = staleVisible.length;
|
||||
const archivedCount = archivedVisible.length;
|
||||
|
||||
const handleCreateSkill = () => {
|
||||
onClose?.();
|
||||
router.push("/?mode=skill");
|
||||
};
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<header className="flex justify-between">
|
||||
<div className="flex gap-2">
|
||||
<Tabs defaultValue="public" onValueChange={setFilter}>
|
||||
<header className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Tabs value={filter} onValueChange={setFilter}>
|
||||
<TabsList variant="line">
|
||||
<TabsTrigger value="public">{t.common.public}</TabsTrigger>
|
||||
<TabsTrigger value="custom">{t.common.custom}</TabsTrigger>
|
||||
<TabsTrigger value="mine">{t.settings.skills.tabMine}</TabsTrigger>
|
||||
<TabsTrigger value="all">{t.settings.skills.tabAll}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={staleCount === 0}
|
||||
onClick={() => setStaleSheetOpen(true)}
|
||||
title={t.settings.skills.badgeStaleTitle}
|
||||
>
|
||||
<ClockIcon className="size-4" />
|
||||
{t.settings.skills.entryStale.replace("{count}", String(staleCount))}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={archivedCount === 0}
|
||||
onClick={() => setArchiveSheetOpen(true)}
|
||||
title={t.settings.skills.badgeArchivedTitle}
|
||||
>
|
||||
<ArchiveIcon className="size-4" />
|
||||
{t.settings.skills.entryArchived.replace(
|
||||
"{count}",
|
||||
String(archivedCount),
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
onClose?.();
|
||||
router.push("/page/workspace/skills/evolution");
|
||||
}}
|
||||
>
|
||||
<TrendingUpIcon className="size-4" />
|
||||
{t.settings.skills.goToEvolution}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -111,39 +205,357 @@ function SkillSettingsList({
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
{filteredSkills.length === 0 && (
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="max-w-xl"
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
)}
|
||||
{error && <div className="text-destructive text-sm">Error: {error.message}</div>}
|
||||
{!isLoading && !error && filteredSkills.length === 0 && (
|
||||
<EmptySkill onCreateSkill={handleCreateSkill} />
|
||||
)}
|
||||
{filteredSkills.length > 0 &&
|
||||
{!isLoading && !error &&
|
||||
filteredSkills.length > 0 &&
|
||||
filteredSkills.map((skill) => (
|
||||
<Item className="w-full" variant="outline" key={skill.name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">{skill.name}</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<Switch
|
||||
checked={skill.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
onCheckedChange={(checked) =>
|
||||
enableSkill({ skillName: skill.name, enabled: checked })
|
||||
}
|
||||
/>
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<SkillRow
|
||||
key={skill.name}
|
||||
skill={skill}
|
||||
currentUserId={currentUserId}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
))}
|
||||
<InstallSkillDialog
|
||||
open={installOpen}
|
||||
onClose={() => setInstallOpen(false)}
|
||||
isAdmin={isAdmin}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="stale"
|
||||
open={staleSheetOpen}
|
||||
onOpenChange={setStaleSheetOpen}
|
||||
skills={staleVisible}
|
||||
/>
|
||||
<SkillStateSheet
|
||||
kind="archived"
|
||||
open={archiveSheetOpen}
|
||||
onOpenChange={setArchiveSheetOpen}
|
||||
skills={archivedVisible}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillRow({
|
||||
skill,
|
||||
currentUserId,
|
||||
isAdmin,
|
||||
}: {
|
||||
skill: Skill;
|
||||
currentUserId: string | null;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const setPublished = useSetSkillPublished();
|
||||
const setPinned = useSetSkillPinned();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [tagPickerOpen, setTagPickerOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
|
||||
const isCustom = skill.category === "custom";
|
||||
// Built-in flag falls back to "no DB owner" so legacy responses without
|
||||
// the multitenancy fields still classify public skills correctly.
|
||||
const isBuiltin = skill.builtin ?? !skill.owner_user_id;
|
||||
const isOwned =
|
||||
isCustom && !isBuiltin && !!skill.owner_user_id && skill.owner_user_id === currentUserId;
|
||||
const isFromOthers = isCustom && !isBuiltin && !isOwned;
|
||||
// Admin can edit/delete/unpublish anyone's skill; non-admin only their own.
|
||||
const canManage = isCustom && (isOwned || isAdmin);
|
||||
const canTogglePublish = canManage && !isBuiltin;
|
||||
|
||||
async function handleTogglePublished(next: boolean) {
|
||||
try {
|
||||
await setPublished.mutateAsync({ skillName: skill.name, published: next });
|
||||
toast.success(
|
||||
next
|
||||
? t.settings.skills.publishSuccess
|
||||
: t.settings.skills.unpublishSuccess,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(t.settings.skills.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTogglePinned() {
|
||||
const next = !skill.pinned;
|
||||
try {
|
||||
await setPinned.mutateAsync({ skillName: skill.name, pinned: next });
|
||||
toast.success(
|
||||
next ? t.settings.skills.pinSuccess : t.settings.skills.unpinSuccess,
|
||||
);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Item className="w-full" variant="outline">
|
||||
<ItemContent
|
||||
className="cursor-pointer"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
<ItemTitle>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{skill.name}</span>
|
||||
{isBuiltin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{t.settings.skills.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && (
|
||||
<Badge
|
||||
variant="default"
|
||||
className="bg-emerald-500/15 text-emerald-700 hover:bg-emerald-500/15 dark:text-emerald-300 text-xs"
|
||||
>
|
||||
{t.settings.skills.badgeMine}
|
||||
</Badge>
|
||||
)}
|
||||
{isFromOthers && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-blue-500/30 text-blue-700 dark:text-blue-300 text-xs"
|
||||
title={
|
||||
skill.owner_user_id
|
||||
? t.settings.skills.badgeFromOthersTitle.replace(
|
||||
"{user_id}",
|
||||
skill.owner_user_id,
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t.settings.skills.badgeFromOthers}
|
||||
</Badge>
|
||||
)}
|
||||
{isOwned && skill.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title={t.settings.skills.badgePublishedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={t.settings.skills.badgeSourceAgentTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{isCustom && !isBuiltin && skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={t.settings.skills.badgeSourceUploadTitle}
|
||||
>
|
||||
{t.settings.skills.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.pinned && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-purple-500/30 text-purple-700 dark:text-purple-300 text-xs"
|
||||
title={t.settings.skills.badgePinnedTitle}
|
||||
>
|
||||
{t.settings.skills.badgePinned}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{skill.description}
|
||||
</ItemDescription>
|
||||
{skill.use_count !== undefined && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">
|
||||
{skill.use_count > 0
|
||||
? t.settings.skills.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: t.settings.skills.useCountInlineZero}
|
||||
</div>
|
||||
)}
|
||||
{(skill.tags?.length ?? 0) > 0 && (
|
||||
<TagChipList tags={skill.tags!} className="mt-2" />
|
||||
)}
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
{isAdmin && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
onClick={() => setTagPickerOpen(true)}
|
||||
title={t.adminTags.manageTags}
|
||||
>
|
||||
<TagsIcon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPinned.isPending}
|
||||
onClick={() => void handleTogglePinned()}
|
||||
title={
|
||||
skill.pinned
|
||||
? t.settings.skills.unpinAction
|
||||
: t.settings.skills.pinAction
|
||||
}
|
||||
>
|
||||
{skill.pinned ? (
|
||||
<PinOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<PinIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canTogglePublish && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={setPublished.isPending}
|
||||
onClick={() => void handleTogglePublished(!skill.published)}
|
||||
title={
|
||||
skill.published
|
||||
? t.settings.skills.unpublishAction
|
||||
: t.settings.skills.publishAction
|
||||
}
|
||||
>
|
||||
{skill.published ? (
|
||||
<EyeOffIcon className="size-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive h-8 w-8 shrink-0"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
title={t.settings.skills.deleteAction}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</ItemActions>
|
||||
</Item>
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.settings.skills.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t.settings.skills.deleteConfirm.replace("{name}", skill.name)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
{isAdmin && (
|
||||
<TagPickerDialog
|
||||
open={tagPickerOpen}
|
||||
onOpenChange={setTagPickerOpen}
|
||||
targetType="skill"
|
||||
targetId={skill.name}
|
||||
currentTags={skill.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillDetailDialog({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: Skill;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data, isLoading, error } = useSkillContent(open ? skill.name : null);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogDescription>{skill.description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="bg-muted/40 min-h-0 flex-1 overflow-auto rounded-md border">
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-16 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-destructive p-4 text-sm">
|
||||
{error instanceof Error ? error.message : String(error)}
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-foreground/90 whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
|
||||
{data?.content}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
@@ -167,9 +579,11 @@ function EmptySkill({ onCreateSkill }: { onCreateSkill: () => void }) {
|
||||
function InstallSkillDialog({
|
||||
open,
|
||||
onClose,
|
||||
isAdmin,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
isAdmin: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const dialog = t.settings.skills.installDialog;
|
||||
@@ -178,6 +592,7 @@ function InstallSkillDialog({
|
||||
const [validation, setValidation] = useState<ValidateSkillUploadResponse | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [targetCategory, setTargetCategory] = useState<"custom" | "public">("custom");
|
||||
const validateMutation = useValidateSkillUpload();
|
||||
const installMutation = useInstallSkillUpload();
|
||||
|
||||
@@ -186,6 +601,7 @@ function InstallSkillDialog({
|
||||
setValidation(null);
|
||||
setValidationError(null);
|
||||
setIsDragging(false);
|
||||
setTargetCategory("custom");
|
||||
if (inputRef.current) inputRef.current.value = "";
|
||||
}, []);
|
||||
|
||||
@@ -242,7 +658,7 @@ function InstallSkillDialog({
|
||||
async (overwrite: boolean) => {
|
||||
if (!file) return;
|
||||
try {
|
||||
const result = await installMutation.mutateAsync({ file, overwrite });
|
||||
const result = await installMutation.mutateAsync({ file, overwrite, targetCategory });
|
||||
toast.success(
|
||||
(result.overwritten ? dialog.installSuccessReplaced : dialog.installSuccess).replace(
|
||||
"{name}",
|
||||
@@ -255,13 +671,15 @@ function InstallSkillDialog({
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
},
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset],
|
||||
[dialog.installSuccess, dialog.installSuccessReplaced, file, installMutation, onClose, reset, targetCategory],
|
||||
);
|
||||
|
||||
const isPublicConflict = validation?.conflict && validation.conflict_kind === "public";
|
||||
const isCustomConflict = validation?.conflict && validation.conflict_kind === "custom";
|
||||
const canInstall = !!validation && !validationError && !isPublicConflict && !isCustomConflict;
|
||||
const canOverwrite = !!validation && !validationError && isCustomConflict;
|
||||
// Admin targeting public directory can overwrite an existing public skill.
|
||||
const adminCanOverwritePublic = isAdmin && targetCategory === "public" && !!isPublicConflict;
|
||||
const canInstall = !!validation && !validationError && !isCustomConflict && (adminCanOverwritePublic ? false : !isPublicConflict);
|
||||
const canOverwrite = !!validation && !validationError && (!!isCustomConflict || adminCanOverwritePublic);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
@@ -309,6 +727,27 @@ function InstallSkillDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-muted-foreground shrink-0">{dialog.targetCategoryLabel}</span>
|
||||
<div className="flex gap-4">
|
||||
{(["custom", "public"] as const).map((cat) => (
|
||||
<label key={cat} className="flex cursor-pointer items-center gap-1.5">
|
||||
<input
|
||||
type="radio"
|
||||
name="targetCategory"
|
||||
value={cat}
|
||||
checked={targetCategory === cat}
|
||||
onChange={() => setTargetCategory(cat)}
|
||||
className="accent-primary"
|
||||
/>
|
||||
<span>{cat === "custom" ? dialog.categoryCustom : dialog.categoryPublic}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{validateMutation.isPending && (
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
@@ -366,10 +805,10 @@ function InstallSkillDialog({
|
||||
</div>
|
||||
)}
|
||||
{isPublicConflict && (
|
||||
<div className="text-destructive mt-1 flex items-start gap-2 text-xs">
|
||||
<div className={cn("mt-1 flex items-start gap-2 text-xs", adminCanOverwritePublic ? "text-amber-600" : "text-destructive")}>
|
||||
<AlertCircle className="mt-0.5 size-3.5 shrink-0" />
|
||||
<span>
|
||||
{dialog.conflictPublic.replace("{name}", validation.skill_name)}
|
||||
{(adminCanOverwritePublic ? dialog.conflictPublicAdminHint : dialog.conflictPublic).replace("{name}", validation.skill_name)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
"use client";
|
||||
|
||||
import { ArchiveIcon, ClockIcon, MoreHorizontalIcon, RotateCcwIcon, Trash2Icon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useDeleteCustomSkill, useSetSkillState } from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Kind = "stale" | "archived";
|
||||
|
||||
export function SkillStateSheet({
|
||||
kind,
|
||||
open,
|
||||
onOpenChange,
|
||||
skills,
|
||||
}: {
|
||||
kind: Kind;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
skills: Skill[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skills;
|
||||
const ts = tt.stateSheet;
|
||||
const isArchived = kind === "archived";
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="flex w-full flex-col gap-0 p-0 sm:max-w-2xl">
|
||||
<SheetHeader className="border-b">
|
||||
<SheetTitle className="flex items-center gap-2">
|
||||
{isArchived ? (
|
||||
<ArchiveIcon className="size-4" />
|
||||
) : (
|
||||
<ClockIcon className="size-4" />
|
||||
)}
|
||||
{isArchived ? ts.archivedTitle : ts.staleTitle}
|
||||
<span className="text-muted-foreground text-sm font-normal">
|
||||
({skills.length})
|
||||
</span>
|
||||
</SheetTitle>
|
||||
<SheetDescription>
|
||||
{isArchived ? ts.archivedDescription : ts.staleDescription}
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{skills.length === 0 ? (
|
||||
<div className="text-muted-foreground py-12 text-center text-sm">
|
||||
{ts.empty}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{skills.map((skill) => (
|
||||
<SkillStateRow key={skill.name} skill={skill} kind={kind} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillStateRow({ skill, kind }: { skill: Skill; kind: Kind }) {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skills;
|
||||
const ts = tt.stateSheet;
|
||||
const setState = useSetSkillState();
|
||||
const deleteSkill = useDeleteCustomSkill();
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const [viewOpen, setViewOpen] = useState(false);
|
||||
|
||||
const pending = setState.isPending || deleteSkill.isPending;
|
||||
const isArchived = kind === "archived";
|
||||
|
||||
const timeLabel = useMemo(() => {
|
||||
if (isArchived) {
|
||||
const when = skill.last_activity_at;
|
||||
return when
|
||||
? ts.archivedAt.replace("{when}", formatRelative(when))
|
||||
: ts.archivedAt.replace("{when}", "—");
|
||||
}
|
||||
const when = skill.last_activity_at;
|
||||
return when
|
||||
? ts.lastUsedAt.replace("{when}", formatRelative(when))
|
||||
: ts.neverUsed;
|
||||
}, [isArchived, skill.last_activity_at, ts.archivedAt, ts.lastUsedAt, ts.neverUsed]);
|
||||
|
||||
async function handleRestore() {
|
||||
try {
|
||||
await setState.mutateAsync({ skillName: skill.name, state: "active" });
|
||||
toast.success(ts.restoreSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
try {
|
||||
await setState.mutateAsync({ skillName: skill.name, state: "archived" });
|
||||
toast.success(ts.archiveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
try {
|
||||
await deleteSkill.mutateAsync(skill.name);
|
||||
toast.success(tt.deleteSuccess);
|
||||
setConfirmDeleteOpen(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-card flex flex-col gap-2 rounded-md border p-3">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{skill.name}</span>
|
||||
{isArchived ? (
|
||||
<Badge variant="secondary" className="text-xs" title={tt.badgeArchivedTitle}>
|
||||
{tt.badgeArchived}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-amber-500/30 text-amber-700 dark:text-amber-300 text-xs"
|
||||
title={tt.badgeStaleTitle}
|
||||
>
|
||||
{tt.badgeStale}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.source === "agent" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-cyan-500/30 text-cyan-700 dark:text-cyan-300 text-xs"
|
||||
title={tt.badgeSourceAgentTitle}
|
||||
>
|
||||
{tt.badgeSourceAgent}
|
||||
</Badge>
|
||||
)}
|
||||
{skill.source === "upload" && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-zinc-400/40 text-zinc-600 dark:text-zinc-400 text-xs"
|
||||
title={tt.badgeSourceUploadTitle}
|
||||
>
|
||||
{tt.badgeSourceUpload}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 line-clamp-2 text-sm">
|
||||
{skill.description}
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1 flex flex-wrap gap-x-3 text-xs">
|
||||
<span>{timeLabel}</span>
|
||||
<span>
|
||||
{skill.use_count && skill.use_count > 0
|
||||
? tt.useCountInline.replace(
|
||||
"{count}",
|
||||
String(skill.use_count),
|
||||
)
|
||||
: tt.useCountInlineZero}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleRestore()}
|
||||
disabled={pending}
|
||||
>
|
||||
<RotateCcwIcon className="size-3.5" />
|
||||
{ts.actionRestore}
|
||||
</Button>
|
||||
{!isArchived && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => void handleArchive()}
|
||||
disabled={pending}
|
||||
>
|
||||
<ArchiveIcon className="size-3.5" />
|
||||
{ts.actionArchiveNow}
|
||||
</Button>
|
||||
)}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 shrink-0"
|
||||
disabled={pending}
|
||||
title={ts.moreMenu}
|
||||
>
|
||||
<MoreHorizontalIcon className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => setViewOpen(true)}>
|
||||
{ts.actionView}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={() => setConfirmDeleteOpen(true)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{ts.actionDelete}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SkillViewDialog
|
||||
skill={skill}
|
||||
open={viewOpen}
|
||||
onOpenChange={setViewOpen}
|
||||
/>
|
||||
|
||||
<Dialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tt.deleteAction}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{tt.deleteConfirm.replace("{name}", skill.name)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setConfirmDeleteOpen(false)}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={deleteSkill.isPending}
|
||||
>
|
||||
{deleteSkill.isPending ? t.common.loading : t.common.delete}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SkillViewDialog({
|
||||
skill,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skill: Skill;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tv = t.settings.skills.viewDialog;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{skill.name}</DialogTitle>
|
||||
<DialogDescription>{tv.title}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 text-sm">
|
||||
<Row label={tv.descriptionLabel}>
|
||||
<span className="whitespace-pre-wrap">{skill.description}</span>
|
||||
</Row>
|
||||
<Row label={tv.stateLabel}>
|
||||
<span className={cn("font-medium", stateColor(skill.state))}>
|
||||
{skill.state ?? "active"}
|
||||
</span>
|
||||
</Row>
|
||||
{skill.use_count !== undefined && (
|
||||
<Row label={tv.useCountLabel}>{skill.use_count}</Row>
|
||||
)}
|
||||
{skill.last_activity_at && (
|
||||
<Row label={tv.lastUsedLabel}>
|
||||
{formatRelative(skill.last_activity_at)}
|
||||
</Row>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
{tv.close}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="grid grid-cols-[80px_1fr] gap-2">
|
||||
<span className="text-muted-foreground shrink-0 text-xs">{label}</span>
|
||||
<div className="min-w-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function stateColor(state: string | undefined): string {
|
||||
switch (state) {
|
||||
case "archived":
|
||||
return "text-muted-foreground";
|
||||
case "stale":
|
||||
return "text-amber-600 dark:text-amber-300";
|
||||
default:
|
||||
return "text-emerald-600 dark:text-emerald-300";
|
||||
}
|
||||
}
|
||||
|
||||
function formatRelative(iso: string): string {
|
||||
const ms = Date.now() - new Date(iso).getTime();
|
||||
if (Number.isNaN(ms)) return iso;
|
||||
const sec = Math.max(0, Math.floor(ms / 1000));
|
||||
const min = Math.floor(sec / 60);
|
||||
const hour = Math.floor(min / 60);
|
||||
const day = Math.floor(hour / 24);
|
||||
if (day >= 1) return `${day}d ago`;
|
||||
if (hour >= 1) return `${hour}h ago`;
|
||||
if (min >= 1) return `${min}m ago`;
|
||||
return "just now";
|
||||
}
|
||||
@@ -1,5 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Pencil, Plus, Trash2 } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Item,
|
||||
ItemActions,
|
||||
@@ -8,16 +21,54 @@ import {
|
||||
ItemTitle,
|
||||
} from "@/components/ui/item";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useMCPConfig, useEnableMCPServer } from "@/core/mcp/hooks";
|
||||
import {
|
||||
useDeleteMCPServer,
|
||||
useEnableMCPServer,
|
||||
useMCPConfig,
|
||||
} from "@/core/mcp/hooks";
|
||||
import type { MCPServerConfig } from "@/core/mcp/types";
|
||||
import { env } from "@/env";
|
||||
|
||||
import { MCPServerEditDialog } from "./mcp-server-edit-dialog";
|
||||
import { SettingsSection } from "./settings-section";
|
||||
|
||||
export function ToolSettingsPage() {
|
||||
const { t } = useI18n();
|
||||
const { config, isLoading, error } = useMCPConfig();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const isStaticMode = env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true";
|
||||
|
||||
const [editOpen, setEditOpen] = useState(false);
|
||||
const [editMode, setEditMode] = useState<"create" | "edit">("create");
|
||||
const [editingName, setEditingName] = useState<string | undefined>(undefined);
|
||||
const [editingServer, setEditingServer] = useState<
|
||||
MCPServerConfig | undefined
|
||||
>(undefined);
|
||||
const [deleteTarget, setDeleteTarget] = useState<string | null>(null);
|
||||
|
||||
const servers = useMemo(() => {
|
||||
if (!config) return [] as Array<[string, MCPServerConfig]>;
|
||||
return Object.entries(config.mcp_servers);
|
||||
}, [config]);
|
||||
|
||||
const existingNames = useMemo(() => servers.map(([n]) => n), [servers]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setEditMode("create");
|
||||
setEditingName(undefined);
|
||||
setEditingServer(undefined);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
const handleEdit = (name: string, server: MCPServerConfig) => {
|
||||
setEditMode("edit");
|
||||
setEditingName(name);
|
||||
setEditingServer(server);
|
||||
setEditOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t.settings.tools.title}
|
||||
@@ -26,38 +77,128 @@ export function ToolSettingsPage() {
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
) : error ? (
|
||||
<div>Error: {error.message}</div>
|
||||
<div className="text-destructive text-sm">Error: {error.message}</div>
|
||||
) : (
|
||||
config && <MCPServerList servers={config.mcp_servers} />
|
||||
<div className="flex w-full flex-col gap-3">
|
||||
{isAdmin && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleCreate}
|
||||
disabled={isStaticMode}
|
||||
>
|
||||
<Plus className="size-4" />
|
||||
新增 MCP 服务器
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<MCPServerList
|
||||
servers={servers}
|
||||
isAdmin={isAdmin}
|
||||
isStaticMode={isStaticMode}
|
||||
onEdit={handleEdit}
|
||||
onDelete={(name) => setDeleteTarget(name)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<MCPServerEditDialog
|
||||
open={editOpen}
|
||||
onOpenChange={setEditOpen}
|
||||
mode={editMode}
|
||||
originalName={editingName}
|
||||
initialServer={editingServer}
|
||||
existingNames={existingNames}
|
||||
/>
|
||||
|
||||
<DeleteConfirmDialog
|
||||
target={deleteTarget}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
function MCPServerList({
|
||||
servers,
|
||||
isAdmin,
|
||||
isStaticMode,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
servers: Record<string, MCPServerConfig>;
|
||||
servers: Array<[string, MCPServerConfig]>;
|
||||
isAdmin: boolean;
|
||||
isStaticMode: boolean;
|
||||
onEdit: (name: string, server: MCPServerConfig) => void;
|
||||
onDelete: (name: string) => void;
|
||||
}) {
|
||||
const { mutate: enableMCPServer } = useEnableMCPServer();
|
||||
|
||||
if (servers.length === 0) {
|
||||
return (
|
||||
<div className="text-muted-foreground rounded-md border p-6 text-center text-sm">
|
||||
{isAdmin
|
||||
? "还没有配置任何 MCP 服务器,点上面的按钮添加一个。"
|
||||
: "管理员还没有配置任何 MCP 服务器。"}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{Object.entries(servers).map(([name, config]) => (
|
||||
{servers.map(([name, config]) => (
|
||||
<Item className="w-full" variant="outline" key={name}>
|
||||
<ItemContent>
|
||||
<ItemTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<div>{name}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{name}</span>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{config.type ?? "stdio"}
|
||||
</Badge>
|
||||
{config.disabled_tools &&
|
||||
config.disabled_tools.length > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已禁用 {config.disabled_tools.length} 个工具
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</ItemTitle>
|
||||
<ItemDescription className="line-clamp-4">
|
||||
{config.description}
|
||||
{config.description ||
|
||||
(config.type === "stdio"
|
||||
? config.command
|
||||
: config.url) ||
|
||||
"暂无描述"}
|
||||
</ItemDescription>
|
||||
</ItemContent>
|
||||
<ItemActions>
|
||||
<ItemActions className="flex items-center gap-2">
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="size-8"
|
||||
onClick={() => onEdit(name, config)}
|
||||
disabled={isStaticMode}
|
||||
aria-label="编辑"
|
||||
>
|
||||
<Pencil className="size-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive size-8"
|
||||
onClick={() => onDelete(name)}
|
||||
disabled={isStaticMode}
|
||||
aria-label="删除"
|
||||
>
|
||||
<Trash2 className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Switch
|
||||
checked={config.enabled}
|
||||
disabled={env.NEXT_PUBLIC_STATIC_WEBSITE_ONLY === "true"}
|
||||
disabled={isStaticMode || !isAdmin}
|
||||
onCheckedChange={(checked) =>
|
||||
enableMCPServer({ serverName: name, enabled: checked })
|
||||
}
|
||||
@@ -68,3 +209,49 @@ function MCPServerList({
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DeleteConfirmDialog({
|
||||
target,
|
||||
onOpenChange,
|
||||
}: {
|
||||
target: string | null;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const deleteMcp = useDeleteMCPServer();
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!target) return;
|
||||
try {
|
||||
await deleteMcp.mutateAsync({ serverName: target });
|
||||
toast.success(`已删除 MCP 服务器:${target}`);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={target !== null} onOpenChange={onOpenChange}>
|
||||
<DialogContent aria-describedby={undefined}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>删除 MCP 服务器</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-2 text-sm">
|
||||
确定要删除 MCP 服务器 <b>{target}</b> 吗?该操作不可撤销。
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={handleConfirm}
|
||||
disabled={deleteMcp.isPending}
|
||||
>
|
||||
{deleteMcp.isPending ? "删除中..." : "删除"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
Loader2Icon,
|
||||
PlayIcon,
|
||||
RefreshCwIcon,
|
||||
SaveIcon,
|
||||
} from "lucide-react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "@/components/ui/card";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import {
|
||||
useCuratorConfig,
|
||||
useCuratorLogs,
|
||||
useCuratorStatus,
|
||||
useRunCurator,
|
||||
useUpdateCuratorConfig,
|
||||
} from "@/core/curator/hooks";
|
||||
import type { CuratorConfig } from "@/core/curator/types";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function SkillEvolutionPanel() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const tt = t.settings.skillEvolution;
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">{tt.title}</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
{tt.description}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => router.push("/page/workspace/skills")}
|
||||
>
|
||||
<ArrowLeftIcon className="size-4" />
|
||||
{tt.backToSkills}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<StatusCard />
|
||||
<ConfigCard />
|
||||
<LogsCard />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.status;
|
||||
const { status, isLoading } = useCuratorStatus();
|
||||
const runCurator = useRunCurator();
|
||||
|
||||
const lastRunDisplay = useMemo(() => {
|
||||
if (!status?.last_run_at) return tt.lastRunNever;
|
||||
try {
|
||||
return new Date(status.last_run_at).toLocaleString();
|
||||
} catch {
|
||||
return status.last_run_at;
|
||||
}
|
||||
}, [status?.last_run_at, tt.lastRunNever]);
|
||||
|
||||
const handleRun = async () => {
|
||||
try {
|
||||
await runCurator.mutateAsync();
|
||||
toast.success(tt.runStarted);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
{status && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<Badge variant={status.enabled ? "default" : "secondary"}>
|
||||
{status.enabled ? tt.enabled : tt.disabled}
|
||||
</Badge>
|
||||
{status.paused && (
|
||||
<Badge variant="outline">{tt.paused}</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleRun}
|
||||
disabled={runCurator.isPending}
|
||||
>
|
||||
{runCurator.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<PlayIcon className="size-4" />
|
||||
)}
|
||||
{tt.runNow}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : status ? (
|
||||
<div className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<StatRow label={tt.runCount}>
|
||||
{status.run_count}
|
||||
{tt.runCountUnit && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
{tt.runCountUnit}
|
||||
</span>
|
||||
)}
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastRunAt}>{lastRunDisplay}</StatRow>
|
||||
<StatRow label={tt.lastRunSummary} className="sm:col-span-2">
|
||||
<span className="text-muted-foreground">
|
||||
{status.last_run_summary ?? tt.lastRunSummaryEmpty}
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.skillCounts} className="sm:col-span-2">
|
||||
<span className="text-muted-foreground inline-flex flex-wrap gap-3">
|
||||
<span>
|
||||
{tt.total}: {status.agent_created_skills.total}
|
||||
</span>
|
||||
<span>
|
||||
{tt.active}: {status.agent_created_skills.active}
|
||||
</span>
|
||||
<span>
|
||||
{tt.stale}: {status.agent_created_skills.stale}
|
||||
</span>
|
||||
<span>
|
||||
{tt.archived}: {status.agent_created_skills.archived}
|
||||
</span>
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastActions} className="sm:col-span-2">
|
||||
<div className="flex flex-col gap-2 text-xs">
|
||||
<div>
|
||||
<div className="font-medium">{tt.llmSuggested}</div>
|
||||
{status.last_consolidation_suggestions.consolidations.length ===
|
||||
0 &&
|
||||
status.last_consolidation_suggestions.prunings.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="text-muted-foreground mt-0.5 list-disc pl-4">
|
||||
{status.last_consolidation_suggestions.consolidations.map(
|
||||
(c, i) => (
|
||||
<li key={`c${i}`}>
|
||||
{(c.from ?? "?") + " → " + (c.into ?? "?")}
|
||||
{c.reason ? `(${c.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
{status.last_consolidation_suggestions.prunings.map(
|
||||
(p, i) => (
|
||||
<li key={`p${i}`}>
|
||||
{p.name ?? "?"}
|
||||
{p.reason ? `(${p.reason})` : ""}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-medium">{tt.actuallyDid}</div>
|
||||
{status.last_actual_actions.length === 0 ? (
|
||||
<span className="text-muted-foreground">
|
||||
{tt.noActions}
|
||||
</span>
|
||||
) : (
|
||||
<ul className="mt-0.5 flex flex-col gap-1">
|
||||
{status.last_actual_actions.map((a, i) => (
|
||||
<li
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<span className="font-mono">{a.tool ?? "?"}</span>
|
||||
{typeof a.args?.name === "string" && (
|
||||
<span className="text-muted-foreground">
|
||||
{String(a.args.name)}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant={
|
||||
a.status === "error"
|
||||
? "destructive"
|
||||
: "secondary"
|
||||
}
|
||||
className="px-1 text-[10px]"
|
||||
>
|
||||
{a.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</StatRow>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({
|
||||
label,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<span className="text-muted-foreground min-w-[6rem]">{label}</span>
|
||||
<span className="font-medium">{children}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.config;
|
||||
const { config, isLoading } = useCuratorConfig();
|
||||
const updateConfig = useUpdateCuratorConfig();
|
||||
|
||||
const [draft, setDraft] = useState<CuratorConfig | null>(null);
|
||||
useEffect(() => {
|
||||
if (config && !draft) {
|
||||
setDraft(config);
|
||||
}
|
||||
}, [config, draft]);
|
||||
|
||||
if (isLoading || !draft) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const isDirty =
|
||||
!!config &&
|
||||
JSON.stringify(draft) !== JSON.stringify(config);
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updated = await updateConfig.mutateAsync(draft);
|
||||
setDraft(updated);
|
||||
toast.success(tt.saveSuccess);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<FieldRow
|
||||
label={tt.enabledLabel}
|
||||
helper={tt.enabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.enabled}
|
||||
onCheckedChange={(v) => setDraft({ ...draft, enabled: v })}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.curatorEnabledLabel}
|
||||
helper={tt.curatorEnabledHelper}
|
||||
>
|
||||
<Switch
|
||||
checked={draft.curator.enabled}
|
||||
onCheckedChange={(v) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: { ...draft.curator, enabled: v },
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.intervalHoursLabel}
|
||||
helper={tt.intervalHoursHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.interval_hours}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
interval_hours: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.staleAfterDaysLabel}
|
||||
helper={tt.staleAfterDaysHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.stale_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
stale_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.archiveAfterDaysLabel}
|
||||
helper={tt.archiveAfterDaysHelper}
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
className="max-w-[10rem]"
|
||||
value={draft.curator.archive_after_days}
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
curator: {
|
||||
...draft.curator,
|
||||
archive_after_days: Math.max(1, Number(e.target.value) || 1),
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldRow
|
||||
label={tt.moderationModelLabel}
|
||||
helper={tt.moderationModelHelper}
|
||||
>
|
||||
<Input
|
||||
type="text"
|
||||
className="max-w-[20rem]"
|
||||
value={draft.moderation_model_name ?? ""}
|
||||
placeholder=""
|
||||
onChange={(e) =>
|
||||
setDraft({
|
||||
...draft,
|
||||
moderation_model_name:
|
||||
e.target.value.trim() === "" ? null : e.target.value,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</FieldRow>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={() => config && setDraft(config)}
|
||||
>
|
||||
{tt.reset}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!isDirty || updateConfig.isPending}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{updateConfig.isPending ? (
|
||||
<Loader2Icon className="size-4 animate-spin" />
|
||||
) : (
|
||||
<SaveIcon className="size-4" />
|
||||
)}
|
||||
{tt.save}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldRow({
|
||||
label,
|
||||
helper,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
helper?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 border-b pb-4 last:border-b-0 last:pb-0">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium">{label}</div>
|
||||
{helper && (
|
||||
<div className="text-muted-foreground mt-1 text-xs">{helper}</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-shrink-0">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function LogsCard() {
|
||||
const { t } = useI18n();
|
||||
const tt = t.settings.skillEvolution.logs;
|
||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
||||
const { logs, isLoading, refetch } = useCuratorLogs({ autoRefresh });
|
||||
|
||||
const records = logs?.records ?? [];
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-4">
|
||||
<div>
|
||||
<CardTitle>{tt.title}</CardTitle>
|
||||
<CardDescription>{tt.description}</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-muted-foreground flex items-center gap-2 text-xs">
|
||||
<Switch
|
||||
checked={autoRefresh}
|
||||
onCheckedChange={setAutoRefresh}
|
||||
/>
|
||||
{tt.autoRefresh}
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={() => void refetch()}>
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
{tt.refresh}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading && records.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : records.length === 0 ? (
|
||||
<div className="text-muted-foreground text-sm">{tt.empty}</div>
|
||||
) : (
|
||||
<div className="bg-muted/30 max-h-[420px] overflow-auto rounded-md border p-3 font-mono text-xs leading-relaxed">
|
||||
{records.map((record, idx) => (
|
||||
<div
|
||||
key={`${record.ts}-${idx}`}
|
||||
className={cn(
|
||||
"flex gap-3",
|
||||
record.level === "warning" && "text-yellow-700 dark:text-yellow-300",
|
||||
record.level === "error" && "text-red-600 dark:text-red-300",
|
||||
)}
|
||||
>
|
||||
<span className="text-muted-foreground shrink-0">
|
||||
{formatTime(record.ts)}
|
||||
</span>
|
||||
<span className="text-muted-foreground w-14 shrink-0 uppercase">
|
||||
{record.level}
|
||||
</span>
|
||||
<span className="whitespace-pre-wrap break-all">
|
||||
{record.message === "run_started"
|
||||
? "——— 本次策展开始 ———"
|
||||
: record.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
const pad = (n: number) => String(n).padStart(2, "0");
|
||||
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/** A read-only (or removable) coloured chip for a single tag. */
|
||||
export function TagChip({
|
||||
tag,
|
||||
onRemove,
|
||||
className,
|
||||
}: {
|
||||
tag: Pick<Tag, "name" | "color">;
|
||||
onRemove?: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium",
|
||||
color.chip,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{tag.name}
|
||||
{onRemove && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
className="hover:opacity-70"
|
||||
aria-label={`remove ${tag.name}`}
|
||||
>
|
||||
<XIcon className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
/** Renders a horizontal list of tag chips; nothing when the list is empty. */
|
||||
export function TagChipList({
|
||||
tags,
|
||||
className,
|
||||
}: {
|
||||
tags: Pick<Tag, "name" | "color">[];
|
||||
className?: string;
|
||||
}) {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{tags.map((tag) => (
|
||||
<TagChip key={tag.name} tag={tag} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
// Fixed tag color palette. The backend only stores the color *name*; the
|
||||
// Tailwind classes are resolved here so the class strings stay static and
|
||||
// survive the production purge.
|
||||
|
||||
export interface TagColorDef {
|
||||
name: string;
|
||||
/** Classes for a rendered chip (background + text + border). */
|
||||
chip: string;
|
||||
/** A solid swatch used in the colour picker. */
|
||||
swatch: string;
|
||||
}
|
||||
|
||||
export const TAG_COLORS: TagColorDef[] = [
|
||||
{ name: "gray", chip: "bg-gray-100 text-gray-700 border-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:border-gray-700", swatch: "bg-gray-400" },
|
||||
{ name: "red", chip: "bg-red-100 text-red-700 border-red-200 dark:bg-red-950 dark:text-red-300 dark:border-red-900", swatch: "bg-red-500" },
|
||||
{ name: "orange", chip: "bg-orange-100 text-orange-700 border-orange-200 dark:bg-orange-950 dark:text-orange-300 dark:border-orange-900", swatch: "bg-orange-500" },
|
||||
{ name: "amber", chip: "bg-amber-100 text-amber-800 border-amber-200 dark:bg-amber-950 dark:text-amber-300 dark:border-amber-900", swatch: "bg-amber-500" },
|
||||
{ name: "green", chip: "bg-green-100 text-green-700 border-green-200 dark:bg-green-950 dark:text-green-300 dark:border-green-900", swatch: "bg-green-500" },
|
||||
{ name: "teal", chip: "bg-teal-100 text-teal-700 border-teal-200 dark:bg-teal-950 dark:text-teal-300 dark:border-teal-900", swatch: "bg-teal-500" },
|
||||
{ name: "blue", chip: "bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-950 dark:text-blue-300 dark:border-blue-900", swatch: "bg-blue-500" },
|
||||
{ name: "indigo", chip: "bg-indigo-100 text-indigo-700 border-indigo-200 dark:bg-indigo-950 dark:text-indigo-300 dark:border-indigo-900", swatch: "bg-indigo-500" },
|
||||
{ name: "purple", chip: "bg-purple-100 text-purple-700 border-purple-200 dark:bg-purple-950 dark:text-purple-300 dark:border-purple-900", swatch: "bg-purple-500" },
|
||||
{ name: "pink", chip: "bg-pink-100 text-pink-700 border-pink-200 dark:bg-pink-950 dark:text-pink-300 dark:border-pink-900", swatch: "bg-pink-500" },
|
||||
];
|
||||
|
||||
const BY_NAME = new Map(TAG_COLORS.map((c) => [c.name, c]));
|
||||
|
||||
export function resolveTagColor(name: string | undefined | null): TagColorDef {
|
||||
return (name && BY_NAME.get(name)) || TAG_COLORS[0];
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
"use client";
|
||||
|
||||
import { CheckIcon, Loader2 } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useSetResourceTags, useTags, type Tag, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
/**
|
||||
* Admin-only dialog to set the tags on one resource. Clicking a tag binds or
|
||||
* unbinds it immediately (no separate save step). Only tags of ``targetType``
|
||||
* are offered, enforcing "a tag may only tag its own type".
|
||||
*/
|
||||
export function TagPickerDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
targetType,
|
||||
targetId,
|
||||
currentTags,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
targetType: TagType;
|
||||
targetId: string;
|
||||
currentTags: Tag[];
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: allTags, isLoading } = useTags(targetType);
|
||||
const setResourceTags = useSetResourceTags();
|
||||
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||
const [syncedFor, setSyncedFor] = useState<string | null>(null);
|
||||
const syncKey = `${open}:${targetId}`;
|
||||
if (open && syncedFor !== syncKey) {
|
||||
setSelected(new Set(currentTags.map((tg) => tg.id)));
|
||||
setSyncedFor(syncKey);
|
||||
}
|
||||
if (!open && syncedFor !== null) setSyncedFor(null);
|
||||
|
||||
// Clicking a tag mutates immediately. Local state updates optimistically and
|
||||
// rolls back if the request fails.
|
||||
const toggle = (tagId: string) => {
|
||||
const prev = selected;
|
||||
const next = new Set(prev);
|
||||
if (next.has(tagId)) next.delete(tagId);
|
||||
else next.add(tagId);
|
||||
setSelected(next);
|
||||
setResourceTags.mutate(
|
||||
{ targetType, targetId, tagIds: [...next] },
|
||||
{
|
||||
onError: (e) => {
|
||||
setSelected(prev);
|
||||
toast.error(String(e instanceof Error ? e.message : e));
|
||||
},
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t.adminTags.pickerTitle}</DialogTitle>
|
||||
<DialogDescription>{t.adminTags.pickerHint}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="min-h-[120px]">
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
<span>{t.common.loading}</span>
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && (!allTags || allTags.length === 0) && (
|
||||
<div className="text-muted-foreground py-10 text-center text-sm">
|
||||
{t.adminTags.pickerEmpty}
|
||||
</div>
|
||||
)}
|
||||
{!isLoading && allTags && allTags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{allTags.map((tag) => {
|
||||
const checked = selected.has(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggle(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm font-medium transition-all",
|
||||
color.chip,
|
||||
checked
|
||||
? "ring-2 ring-offset-1 ring-foreground/40"
|
||||
: "opacity-45 hover:opacity-90",
|
||||
)}
|
||||
>
|
||||
{checked && <CheckIcon className="size-3.5" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="items-center sm:justify-between">
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{setResourceTags.isPending ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Loader2 className="size-3 animate-spin" />
|
||||
{t.adminTags.saving}
|
||||
</span>
|
||||
) : (
|
||||
t.adminTags.pickerSelectedCount.replace(
|
||||
"{count}",
|
||||
String(selected.size),
|
||||
)
|
||||
)}
|
||||
</span>
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
{t.adminTags.done}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useTags, type TagType } from "@/core/tags";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const ALL = "__all__";
|
||||
|
||||
/**
|
||||
* Reusable name-search + tag-filter toolbar for resource list pages.
|
||||
*
|
||||
* The name input is debounced (300ms) before ``onSearchChange`` fires, so the
|
||||
* caller can feed the value straight into a server-side query without a
|
||||
* request per keystroke. Filtering is open to all users.
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagId,
|
||||
onTagIdChange,
|
||||
className,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagId: string | null;
|
||||
onTagIdChange: (value: string | null) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
useEffect(() => {
|
||||
const id = setTimeout(() => cbRef.current(input.trim()), 300);
|
||||
return () => clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative flex-1">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder={t.adminTags.searchPlaceholder}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
value={tagId ?? ALL}
|
||||
onValueChange={(v) => onTagIdChange(v === ALL ? null : v)}
|
||||
>
|
||||
<SelectTrigger className="w-44">
|
||||
<SelectValue placeholder={t.adminTags.filterByTag} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={ALL}>{t.adminTags.allTags}</SelectItem>
|
||||
{(tags ?? []).map((tag) => (
|
||||
<SelectItem key={tag.id} value={tag.id}>
|
||||
{tag.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -50,23 +50,23 @@ export function Welcome({
|
||||
</div>
|
||||
{searchParams.get("mode") === "skill" ? (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.welcome.createYourOwnSkillDescription.includes("\n") ? (
|
||||
{/* {t.welcome.createYourOwnSkillDescription.includes("\n") ? (
|
||||
<pre className="font-sans whitespace-pre">
|
||||
{t.welcome.createYourOwnSkillDescription}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{t.welcome.createYourOwnSkillDescription}</p>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground text-sm">
|
||||
{t.welcome.description.includes("\n") ? (
|
||||
{/* {t.welcome.description.includes("\n") ? (
|
||||
<pre className="font-sans whitespace-pre">
|
||||
{t.welcome.description}
|
||||
</pre>
|
||||
) : (
|
||||
<p>{t.welcome.description}</p>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare } from "lucide-react";
|
||||
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
@@ -10,11 +11,14 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { BotIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
|
||||
export function WorkspaceNavChatList() {
|
||||
const { t } = useI18n();
|
||||
const pathname = usePathname();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
return (
|
||||
<SidebarGroup className="px-2 pt-2">
|
||||
<SidebarMenu>
|
||||
@@ -48,6 +52,17 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/skills")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/skills">
|
||||
<SparklesIcon />
|
||||
<span>技能</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
@@ -59,6 +74,19 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
{isAdmin && (
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/admin")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
|
||||
<UsersIcon />
|
||||
<span>{t.sidebar.users}</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroup>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { ChevronsUpDown, Settings2Icon, SettingsIcon } from "lucide-react";
|
||||
import {
|
||||
BellIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -17,7 +26,12 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import { useUnreadCount } from "@/core/notifications";
|
||||
|
||||
import { NotificationDialog } from "./notification-dialog";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { SettingsDialog } from "./settings";
|
||||
|
||||
@@ -42,13 +56,17 @@ function NavMenuButtonContent({
|
||||
}
|
||||
|
||||
export function WorkspaceNavMenu() {
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = getStoredAuth()?.system_role === "admin";
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [settingsDefaultSection, setSettingsDefaultSection] = useState<
|
||||
"appearance" | "memory" | "tools" | "skills" | "notification" | "about"
|
||||
"appearance" | "memory" | "tools" | "about"
|
||||
>("appearance");
|
||||
const [notificationOpen, setNotificationOpen] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const { open: isSidebarOpen } = useSidebar();
|
||||
const { t } = useI18n();
|
||||
const { data: unreadCount = 0 } = useUnreadCount();
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -61,6 +79,10 @@ export function WorkspaceNavMenu() {
|
||||
onOpenChange={setSettingsOpen}
|
||||
defaultSection={settingsDefaultSection}
|
||||
/>
|
||||
<NotificationDialog
|
||||
open={notificationOpen}
|
||||
onOpenChange={setNotificationOpen}
|
||||
/>
|
||||
<SidebarMenu className="w-full">
|
||||
<SidebarMenuItem>
|
||||
{mounted ? (
|
||||
@@ -79,6 +101,15 @@ export function WorkspaceNavMenu() {
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => setNotificationOpen(true)}>
|
||||
<BellIcon />
|
||||
{t.notifications.title}
|
||||
{unreadCount > 0 && (
|
||||
<span className="ml-auto flex h-4 min-w-4 items-center justify-center rounded-full bg-red-500 px-1 text-[10px] font-medium leading-none text-white">
|
||||
{unreadCount > 99 ? "99+" : unreadCount}
|
||||
</span>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDefaultSection("appearance");
|
||||
@@ -88,6 +119,44 @@ export function WorkspaceNavMenu() {
|
||||
<Settings2Icon />
|
||||
{t.common.settings}
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/recommended-questions")
|
||||
}
|
||||
>
|
||||
<MessageSquarePlusIcon />
|
||||
推荐问题配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/prompt-prefix")
|
||||
}
|
||||
>
|
||||
<MessageSquareCodeIcon />
|
||||
问答前缀配置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/settings/llm-metrics")
|
||||
}
|
||||
>
|
||||
<GaugeIcon />
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
>
|
||||
<TagsIcon />
|
||||
{t.adminTags.title}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
</DropdownMenuContent>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* 后端 API 默认地址
|
||||
*/
|
||||
export const DEFAULT_BACKEND_URL = 'http://10.26.128.38:8030';
|
||||
|
||||
/**
|
||||
* 默认前端配置
|
||||
*
|
||||
* 包含:
|
||||
* - coordinator_config: 协调者各阶段策略提示
|
||||
* - agents: 默认四智能体(背景研判师、目的策略师、受众分析师、叙事策划师)
|
||||
* - report_schema: 六字段报告结构定义
|
||||
* - report_examples: 各字段的示例输出(问界 M7 事故场景)
|
||||
* - field_requirements: 字段依赖关系定义
|
||||
*/
|
||||
export const DEFAULT_CONFIG = {
|
||||
task_context: "",
|
||||
llm_preset_id: "",
|
||||
consistency_mode: "soft",
|
||||
coordinator_mode: "auto",
|
||||
coordinator_config: {
|
||||
system_prompt_prefix: "",
|
||||
round1_guidance: `这是发散阶段第1轮,草稿刚起步。
|
||||
策略:优先填写草稿中空缺最关键的字段,同时识别智能体之间最尖锐的分歧——
|
||||
这些分歧往往意味着草稿中某个判断还无法做出,需要通过交锋来解决。`,
|
||||
middle_round_guidance: `这是发散阶段的中间轮次。
|
||||
策略:审查草稿现状,找出仍然太模糊或有冲突的字段,
|
||||
选择能推进这些字段的智能体,给出让草稿变得更具体的任务。`,
|
||||
final_round_guidance: `这是发散阶段最后一轮。
|
||||
策略:草稿必须在这轮后达到可以收尾的状态,但仍要求智能体给出完整材料包,
|
||||
包括明确判断、依据链、反例、风险边界和可核验线索;最终压缩、取舍和成文由协调者完成。`,
|
||||
speaker_selection_rules: "优先选择最能扩充 partial/blocked 字段材料、解决关键冲突、补充证据或论证链的智能体。",
|
||||
focus_instruction_rules: "focus 必须写成内容型任务书,不能只催促 agent 继续分析。每条至少包含:目标字段、已有材料或草稿判断、真正缺口、为什么该缺口会阻塞报告、需要补充的具体材料类型、建议切入角度、预期输出形态、风险或禁止事项。指令应鼓励 agent 展开材料、证据线索、论证链、反例、分歧和边界条件,而不是只给短结论。",
|
||||
revise_prompt_prefix: ""
|
||||
},
|
||||
agents: [
|
||||
{
|
||||
id: "situation",
|
||||
name: "背景研判师",
|
||||
dimension: "背景研判",
|
||||
expertise: "你是一位资深的舆情和战略分析专家。你的任务是判断当前舆论场的格局:现在存在哪几条叙事,各自的势头如何,哪些认知锚点可以被打散,哪些比较维度可以被引入或重构,竞争对手的动作有哪些逻辑漏洞。不是做损失报告,而是做战略研判——找到可以操作的叙事空间。",
|
||||
},
|
||||
{
|
||||
id: "purpose",
|
||||
name: "目的策略师",
|
||||
dimension: "传播目标",
|
||||
expertise: "你是一位认知传播专家,专门设计可实现的传播目标——即通过传播内容能够在受众头脑中建立的具体认知或情绪。你不关心商业结果,只关心:这次传播结束后,目标受众的想法和感受会发生什么具体变化?用第一人称写出目标受众在成功传播后的内心独白。也可以设计竞争性目标(让受众对竞品产生质疑)。",
|
||||
},
|
||||
{
|
||||
id: "audience",
|
||||
name: "受众分析师",
|
||||
dimension: "受众图谱",
|
||||
expertise: "你是一位受众研究专家。你的任务是识别具体的、可操作的目标受众群体。拒绝泛化:不接受'年轻人''汽车爱好者'这类描述,必须给出年龄段、性别比例、城市层级、具体平台和具体账号/社区名称。同时要写出该群体当前的心理状态——他们现在在想什么,核心疑虑是什么,用第一人称写出他们的内心独白。",
|
||||
},
|
||||
{
|
||||
id: "narrative",
|
||||
name: "叙事策划师",
|
||||
dimension: "叙事方案",
|
||||
expertise: "你是一位顶尖的叙事战略家和内容策划师。你的任务是设计具体的叙事支柱:每个支柱必须包含(1)具体内容——发什么、怎么说,给出可执行的内容方向或文案草稿;(2)为什么这么设计——预期的心理机制;(3)预期效果——目标受众会产生什么认知变化;(4)风险与局限——在什么条件下会失效或反噬。叙事框架和具体消息写在一起,不要分开。",
|
||||
}
|
||||
],
|
||||
phase_config: { diverge_rounds: 3 },
|
||||
template_id: "",
|
||||
template_name: "",
|
||||
template_brief: "",
|
||||
report_schema: {
|
||||
situation_assessment: "背景研判:当前舆论场/市场/竞争格局的战略研判。包含现有几条并行叙事及其势头、可以被打散的认知锚点、竞争对手动作的逻辑漏洞、可操作的叙事空间。不是损失报告,是战略判断。",
|
||||
communication_objectives: "传播目标:2-4个本方案期望在受众中建立的具体认知或情绪,用目标受众的第一人称内心独白表达。不写商业结果,不写指标,只写认知和情绪目标。可含竞争性目标。",
|
||||
audience_map: "核心受众:2-4个具体受众群体,每个包含:(1)人口画像——年龄段、性别、城市层级、具体特征;(2)当前心理状态——用第一人称内心独白;(3)关键信息节点——具体平台和账号/社区名称,不接受泛化描述。",
|
||||
narrative_plan: "叙事方案:2-3个叙事支柱,每个包含:(1)具体内容——可执行的内容方向或文案草稿;(2)为什么这么设计——预期心理机制;(3)预期效果——目标受众的认知变化;(4)风险与局限——失效条件。叙事框架和消息合并在一起。",
|
||||
action_plan: "分阶段行动计划:按时间线的3-4个阶段,每个行动具体到:做什么、谁执行(官方/第三方/独立账号)、何时。",
|
||||
risk_playbook: "风险预案:3-5个风险场景,每个含触发条件、具体应对步骤、明确禁止的动作。"
|
||||
},
|
||||
report_examples: {
|
||||
situation_assessment: `事故发生后,舆论场中存在三条并行叙事:
|
||||
- 主流情绪叙事(当前占主导):"电动车关门困死人"——聚焦电动门锁这一单一缺陷,将问界塑造为主动伤人的产品
|
||||
- 竞品借势叙事:理想、小米相继发声强调"物理门把手不会消失",将安全心智差异化
|
||||
- 技术中性叙事(目前声量极弱):部分汽车工程师账号指出燃油车追尾同样可能起火且无法逃生,但未被放大
|
||||
|
||||
核心判断:目前的"关门困死人"叙事对问界的伤害远超事故本身,因为它指向的是所有电动车门锁的系统性问题,而非这次追尾的偶发性。叙事反转的关键在于:将"问界门锁缺陷"重新定框为"追尾高速起火是极端工况,不同于日常安全风险",同时打开"燃油车在同等追尾条件下结果是否更好"这一比较维度。
|
||||
|
||||
此外,竞品的"物理门把手"营销动作,实质上是在暗示所有纯电动车都有同等风险——问界可以借助行业数据,将这一模糊指控反向用于质疑竞品的安全宣传逻辑。`,
|
||||
|
||||
communication_objectives: `目标一:让现有问界车主相信此次事故属于极端偶发工况
|
||||
> "这是一辆车被高速追尾、车身严重变形后才发生的,不是日常开车会遇到的情况,和我每天通勤的安全性是两件事。"
|
||||
|
||||
目标二:让公众了解燃油车在同等追尾工况下的起火概率并不低于电动车
|
||||
> "我以为电动车特别容易起火,看了数据才知道燃油车追尾也会爆炸,而且逃生时间更短。这个问题不是问界独有的。"
|
||||
|
||||
目标三:让潜在购车者相信问界已做出真实的技术改进,而非只是公关操作
|
||||
> "他们不只是道歉,而是真的改了——门锁逻辑改了,还加了物理把手,有第三方测了。这和之前那些只道歉不改的品牌不一样。"
|
||||
|
||||
目标四(竞争性):在科技圈和华为生态用户中种下"竞品安全宣传是过度营销"的质疑
|
||||
> "理想说保留物理门把手就安全了?那燃油车追尾不也照样困死人,他们这是蹭热度。"`,
|
||||
|
||||
audience_map: `受众一:现有问界M7车主(最优先)
|
||||
- 画像:25-45岁男性为主(问界买家中男性占比约75%),华为手机深度用户,在二三线城市比例高于理想/小米,家庭用车场景为主
|
||||
- 当前心理状态:"在家人追问压力下动摇,既不想认错(承认买错了),又需要一个可以说服家人的说法"
|
||||
- 关键触点:AITO官方App推送、华为花粉俱乐部社区(注册用户2000万+)、各省市问界车主微信群
|
||||
|
||||
受众二:考虑30万内家用SUV的25-38岁已婚男性(主要流失群体)
|
||||
- 画像:集中在北京、上海、成都、杭州、武汉;在懂车帝/汽车之家浏览过问界M7、理想L7对比页面;家庭有6岁以下子女的比例约40%,配偶对选车有实质否决权
|
||||
- 当前心理状态:"事故前已接近下定,现在进入观望,主要疑虑是'安全有保障吗',在等一个可以放心的理由"
|
||||
- 关键触点:懂车帝的问界M7评测区(日均浏览约4万次)、B站@42号车库(汽车技术向,260万粉)、@老司机测评(抖音+微博,180万粉)`,
|
||||
|
||||
narrative_plan: `叙事支柱一:工况重构——把"电动门锁缺陷"扩展为"追尾高速起火"议题
|
||||
|
||||
具体内容:发布或推动媒体发布《追尾高速事故生存数据分析》,引用可验证的事故数据:中国高速追尾事故中燃油车起火比例、燃油车在高速追尾后因油箱破裂导致无法逃生的案例、断电门锁问题在行业内的普遍性(其他品牌纯电车是否存在类似设计)。
|
||||
|
||||
为什么这么设计:公众对"电动车起火"的恐惧,部分来自"燃油车才是安全默认值"的认知锚点。此叙事目标不是洗白问界,而是打散原有的比较基准——把"问界门锁失效"从"品牌失职"变成"高速追尾这一极端工况下的行业性挑战"。
|
||||
|
||||
预期效果:目标受众在搜索"问界安全"时,搜到的不是事故视频,而是工况对比分析,认知从"问界车会困死人"转向"高速追尾是极端事故,概率极低"。
|
||||
|
||||
风险与局限:如果数据无法支持"燃油车风险不低于电动车"的结论,此叙事会反噬——数据必须真实可查。部分受众会将此解读为"转移焦点"。`,
|
||||
|
||||
action_plan: `第一阶段:止血(D+0 至 D+3)
|
||||
- D+0:余承东删除原有争议微博。AITO官方发布声明,核心措辞:承认断电门锁释放机制有改进空间,公布将与第三方机构联合调查,暂停全部品牌宣传广告
|
||||
- D+1:通过APP+服务顾问向全体问界M7车主发送一对一信息,提供免费安全检测预约和免费代步车
|
||||
- D+2:联系@42号车库、@老司机测评,提供完整的碰撞技术资料(不要求立场,只提供信息)
|
||||
|
||||
第二阶段:叙事建立(第1-4周)
|
||||
- 第1周:推动@42号车库发布《追尾高速起火,燃油车和电动车谁更危险?》技术向内容,问界提供数据但不参与内容制作
|
||||
- 第2周:举办技术说明会,邀请10家垂直媒体,公布OTA升级方案和物理把手加装方案详细规格
|
||||
- 第3周:中国汽研开始改进后车辆的独立测试,全程开放媒体记者跟拍`,
|
||||
|
||||
risk_playbook: `风险一:12周内发生第二起相似事故
|
||||
- 触发条件:同类门锁失效导致的伤亡事故再次出现
|
||||
- 应对步骤:①立即暂停全部销售;②主动召回所有已售问界M7,不等监管要求;③停止所有叙事支柱三(竞品对比)内容的推进
|
||||
- 明确禁止:不得发布任何暗示"概率极低"的安抚性内容
|
||||
|
||||
风险二:数据核查显示燃油车追尾起火比例低于电动车
|
||||
- 触发条件:第三方核查发现叙事支柱一中引用的数据不成立
|
||||
- 应对步骤:①立即停止涉及数据比较的传播;②调整叙事回归"我们改进了"主线,放弃工况比较维度
|
||||
- 明确禁止:不得继续引用已被质疑的数据,不得删除原文(会被截图放大)`
|
||||
},
|
||||
field_requirements: {
|
||||
communication_objectives: {
|
||||
title: "传播目标",
|
||||
dependencies: ["situation_assessment"],
|
||||
},
|
||||
audience_map: {
|
||||
title: "核心受众",
|
||||
dependencies: ["communication_objectives"],
|
||||
},
|
||||
narrative_plan: {
|
||||
title: "叙事方案",
|
||||
dependencies: ["communication_objectives", "audience_map"],
|
||||
},
|
||||
action_plan: {
|
||||
title: "分阶段行动计划",
|
||||
dependencies: ["communication_objectives", "audience_map", "narrative_plan"],
|
||||
},
|
||||
risk_playbook: {
|
||||
title: "风险预案",
|
||||
dependencies: ["narrative_plan", "action_plan"],
|
||||
},
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,96 @@
|
||||
/** localStorage 存储前缀,用于模板覆盖数据的 key */
|
||||
const OVERRIDE_PREFIX = 'mope_template_override:';
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* 使用 JSON 序列化/反序列化实现简单深拷贝,
|
||||
* 适用于纯数据的配置对象(不含函数、循环引用等)。
|
||||
*/
|
||||
export function clone(value) {
|
||||
return JSON.parse(JSON.stringify(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 从后端加载模板索引列表
|
||||
* @returns {Promise<Array>} 模板摘要数组 [{ template_id, template_name, file }]
|
||||
*/
|
||||
export async function loadTemplateIndex() {
|
||||
const response = await fetch('/templates/index.json');
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const data = await response.json();
|
||||
return data.templates || [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载完整模板数据
|
||||
* 优先使用 localStorage 中的用户覆盖版本,
|
||||
* 否则从后端静态文件加载。
|
||||
*
|
||||
* @param {string|Object} metaOrId - 模板 ID 字符串或包含 template_id/file 的元数据对象
|
||||
* @returns {Promise<Object>} 完整模板对象
|
||||
*/
|
||||
export async function loadTemplateByMeta(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const template = await response.json();
|
||||
const override = getTemplateOverride(template.template_id || templateId);
|
||||
return override ? clone(override) : template;
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载原始模板(忽略用户覆盖)
|
||||
* @param {string|Object} metaOrId - 模板标识
|
||||
* @returns {Promise<Object>} 后端静态模板
|
||||
*/
|
||||
export async function loadOriginalTemplate(metaOrId) {
|
||||
const templateId = typeof metaOrId === 'string' ? metaOrId : metaOrId?.template_id;
|
||||
const file = typeof metaOrId === 'string' ? `/templates/${templateId}.json` : metaOrId?.file;
|
||||
const response = await fetch(file || `/templates/${templateId}.json`);
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
return response.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户在 localStorage 中保存的模板覆盖数据
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {Object|null} 覆盖的模板对象,未覆盖时返回 null
|
||||
*/
|
||||
export function getTemplateOverride(templateId) {
|
||||
if (!templateId) return null;
|
||||
try {
|
||||
const raw = localStorage.getItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
return raw ? JSON.parse(raw) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断指定模板是否已被用户覆盖修改
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function hasTemplateOverride(templateId) {
|
||||
return Boolean(getTemplateOverride(templateId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存模板覆盖到 localStorage
|
||||
* @param {string} templateId - 模板 ID
|
||||
* @param {Object} template - 修改后的模板对象
|
||||
*/
|
||||
export function saveTemplateOverride(templateId, template) {
|
||||
if (!templateId) throw new Error('缺少 template_id');
|
||||
localStorage.setItem(`${OVERRIDE_PREFIX}${templateId}`, JSON.stringify(template));
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定模板的覆盖数据,恢复为后端原始版本
|
||||
* @param {string} templateId - 模板 ID
|
||||
*/
|
||||
export function clearTemplateOverride(templateId) {
|
||||
if (!templateId) return;
|
||||
localStorage.removeItem(`${OVERRIDE_PREFIX}${templateId}`);
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
timestamp: number;
|
||||
type?: 'general' | 'agent' | 'compare';
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
interface ChatContextType {
|
||||
messages: ChatMessage[];
|
||||
addMessage: (role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => void;
|
||||
clearMessages: () => void;
|
||||
deleteMessage: (id: string) => void;
|
||||
}
|
||||
|
||||
const ChatContext = createContext<ChatContextType | undefined>(undefined);
|
||||
|
||||
export function ChatProvider({ children }: { children: ReactNode }) {
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem('qa-chat-history');
|
||||
return stored ? JSON.parse(stored) : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
const saveToStorage = useCallback((newMessages: ChatMessage[]) => {
|
||||
try {
|
||||
localStorage.setItem('qa-chat-history', JSON.stringify(newMessages));
|
||||
} catch (error) {
|
||||
console.error('Failed to save chat history:', error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const addMessage = useCallback((role: 'user' | 'assistant', content: string, type?: 'general' | 'agent' | 'compare', agentName?: string) => {
|
||||
const newMessage: ChatMessage = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
||||
role,
|
||||
content,
|
||||
timestamp: Date.now(),
|
||||
type,
|
||||
agentName,
|
||||
};
|
||||
setMessages((prev) => {
|
||||
const updated = [...prev, newMessage];
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
const clearMessages = useCallback(() => {
|
||||
setMessages([]);
|
||||
localStorage.removeItem('qa-chat-history');
|
||||
}, []);
|
||||
|
||||
const deleteMessage = useCallback((id: string) => {
|
||||
setMessages((prev) => {
|
||||
const updated = prev.filter((msg) => msg.id !== id);
|
||||
saveToStorage(updated);
|
||||
return updated;
|
||||
});
|
||||
}, [saveToStorage]);
|
||||
|
||||
return (
|
||||
<ChatContext.Provider value={{ messages, addMessage, clearMessages, deleteMessage }}>
|
||||
{children}
|
||||
</ChatContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatContext() {
|
||||
const context = useContext(ChatContext);
|
||||
if (context === undefined) {
|
||||
throw new Error('useChatContext must be used within a ChatProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
|
||||
// 主题类型
|
||||
export type ThemeType = 'dark' | 'light';
|
||||
|
||||
// 主题配置
|
||||
export interface ThemeConfig {
|
||||
type: ThemeType;
|
||||
name: string;
|
||||
colors: {
|
||||
background: string;
|
||||
backgroundSecondary: string;
|
||||
text: string;
|
||||
textSecondary: string;
|
||||
primary: string;
|
||||
primaryHover: string;
|
||||
border: string;
|
||||
accent: string;
|
||||
success: string;
|
||||
warning: string;
|
||||
error: string;
|
||||
card: string;
|
||||
cardHover: string;
|
||||
input: string;
|
||||
inputBorder: string;
|
||||
overlay: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 主题配置
|
||||
const themes: Record<ThemeType, ThemeConfig> = {
|
||||
dark: {
|
||||
type: 'dark',
|
||||
name: '深色主题',
|
||||
colors: {
|
||||
background: 'slate-900',
|
||||
backgroundSecondary: 'slate-800',
|
||||
text: 'white',
|
||||
textSecondary: 'slate-400',
|
||||
primary: 'blue-500',
|
||||
primaryHover: 'blue-400',
|
||||
border: 'slate-700',
|
||||
accent: 'blue-600',
|
||||
success: 'emerald-500',
|
||||
warning: 'amber-500',
|
||||
error: 'rose-500',
|
||||
card: 'slate-800',
|
||||
cardHover: 'slate-700',
|
||||
input: 'slate-800',
|
||||
inputBorder: 'slate-600',
|
||||
overlay: 'slate-900/80',
|
||||
},
|
||||
},
|
||||
light: {
|
||||
type: 'light',
|
||||
name: '红白主题',
|
||||
colors: {
|
||||
background: 'white',
|
||||
backgroundSecondary: 'red-50',
|
||||
text: 'gray-900',
|
||||
textSecondary: 'gray-600',
|
||||
primary: 'red-600',
|
||||
primaryHover: 'red-500',
|
||||
border: 'red-200',
|
||||
accent: 'red-700',
|
||||
success: 'green-600',
|
||||
warning: 'amber-500',
|
||||
error: 'red-600',
|
||||
card: 'white',
|
||||
cardHover: 'red-50',
|
||||
input: 'white',
|
||||
inputBorder: 'red-200',
|
||||
overlay: 'black/30',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
// 主题上下文
|
||||
interface ThemeContextType {
|
||||
theme: ThemeType;
|
||||
themeConfig: ThemeConfig;
|
||||
toggleTheme: () => void;
|
||||
setTheme: (theme: ThemeType) => void;
|
||||
}
|
||||
|
||||
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||
|
||||
// 主题提供者
|
||||
interface ThemeProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
|
||||
const [theme, setThemeState] = useState<ThemeType>(() => {
|
||||
// 从 localStorage 读取保存的主题
|
||||
const saved = localStorage.getItem('strategy-theme');
|
||||
return (saved as ThemeType) || 'dark';
|
||||
});
|
||||
|
||||
// 切换主题
|
||||
const toggleTheme = () => {
|
||||
setThemeState((prev) => (prev === 'dark' ? 'light' : 'dark'));
|
||||
};
|
||||
|
||||
// 设置特定主题
|
||||
const setTheme = (newTheme: ThemeType) => {
|
||||
setThemeState(newTheme);
|
||||
};
|
||||
|
||||
// 保存到 localStorage
|
||||
useEffect(() => {
|
||||
localStorage.setItem('strategy-theme', theme);
|
||||
// 更新文档类名
|
||||
document.documentElement.classList.remove('dark', 'light');
|
||||
document.documentElement.classList.add(theme);
|
||||
}, [theme]);
|
||||
|
||||
const themeConfig = themes[theme];
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={{ theme, themeConfig, toggleTheme, setTheme }}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
// 使用主题的 Hook
|
||||
export const useTheme = (): ThemeContextType => {
|
||||
const context = useContext(ThemeContext);
|
||||
if (!context) {
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// 主题工具函数 - 生成动态类名
|
||||
export const getThemeClasses = (colorKey: keyof ThemeConfig['colors']): string => {
|
||||
const { themeConfig } = useTheme();
|
||||
return themeConfig.colors[colorKey];
|
||||
};
|
||||
|
||||
// 组合类名工具函数
|
||||
export const cn = (...classes: (string | undefined | null | false)[]): string => {
|
||||
return classes.filter(Boolean).join(' ');
|
||||
};
|
||||
@@ -15,8 +15,19 @@ export class AgentNameCheckError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export async function listAgents(): Promise<Agent[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/agents`);
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function listAgents(params: ListAgentsParams = {}): Promise<Agent[]> {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load agents: ${res.statusText}`);
|
||||
const data = (await res.json()) as { agents: Agent[] };
|
||||
return data.agents;
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createAgent,
|
||||
@@ -9,10 +14,13 @@ import {
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents() {
|
||||
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents"],
|
||||
queryFn: () => listAgents(),
|
||||
queryKey: ["agents", search, tagId],
|
||||
queryFn: () => listAgents({ search, tagId }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -11,6 +13,7 @@ export interface Agent {
|
||||
model?: string | null;
|
||||
tool_groups?: string[] | null;
|
||||
skills?: string[] | null;
|
||||
tags?: Tag[];
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import { urlOfArtifact } from "./utils";
|
||||
|
||||
async function errorMessageFromResponse(res: Response): Promise<string> {
|
||||
try {
|
||||
const data: unknown = await res.json();
|
||||
if (data && typeof data === "object" && "detail" in data) {
|
||||
const detail = (data as { detail: unknown }).detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
}
|
||||
} catch {
|
||||
// ignore non-json errors
|
||||
}
|
||||
return res.statusText || "Request failed";
|
||||
}
|
||||
|
||||
export async function updateArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
content,
|
||||
isMock,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
content: string;
|
||||
isMock?: boolean;
|
||||
}) {
|
||||
const res = await apiFetch(urlOfArtifact({ filepath, threadId, isMock }), {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ content }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
}
|
||||
return res.text();
|
||||
}
|
||||
@@ -1,9 +1,65 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
function hasToolResult(thread: ReturnType<typeof useThread>["thread"], toolCallId?: string) {
|
||||
if (!toolCallId) {
|
||||
return false;
|
||||
}
|
||||
return thread.messages.some(
|
||||
(message) => message.type === "tool" && message.tool_call_id === toolCallId,
|
||||
);
|
||||
}
|
||||
|
||||
function applyPendingTextEdit({
|
||||
content,
|
||||
filepath,
|
||||
thread,
|
||||
}: {
|
||||
content: string | undefined;
|
||||
filepath: string;
|
||||
thread: ReturnType<typeof useThread>["thread"];
|
||||
}) {
|
||||
if (!content || !thread.isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const message of [...thread.messages].reverse()) {
|
||||
if (message.type !== "ai" || !message.tool_calls) {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of [...message.tool_calls].reverse()) {
|
||||
if (toolCall.name !== "str_replace" || hasToolResult(thread, toolCall.id)) {
|
||||
continue;
|
||||
}
|
||||
const args = toolCall.args as {
|
||||
path?: unknown;
|
||||
old_str?: unknown;
|
||||
new_str?: unknown;
|
||||
replace_all?: unknown;
|
||||
};
|
||||
if (args.path !== filepath) {
|
||||
continue;
|
||||
}
|
||||
if (typeof args.old_str !== "string" || typeof args.new_str !== "string") {
|
||||
continue;
|
||||
}
|
||||
if (!content.includes(args.old_str)) {
|
||||
return { content, kind: "editing" as const };
|
||||
}
|
||||
const nextContent =
|
||||
args.replace_all === true
|
||||
? content.split(args.old_str).join(args.new_str)
|
||||
: content.replace(args.old_str, args.new_str);
|
||||
return { content: nextContent, kind: "editing" as const };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
@@ -25,19 +81,48 @@ export function useArtifactContent({
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
enabled,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
return {
|
||||
content: isWriteFile ? content : data?.content,
|
||||
url: isWriteFile ? undefined : data?.url,
|
||||
isLoading,
|
||||
error,
|
||||
};
|
||||
}
|
||||
enabled,
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
const pendingEdit = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return null;
|
||||
}
|
||||
return applyPendingTextEdit({
|
||||
content: data?.content,
|
||||
filepath,
|
||||
thread,
|
||||
});
|
||||
}, [data?.content, filepath, isWriteFile, thread]);
|
||||
|
||||
return {
|
||||
content: isWriteFile ? content : (pendingEdit?.content ?? data?.content),
|
||||
url: isWriteFile ? undefined : data?.url,
|
||||
isLoading,
|
||||
isApplyingEdit: pendingEdit?.kind === "editing",
|
||||
error,
|
||||
};
|
||||
}
|
||||
|
||||
export function useUpdateArtifactContent() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: updateArtifactContent,
|
||||
onSuccess: (content, variables) => {
|
||||
queryClient.setQueryData(
|
||||
["artifact", variables.filepath, variables.threadId, variables.isMock],
|
||||
{
|
||||
content,
|
||||
url: undefined,
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
export * from "./download";
|
||||
export * from "./loader";
|
||||
export * from "./api";
|
||||
export * from "./download";
|
||||
export * from "./loader";
|
||||
@@ -3,6 +3,20 @@ import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type { LoginByUsernameResponse } from "./index";
|
||||
|
||||
export interface UserListItem {
|
||||
id: string;
|
||||
email: string;
|
||||
system_role: "admin" | "user";
|
||||
skill_count: number;
|
||||
agent_count: number;
|
||||
}
|
||||
|
||||
export async function listAllUsers(): Promise<UserListItem[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/v1/auth/users`);
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
||||
return res.json() as Promise<UserListItem[]>;
|
||||
}
|
||||
|
||||
export async function loginByUsername(
|
||||
username: string,
|
||||
): Promise<LoginByUsernameResponse> {
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { listAllUsers } from "./api";
|
||||
|
||||
export function useAllUsers(enabled: boolean) {
|
||||
return useQuery({
|
||||
queryKey: ["admin", "users"],
|
||||
queryFn: listAllUsers,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
CuratorConfig,
|
||||
CuratorLogs,
|
||||
CuratorStatus,
|
||||
} from "./types";
|
||||
|
||||
export async function getCuratorStatus(): Promise<CuratorStatus> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/status`);
|
||||
return response.json() as Promise<CuratorStatus>;
|
||||
}
|
||||
|
||||
export async function getCuratorConfig(): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`);
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function updateCuratorConfig(
|
||||
payload: CuratorConfig,
|
||||
): Promise<CuratorConfig> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/config`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return response.json() as Promise<CuratorConfig>;
|
||||
}
|
||||
|
||||
export async function runCurator(): Promise<{ triggered: boolean; message: string }> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/run`, {
|
||||
method: "POST",
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
|
||||
export async function getCuratorLogs(): Promise<CuratorLogs> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/curator/logs`);
|
||||
return response.json() as Promise<CuratorLogs>;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
import {
|
||||
getCuratorConfig,
|
||||
getCuratorLogs,
|
||||
getCuratorStatus,
|
||||
runCurator,
|
||||
updateCuratorConfig,
|
||||
} from "./api";
|
||||
import type { CuratorConfig } from "./types";
|
||||
|
||||
// All curator-related queries are per-user data (status / config / logs all
|
||||
// live under .deer-flow/users/{user_id}/), so the cache key MUST be scoped by
|
||||
// user_id. Otherwise switching accounts in the same tab would let user B
|
||||
// briefly read user A's cached values — and clicking "save" on that stale
|
||||
// frame would silently overwrite user B's real config with user A's values.
|
||||
function useCuratorKeys() {
|
||||
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
|
||||
return useMemo(
|
||||
() => ({
|
||||
status: ["curator", userId, "status"] as const,
|
||||
config: ["curator", userId, "config"] as const,
|
||||
logs: ["curator", userId, "logs"] as const,
|
||||
}),
|
||||
[userId],
|
||||
);
|
||||
}
|
||||
|
||||
export function useCuratorStatus() {
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.status,
|
||||
queryFn: getCuratorStatus,
|
||||
});
|
||||
return { status: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useCuratorConfig() {
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.config,
|
||||
queryFn: getCuratorConfig,
|
||||
});
|
||||
return { config: query.data, isLoading: query.isLoading, error: query.error };
|
||||
}
|
||||
|
||||
export function useUpdateCuratorConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: (payload: CuratorConfig) => updateCuratorConfig(payload),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.config });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRunCurator() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useCuratorKeys();
|
||||
return useMutation({
|
||||
mutationFn: () => runCurator(),
|
||||
onSuccess: () => {
|
||||
// Logs start from scratch on a new run — refresh so the UI clears
|
||||
// immediately and then picks up the new run_started record.
|
||||
void queryClient.invalidateQueries({ queryKey: keys.logs });
|
||||
void queryClient.invalidateQueries({ queryKey: keys.status });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCuratorLogs(options?: { autoRefresh?: boolean }) {
|
||||
const autoRefresh = options?.autoRefresh ?? true;
|
||||
const keys = useCuratorKeys();
|
||||
const query = useQuery({
|
||||
queryKey: keys.logs,
|
||||
queryFn: getCuratorLogs,
|
||||
refetchInterval: autoRefresh ? 2000 : false,
|
||||
});
|
||||
return {
|
||||
logs: query.data,
|
||||
isLoading: query.isLoading,
|
||||
error: query.error,
|
||||
refetch: query.refetch,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
export interface CuratorSubConfig {
|
||||
enabled: boolean;
|
||||
interval_hours: number;
|
||||
stale_after_days: number;
|
||||
archive_after_days: number;
|
||||
}
|
||||
|
||||
export interface CuratorConfig {
|
||||
enabled: boolean;
|
||||
moderation_model_name: string | null;
|
||||
curator: CuratorSubConfig;
|
||||
}
|
||||
|
||||
export interface CuratorAction {
|
||||
tool: string | null;
|
||||
args: Record<string, unknown>;
|
||||
status: string;
|
||||
result: string;
|
||||
}
|
||||
|
||||
export interface CuratorConsolidation {
|
||||
from?: string;
|
||||
into?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CuratorPruning {
|
||||
name?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CuratorSuggestions {
|
||||
consolidations: CuratorConsolidation[];
|
||||
prunings: CuratorPruning[];
|
||||
}
|
||||
|
||||
export interface CuratorStatus {
|
||||
enabled: boolean;
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
last_run_summary: string | null;
|
||||
paused: boolean;
|
||||
agent_created_skills: {
|
||||
total: number;
|
||||
active: number;
|
||||
stale: number;
|
||||
archived: number;
|
||||
};
|
||||
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
|
||||
last_consolidation_suggestions: CuratorSuggestions;
|
||||
/** 上次运行的真实工具调用记录(它"实际"做了什么)。 */
|
||||
last_actual_actions: CuratorAction[];
|
||||
}
|
||||
|
||||
export interface CuratorLogRecord {
|
||||
ts: string;
|
||||
level: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface CuratorLogs {
|
||||
records: CuratorLogRecord[];
|
||||
run_count: number;
|
||||
last_run_at: string | null;
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export const enUS: Translations = {
|
||||
recentChats: "Recent chats",
|
||||
demoChats: "Demo chats",
|
||||
agents: "Agents",
|
||||
users: "Users",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -434,10 +435,77 @@ export const enUS: Translations = {
|
||||
"Manage the configuration and enabled status of the agent skills.",
|
||||
createSkill: "Create skill",
|
||||
installSkill: "Install skill",
|
||||
goToEvolution: "Skill evolution",
|
||||
emptyTitle: "No agent skill yet",
|
||||
emptyDescription:
|
||||
"Put your agent skill folders under the `/skills/custom` folder under the root folder of CM Assistant.",
|
||||
emptyButton: "Create Your First Skill",
|
||||
badgeBuiltin: "Built-in",
|
||||
badgeMine: "Mine",
|
||||
badgeFromOthers: "From others",
|
||||
badgeFromOthersTitle: "Owned by user {user_id}",
|
||||
badgePublished: "Published",
|
||||
badgePublishedTitle: "Visible to all users",
|
||||
badgeStale: "Stale",
|
||||
badgeStaleTitle: "Idle for a long time — flagged by the curator",
|
||||
badgeArchived: "Archived",
|
||||
badgeArchivedTitle: "Archived — recoverable from the archive drawer",
|
||||
badgePinned: "Pinned",
|
||||
badgePinnedTitle: "Pinned — the curator will never auto-archive this skill",
|
||||
badgeSourceAgent: "Agent-created",
|
||||
badgeSourceAgentTitle: "Auto-generated by an agent during a conversation",
|
||||
badgeSourceUpload: "Uploaded",
|
||||
badgeSourceUploadTitle: "Manually uploaded or edited by a user",
|
||||
tabAll: "Square",
|
||||
tabMine: "Mine",
|
||||
entryStale: "Stale {count}",
|
||||
entryArchived: "Archived {count}",
|
||||
useCountInline: "Used {count} times",
|
||||
useCountInlineZero: "Not used yet",
|
||||
publishAction: "Publish",
|
||||
unpublishAction: "Unpublish",
|
||||
publishSuccess: "Skill published",
|
||||
unpublishSuccess: "Skill unpublished",
|
||||
pinAction: "Pin (prevent auto-archive)",
|
||||
unpinAction: "Unpin",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
deleteAction: "Delete skill",
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
deleteSuccess: "Skill deleted",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
archivedDescription:
|
||||
"These skills have been archived by you or the curator and can be restored at any time.",
|
||||
staleTitle: "Stale skills",
|
||||
staleDescription:
|
||||
"These skills have been idle for a while and were flagged by the curator. Using one again will auto-revive it; you can also act on them here.",
|
||||
empty: "Nothing here",
|
||||
actionRestore: "Restore",
|
||||
actionArchiveNow: "Archive now",
|
||||
actionView: "View",
|
||||
actionPin: "Pin",
|
||||
actionUnpin: "Unpin",
|
||||
actionDelete: "Delete permanently",
|
||||
moreMenu: "More",
|
||||
archivedAt: "Archived {when}",
|
||||
lastUsedAt: "Last used {when}",
|
||||
neverUsed: "Never used",
|
||||
restoreSuccess: "Restored",
|
||||
archiveSuccess: "Archived",
|
||||
pinSuccess: "Pinned",
|
||||
unpinSuccess: "Unpinned",
|
||||
},
|
||||
viewDialog: {
|
||||
title: "Skill details",
|
||||
descriptionLabel: "Description",
|
||||
stateLabel: "State",
|
||||
sourceLabel: "Source",
|
||||
useCountLabel: "Use count",
|
||||
lastUsedLabel: "Last used",
|
||||
close: "Close",
|
||||
},
|
||||
installDialog: {
|
||||
title: "Install skill",
|
||||
dropHint: "Drop a .zip or .skill archive here, or",
|
||||
@@ -456,9 +524,77 @@ export const enUS: Translations = {
|
||||
"A custom skill named '{name}' is already installed. Use 'Replace existing' to overwrite (its enabled state will be kept).",
|
||||
conflictPublic:
|
||||
"A built-in public skill named '{name}' already exists and cannot be overwritten.",
|
||||
conflictPublicAdminHint:
|
||||
"A built-in public skill named '{name}' exists. Switch to 'Built-in' and click 'Replace existing' to overwrite.",
|
||||
installSuccess: "Skill '{name}' installed.",
|
||||
installSuccessReplaced: "Skill '{name}' replaced.",
|
||||
rejectInvalidExt: "Only .zip or .skill files are accepted.",
|
||||
targetCategoryLabel: "Install to",
|
||||
categoryCustom: "Custom skill",
|
||||
categoryPublic: "Built-in (admin)",
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "Skill Evolution",
|
||||
description:
|
||||
"Manage the skill curator: let the agent age out, archive, and consolidate its self-created skills in the background.",
|
||||
backToSkills: "Back to skills",
|
||||
status: {
|
||||
title: "Status",
|
||||
enabled: "Enabled",
|
||||
disabled: "Disabled",
|
||||
paused: "Paused",
|
||||
runCount: "Total runs",
|
||||
runCountUnit: "",
|
||||
lastRunAt: "Last run",
|
||||
lastRunNever: "Never",
|
||||
lastRunSummary: "Last summary",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "Skills",
|
||||
active: "Active",
|
||||
stale: "Stale",
|
||||
archived: "Archived",
|
||||
total: "Total",
|
||||
runNow: "Run now",
|
||||
runStarted: "Background run started",
|
||||
lastActions: "Last run",
|
||||
llmSuggested: "LLM suggested (self-report)",
|
||||
actuallyDid: "Actually done (tool calls)",
|
||||
noActions: "None",
|
||||
},
|
||||
config: {
|
||||
title: "Configuration",
|
||||
description:
|
||||
"Saves to skill_evolution.json and hot-reloads — no restart required.",
|
||||
enabledLabel: "Enable skill evolution",
|
||||
enabledHelper:
|
||||
"When off, the agent cannot create or modify skills under custom/.",
|
||||
curatorEnabledLabel: "Curator runs automatically",
|
||||
curatorEnabledHelper:
|
||||
"When off, the curator only runs when triggered from this page.",
|
||||
intervalHoursLabel: "Run interval (hours)",
|
||||
intervalHoursHelper:
|
||||
"Minimum hours between automatic curator runs.",
|
||||
staleAfterDaysLabel: "Stale threshold (days)",
|
||||
staleAfterDaysHelper:
|
||||
"Skills inactive for this many days are flagged stale.",
|
||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||
archiveAfterDaysHelper:
|
||||
"Stale skills inactive for this many additional days are archived.",
|
||||
moderationModelLabel: "Moderation model (optional)",
|
||||
moderationModelHelper:
|
||||
"Leave blank to use the default chat model.",
|
||||
save: "Save changes",
|
||||
saveSuccess: "Configuration updated",
|
||||
reset: "Reset",
|
||||
},
|
||||
logs: {
|
||||
title: "Recent run logs",
|
||||
description:
|
||||
"Latest curator run only; cleared on backend restart.",
|
||||
empty: "No logs yet. Clicking 'Run now' will populate this panel.",
|
||||
autoRefresh: "Auto-refresh (2s)",
|
||||
refresh: "Refresh now",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
@@ -479,4 +615,63 @@ export const enUS: Translations = {
|
||||
emptyDescription: "Credits and acknowledgements will show here.",
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "User Management",
|
||||
description: "View all registered users and their associated skill and agent counts.",
|
||||
email: "Email",
|
||||
role: "Role",
|
||||
userId: "User ID",
|
||||
skillCount: "Skills",
|
||||
agentCount: "Agents",
|
||||
roleAdmin: "Admin",
|
||||
roleUser: "User",
|
||||
empty: "No users found",
|
||||
},
|
||||
|
||||
adminTags: {
|
||||
title: "Tag Management",
|
||||
description: "Create and manage tags for agents, skills, and scheduled tasks. A tag may only be applied to resources of its own type.",
|
||||
typeAgent: "Agent Tags",
|
||||
typeSkill: "Skill Tags",
|
||||
typeScheduledTask: "Scheduled Task Tags",
|
||||
name: "Name",
|
||||
color: "Color",
|
||||
tagDescription: "Description",
|
||||
usageCount: "Usage",
|
||||
actions: "Actions",
|
||||
empty: "No tags yet",
|
||||
createButton: "New Tag",
|
||||
createTitle: "Create Tag",
|
||||
editTitle: "Edit Tag",
|
||||
deleteConfirm: "Delete this tag? It will be removed from every resource it tags.",
|
||||
namePlaceholder: "Tag name",
|
||||
descriptionPlaceholder: "Optional description",
|
||||
save: "Save",
|
||||
cancel: "Cancel",
|
||||
edit: "Edit",
|
||||
delete: "Delete",
|
||||
pickerTitle: "Edit Tags",
|
||||
pickerHint: "Click a tag to bind or unbind it — changes are saved instantly.",
|
||||
pickerEmpty: "No tags available for this type. Create some in Tag Management.",
|
||||
pickerSelectedCount: "{count} selected",
|
||||
saving: "Saving…",
|
||||
done: "Done",
|
||||
manageTags: "Tags",
|
||||
searchPlaceholder: "Search by name",
|
||||
filterByTag: "Filter by tag",
|
||||
allTags: "All tags",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "Notifications",
|
||||
bellAria: "Open notifications",
|
||||
unreadShort: "{count} unread",
|
||||
markAllRead: "Mark all read",
|
||||
empty: "No notifications",
|
||||
deleteAria: "Delete notification",
|
||||
settingsAria: "Notification settings",
|
||||
backAria: "Back",
|
||||
settingsTitle: "Notification settings",
|
||||
},
|
||||
};
|
||||
@@ -1,5 +1,17 @@
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
|
||||
export interface NotificationsTranslations {
|
||||
title: string;
|
||||
bellAria: string;
|
||||
unreadShort: string;
|
||||
markAllRead: string;
|
||||
empty: string;
|
||||
deleteAria: string;
|
||||
settingsAria: string;
|
||||
backAria: string;
|
||||
settingsTitle: string;
|
||||
}
|
||||
|
||||
export interface Translations {
|
||||
// Locale meta
|
||||
locale: {
|
||||
@@ -116,6 +128,7 @@ export interface Translations {
|
||||
chats: string;
|
||||
demoChats: string;
|
||||
agents: string;
|
||||
users: string;
|
||||
};
|
||||
|
||||
// Agents
|
||||
@@ -355,9 +368,73 @@ export interface Translations {
|
||||
description: string;
|
||||
createSkill: string;
|
||||
installSkill: string;
|
||||
goToEvolution: string;
|
||||
emptyTitle: string;
|
||||
emptyDescription: string;
|
||||
emptyButton: string;
|
||||
badgeBuiltin: string;
|
||||
badgeMine: string;
|
||||
badgeFromOthers: string;
|
||||
badgeFromOthersTitle: string;
|
||||
badgePublished: string;
|
||||
badgePublishedTitle: string;
|
||||
badgeStale: string;
|
||||
badgeStaleTitle: string;
|
||||
badgeArchived: string;
|
||||
badgeArchivedTitle: string;
|
||||
badgePinned: string;
|
||||
badgePinnedTitle: string;
|
||||
badgeSourceAgent: string;
|
||||
badgeSourceAgentTitle: string;
|
||||
badgeSourceUpload: string;
|
||||
badgeSourceUploadTitle: string;
|
||||
tabAll: string;
|
||||
tabMine: string;
|
||||
entryStale: string;
|
||||
entryArchived: string;
|
||||
useCountInline: string;
|
||||
useCountInlineZero: string;
|
||||
publishAction: string;
|
||||
unpublishAction: string;
|
||||
publishSuccess: string;
|
||||
unpublishSuccess: string;
|
||||
pinAction: string;
|
||||
unpinAction: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
archivedDescription: string;
|
||||
staleTitle: string;
|
||||
staleDescription: string;
|
||||
empty: string;
|
||||
actionRestore: string;
|
||||
actionArchiveNow: string;
|
||||
actionView: string;
|
||||
actionPin: string;
|
||||
actionUnpin: string;
|
||||
actionDelete: string;
|
||||
moreMenu: string;
|
||||
archivedAt: string;
|
||||
lastUsedAt: string;
|
||||
neverUsed: string;
|
||||
restoreSuccess: string;
|
||||
archiveSuccess: string;
|
||||
pinSuccess: string;
|
||||
unpinSuccess: string;
|
||||
};
|
||||
viewDialog: {
|
||||
title: string;
|
||||
descriptionLabel: string;
|
||||
stateLabel: string;
|
||||
sourceLabel: string;
|
||||
useCountLabel: string;
|
||||
lastUsedLabel: string;
|
||||
close: string;
|
||||
};
|
||||
installDialog: {
|
||||
title: string;
|
||||
dropHint: string;
|
||||
@@ -374,9 +451,67 @@ export interface Translations {
|
||||
warningsLabel: string;
|
||||
conflictCustom: string;
|
||||
conflictPublic: string;
|
||||
conflictPublicAdminHint: string;
|
||||
installSuccess: string;
|
||||
installSuccessReplaced: string;
|
||||
rejectInvalidExt: string;
|
||||
targetCategoryLabel: string;
|
||||
categoryCustom: string;
|
||||
categoryPublic: string;
|
||||
};
|
||||
};
|
||||
skillEvolution: {
|
||||
title: string;
|
||||
description: string;
|
||||
backToSkills: string;
|
||||
status: {
|
||||
title: string;
|
||||
enabled: string;
|
||||
disabled: string;
|
||||
paused: string;
|
||||
runCount: string;
|
||||
runCountUnit: string;
|
||||
lastRunAt: string;
|
||||
lastRunNever: string;
|
||||
lastRunSummary: string;
|
||||
lastRunSummaryEmpty: string;
|
||||
skillCounts: string;
|
||||
active: string;
|
||||
stale: string;
|
||||
archived: string;
|
||||
total: string;
|
||||
runNow: string;
|
||||
runStarted: string;
|
||||
lastActions: string;
|
||||
llmSuggested: string;
|
||||
actuallyDid: string;
|
||||
noActions: string;
|
||||
};
|
||||
config: {
|
||||
title: string;
|
||||
description: string;
|
||||
enabledLabel: string;
|
||||
enabledHelper: string;
|
||||
curatorEnabledLabel: string;
|
||||
curatorEnabledHelper: string;
|
||||
intervalHoursLabel: string;
|
||||
intervalHoursHelper: string;
|
||||
staleAfterDaysLabel: string;
|
||||
staleAfterDaysHelper: string;
|
||||
archiveAfterDaysLabel: string;
|
||||
archiveAfterDaysHelper: string;
|
||||
moderationModelLabel: string;
|
||||
moderationModelHelper: string;
|
||||
save: string;
|
||||
saveSuccess: string;
|
||||
reset: string;
|
||||
};
|
||||
logs: {
|
||||
title: string;
|
||||
description: string;
|
||||
empty: string;
|
||||
autoRefresh: string;
|
||||
refresh: string;
|
||||
};
|
||||
};
|
||||
notification: {
|
||||
@@ -395,4 +530,58 @@ export interface Translations {
|
||||
emptyDescription: string;
|
||||
};
|
||||
};
|
||||
|
||||
// Admin user management page
|
||||
adminUsers: {
|
||||
title: string;
|
||||
description: string;
|
||||
email: string;
|
||||
role: string;
|
||||
userId: string;
|
||||
skillCount: string;
|
||||
agentCount: string;
|
||||
roleAdmin: string;
|
||||
roleUser: string;
|
||||
empty: string;
|
||||
};
|
||||
|
||||
// Admin tag management page
|
||||
adminTags: {
|
||||
title: string;
|
||||
description: string;
|
||||
typeAgent: string;
|
||||
typeSkill: string;
|
||||
typeScheduledTask: string;
|
||||
name: string;
|
||||
color: string;
|
||||
tagDescription: string;
|
||||
usageCount: string;
|
||||
actions: string;
|
||||
empty: string;
|
||||
createButton: string;
|
||||
createTitle: string;
|
||||
editTitle: string;
|
||||
deleteConfirm: string;
|
||||
namePlaceholder: string;
|
||||
descriptionPlaceholder: string;
|
||||
save: string;
|
||||
cancel: string;
|
||||
edit: string;
|
||||
delete: string;
|
||||
// tag picker dialog
|
||||
pickerTitle: string;
|
||||
pickerHint: string;
|
||||
pickerEmpty: string;
|
||||
pickerSelectedCount: string;
|
||||
saving: string;
|
||||
done: string;
|
||||
manageTags: string;
|
||||
// search controls on resource lists
|
||||
searchPlaceholder: string;
|
||||
filterByTag: string;
|
||||
allTags: string;
|
||||
};
|
||||
|
||||
// In-app notification inbox (bell in the workspace sidebar)
|
||||
notifications: NotificationsTranslations;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {
|
||||
import {
|
||||
CompassIcon,
|
||||
GraduationCapIcon,
|
||||
ImageIcon,
|
||||
@@ -59,7 +59,7 @@ export const zhCN: Translations = {
|
||||
|
||||
// Welcome
|
||||
welcome: {
|
||||
greeting: "你好,欢迎回来!",
|
||||
greeting: "你好,欢迎使用CM助手",
|
||||
description:
|
||||
"欢迎使用 CM 助手",
|
||||
|
||||
@@ -166,6 +166,7 @@ export const zhCN: Translations = {
|
||||
recentChats: "最近的对话",
|
||||
demoChats: "演示对话",
|
||||
agents: "智能体",
|
||||
users: "用户管理",
|
||||
},
|
||||
|
||||
// Agents
|
||||
@@ -415,10 +416,74 @@ export const zhCN: Translations = {
|
||||
description: "管理 Agent Skill 配置和启用状态。",
|
||||
createSkill: "新建技能",
|
||||
installSkill: "安装技能",
|
||||
goToEvolution: "自我进化",
|
||||
emptyTitle: "还没有技能",
|
||||
emptyDescription:
|
||||
"将你的 Agent Skill 文件夹放在 CM 助手 根目录下的 `/skills/custom` 文件夹中。",
|
||||
emptyButton: "创建你的第一个技能",
|
||||
badgeBuiltin: "内置",
|
||||
badgeMine: "我的",
|
||||
badgeFromOthers: "来自他人",
|
||||
badgeFromOthersTitle: "来自用户 {user_id}",
|
||||
badgePublished: "已发布",
|
||||
badgePublishedTitle: "该技能已对所有用户可见",
|
||||
badgeStale: "过时",
|
||||
badgeStaleTitle: "长期未使用,已被策展程序标记",
|
||||
badgeArchived: "归档",
|
||||
badgeArchivedTitle: "已被归档,可在归档抽屉中恢复",
|
||||
badgePinned: "钉选",
|
||||
badgePinnedTitle: "已钉选,策展程序不会自动归档此技能",
|
||||
badgeSourceAgent: "Agent 创建",
|
||||
badgeSourceAgentTitle: "在对话中由 Agent 自动生成",
|
||||
badgeSourceUpload: "用户上传",
|
||||
badgeSourceUploadTitle: "由用户手动上传或编辑",
|
||||
tabAll: "广场",
|
||||
tabMine: "我的",
|
||||
entryStale: "过时 {count}",
|
||||
entryArchived: "归档 {count}",
|
||||
useCountInline: "调用 {count} 次",
|
||||
useCountInlineZero: "尚未调用",
|
||||
publishAction: "发布",
|
||||
unpublishAction: "取消发布",
|
||||
publishSuccess: "已发布",
|
||||
unpublishSuccess: "已取消发布",
|
||||
pinAction: "钉选(防止被自动归档)",
|
||||
unpinAction: "取消钉选",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
|
||||
staleTitle: "过时的技能",
|
||||
staleDescription: "这些技能长期未被使用,已被策展程序标记。重新使用一次会自动恢复活跃,你也可以主动处理。",
|
||||
empty: "暂无内容",
|
||||
actionRestore: "恢复",
|
||||
actionArchiveNow: "立即归档",
|
||||
actionView: "查看详情",
|
||||
actionPin: "钉选",
|
||||
actionUnpin: "取消钉选",
|
||||
actionDelete: "永久删除",
|
||||
moreMenu: "更多操作",
|
||||
archivedAt: "归档于 {when}",
|
||||
lastUsedAt: "最近使用 {when}",
|
||||
neverUsed: "尚未使用",
|
||||
restoreSuccess: "已恢复",
|
||||
archiveSuccess: "已归档",
|
||||
pinSuccess: "已钉选",
|
||||
unpinSuccess: "已取消钉选",
|
||||
},
|
||||
viewDialog: {
|
||||
title: "技能详情",
|
||||
descriptionLabel: "描述",
|
||||
stateLabel: "状态",
|
||||
sourceLabel: "来源",
|
||||
useCountLabel: "调用次数",
|
||||
lastUsedLabel: "最近使用",
|
||||
close: "关闭",
|
||||
},
|
||||
installDialog: {
|
||||
title: "安装技能",
|
||||
dropHint: "拖拽 .zip 或 .skill 文件到此处,或者",
|
||||
@@ -435,10 +500,70 @@ export const zhCN: Translations = {
|
||||
warningsLabel: "警告",
|
||||
conflictCustom:
|
||||
"已存在同名自定义技能 “{name}”。点击「覆盖已有」可替换(原启用状态保留)。",
|
||||
conflictPublic: "存在同名内置技能 “{name}”,无法覆盖。",
|
||||
installSuccess: "技能 “{name}” 已安装。",
|
||||
installSuccessReplaced: "技能 “{name}” 已替换。",
|
||||
rejectInvalidExt: "只接受 .zip 或 .skill 文件。",
|
||||
conflictPublic: `存在同名内置技能 '{name}',无法覆盖。`,
|
||||
conflictPublicAdminHint: `存在同名内置技能 '{name}'。切换到「内置技能」并点击「覆盖已有」可替换。`,
|
||||
installSuccess: `技能 '{name}' 已安装。`,
|
||||
installSuccessReplaced: `技能 '{name}' 已替换。`,
|
||||
rejectInvalidExt: `只接受 .zip 或 .skill 文件。`,
|
||||
targetCategoryLabel: `安装到`,
|
||||
categoryCustom: `自定义技能`,
|
||||
categoryPublic: `内置技能(管理员)`,
|
||||
},
|
||||
},
|
||||
skillEvolution: {
|
||||
title: "自我进化",
|
||||
description:
|
||||
"管理技能 curator:让 agent 在后台对自创建的技能做老化标记、归档和合并建议。",
|
||||
backToSkills: "返回技能",
|
||||
status: {
|
||||
title: "当前状态",
|
||||
enabled: "已启用",
|
||||
disabled: "已停用",
|
||||
paused: "已暂停",
|
||||
runCount: "累计运行",
|
||||
runCountUnit: "次",
|
||||
lastRunAt: "上次运行",
|
||||
lastRunNever: "尚未运行",
|
||||
lastRunSummary: "上次摘要",
|
||||
lastRunSummaryEmpty: "—",
|
||||
skillCounts: "技能",
|
||||
active: "活跃",
|
||||
stale: "陈旧",
|
||||
archived: "已归档",
|
||||
total: "合计",
|
||||
runNow: "立即执行",
|
||||
runStarted: "已触发后台执行",
|
||||
lastActions: "最近一次运行",
|
||||
llmSuggested: "LLM 建议(自述)",
|
||||
actuallyDid: "实际执行(工具调用)",
|
||||
noActions: "无",
|
||||
},
|
||||
config: {
|
||||
title: "配置",
|
||||
description:
|
||||
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
|
||||
enabledLabel: "启用自我进化",
|
||||
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
|
||||
curatorEnabledLabel: "curator 自动运行",
|
||||
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
|
||||
intervalHoursLabel: "运行间隔(小时)",
|
||||
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
|
||||
staleAfterDaysLabel: "陈旧阈值(天)",
|
||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||
archiveAfterDaysLabel: "归档阈值(天)",
|
||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||
moderationModelLabel: "安全审核模型(可选)",
|
||||
moderationModelHelper: "为空时使用默认主模型。",
|
||||
save: "保存修改",
|
||||
saveSuccess: "配置已更新",
|
||||
reset: "重置",
|
||||
},
|
||||
logs: {
|
||||
title: "最近运行日志",
|
||||
description: "展示最近一次 curator 运行的日志,服务重启会清空。",
|
||||
empty: "暂无日志。点击「立即执行」会写入新的运行记录。",
|
||||
autoRefresh: "自动刷新 (2s)",
|
||||
refresh: "立即刷新",
|
||||
},
|
||||
},
|
||||
notification: {
|
||||
@@ -459,4 +584,63 @@ export const zhCN: Translations = {
|
||||
emptyDescription: "相关的致谢信息会展示在这里。",
|
||||
},
|
||||
},
|
||||
|
||||
adminUsers: {
|
||||
title: "用户管理",
|
||||
description: "查看所有注册用户及其关联的技能和智能体数量。",
|
||||
email: "邮箱",
|
||||
role: "角色",
|
||||
userId: "用户 ID",
|
||||
skillCount: "技能数",
|
||||
agentCount: "智能体数",
|
||||
roleAdmin: "管理员",
|
||||
roleUser: "普通用户",
|
||||
empty: "暂无用户",
|
||||
},
|
||||
|
||||
adminTags: {
|
||||
title: "标签管理",
|
||||
description: "为智能体、技能和定时任务创建并管理标签。标签只能用于其所属类型的资源。",
|
||||
typeAgent: "智能体标签",
|
||||
typeSkill: "技能标签",
|
||||
typeScheduledTask: "定时任务标签",
|
||||
name: "名称",
|
||||
color: "颜色",
|
||||
tagDescription: "描述",
|
||||
usageCount: "使用数",
|
||||
actions: "操作",
|
||||
empty: "暂无标签",
|
||||
createButton: "新建标签",
|
||||
createTitle: "创建标签",
|
||||
editTitle: "编辑标签",
|
||||
deleteConfirm: "确认删除此标签?它将从所有已打标签的资源上移除。",
|
||||
namePlaceholder: "标签名称",
|
||||
descriptionPlaceholder: "描述(可选)",
|
||||
save: "保存",
|
||||
cancel: "取消",
|
||||
edit: "编辑",
|
||||
delete: "删除",
|
||||
pickerTitle: "编辑标签",
|
||||
pickerHint: "点击标签即可绑定或解绑,修改实时保存。",
|
||||
pickerEmpty: "该类型暂无可用标签,请先在标签管理中创建。",
|
||||
pickerSelectedCount: "已选 {count} 个",
|
||||
saving: "保存中…",
|
||||
done: "完成",
|
||||
manageTags: "标签",
|
||||
searchPlaceholder: "按名称搜索",
|
||||
filterByTag: "按标签筛选",
|
||||
allTags: "全部标签",
|
||||
},
|
||||
|
||||
notifications: {
|
||||
title: "通知",
|
||||
bellAria: "打开通知",
|
||||
unreadShort: "{count} 条未读",
|
||||
markAllRead: "全部已读",
|
||||
empty: "暂无通知",
|
||||
deleteAria: "删除通知",
|
||||
settingsAria: "通知设置",
|
||||
backAria: "返回",
|
||||
settingsTitle: "通知设置",
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface LlmMetric {
|
||||
id: string;
|
||||
created_at?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
thread_id?: string | null;
|
||||
run_id?: string | null;
|
||||
agent_name?: string | null;
|
||||
model_name?: string | null;
|
||||
duration_ms: number;
|
||||
input_tokens?: number | null;
|
||||
output_tokens?: number | null;
|
||||
total_tokens?: number | null;
|
||||
tokens_per_sec?: number | null;
|
||||
status: "success" | "error" | string;
|
||||
error_type?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface LlmMetricsListResponse {
|
||||
items: LlmMetric[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface LlmMetricsFilters {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
model_name?: string;
|
||||
status?: "success" | "error";
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface LlmMetricsListQuery extends LlmMetricsFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(filters: LlmMetricsListQuery): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters)) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const qs = params.toString();
|
||||
return qs ? `?${qs}` : "";
|
||||
}
|
||||
|
||||
export async function loadLlmMetrics(
|
||||
query: LlmMetricsListQuery,
|
||||
): Promise<LlmMetricsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/llm-metrics${buildQuery(query)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load LLM metrics");
|
||||
}
|
||||
return (await response.json()) as LlmMetricsListResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger the Excel download. Returns the blob URL used so callers can revoke
|
||||
* it if they want; the function itself handles the click and cleanup.
|
||||
*/
|
||||
export async function downloadLlmMetricsExcel(
|
||||
filters: LlmMetricsFilters,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/llm-metrics/export${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to export LLM metrics");
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const filename = (() => {
|
||||
const header = response.headers.get("content-disposition") ?? "";
|
||||
const utf8 = /filename\*=UTF-8''([^;]+)/i.exec(header);
|
||||
if (utf8?.[1]) return decodeURIComponent(utf8[1]);
|
||||
const plain = /filename="?([^";]+)"?/i.exec(header);
|
||||
return plain?.[1] ?? `llm_call_metrics.xlsx`;
|
||||
})();
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
anchor.remove();
|
||||
window.setTimeout(() => URL.revokeObjectURL(url), 30_000);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { loadLlmMetrics, type LlmMetricsListQuery } from "./api";
|
||||
|
||||
export function useLlmMetrics(query: LlmMetricsListQuery) {
|
||||
return useQuery({
|
||||
queryKey: ["llmMetrics", query],
|
||||
queryFn: () => loadLlmMetrics(query),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { loadMCPConfig, updateMCPConfig } from "./api";
|
||||
import type { MCPServerConfig } from "./types";
|
||||
|
||||
export function useMCPConfig() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
@@ -42,3 +43,44 @@ export function useEnableMCPServer() {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpsertMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useMCPConfig();
|
||||
return useMutation({
|
||||
mutationFn: async ({
|
||||
serverName,
|
||||
server,
|
||||
originalName,
|
||||
}: {
|
||||
serverName: string;
|
||||
server: MCPServerConfig;
|
||||
originalName?: string;
|
||||
}) => {
|
||||
const next = { ...(config?.mcp_servers ?? {}) };
|
||||
if (originalName && originalName !== serverName) {
|
||||
delete next[originalName];
|
||||
}
|
||||
next[serverName] = server;
|
||||
await updateMCPConfig({ mcp_servers: next });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMCPServer() {
|
||||
const queryClient = useQueryClient();
|
||||
const { config } = useMCPConfig();
|
||||
return useMutation({
|
||||
mutationFn: async ({ serverName }: { serverName: string }) => {
|
||||
const next = { ...(config?.mcp_servers ?? {}) };
|
||||
delete next[serverName];
|
||||
await updateMCPConfig({ mcp_servers: next });
|
||||
},
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["mcpConfig"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,6 +1,33 @@
|
||||
export interface MCPServerConfig extends Record<string, unknown> {
|
||||
export type MCPServerTransport = "stdio" | "http" | "sse";
|
||||
|
||||
export interface MCPOAuthConfig {
|
||||
enabled?: boolean;
|
||||
token_url?: string;
|
||||
grant_type?: "client_credentials" | "refresh_token";
|
||||
client_id?: string | null;
|
||||
client_secret?: string | null;
|
||||
refresh_token?: string | null;
|
||||
scope?: string | null;
|
||||
audience?: string | null;
|
||||
token_field?: string;
|
||||
token_type_field?: string;
|
||||
expires_in_field?: string;
|
||||
default_token_type?: string;
|
||||
refresh_skew_seconds?: number;
|
||||
extra_token_params?: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface MCPServerConfig {
|
||||
enabled: boolean;
|
||||
type?: MCPServerTransport;
|
||||
command?: string | null;
|
||||
args?: string[];
|
||||
env?: Record<string, string>;
|
||||
url?: string | null;
|
||||
headers?: Record<string, string>;
|
||||
oauth?: MCPOAuthConfig | null;
|
||||
description: string;
|
||||
disabled_tools?: string[];
|
||||
}
|
||||
|
||||
export interface MCPConfig {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
NotificationItem,
|
||||
NotificationListResponse,
|
||||
NotificationUnreadCountResponse,
|
||||
} from "./types";
|
||||
|
||||
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
export async function listNotifications(
|
||||
options: { unreadOnly?: boolean; limit?: number } = {},
|
||||
): Promise<NotificationItem[]> {
|
||||
const params = new URLSearchParams();
|
||||
if (options.unreadOnly) params.set("unread_only", "true");
|
||||
if (options.limit) params.set("limit", String(options.limit));
|
||||
const query = params.toString();
|
||||
const url = `${getBackendBaseURL()}/api/notifications${query ? `?${query}` : ""}`;
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list notifications");
|
||||
const data = (await res.json()) as NotificationListResponse;
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function getUnreadCount(): Promise<number> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/unread_count`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to load unread count");
|
||||
const data = (await res.json()) as NotificationUnreadCountResponse;
|
||||
return data.unread;
|
||||
}
|
||||
|
||||
export async function markNotificationRead(id: string): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}/read`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to mark notification read");
|
||||
}
|
||||
|
||||
export async function markAllNotificationsRead(): Promise<number> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/notifications/read_all`, {
|
||||
method: "POST",
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to mark all read");
|
||||
const data = (await res.json()) as { updated: number };
|
||||
return data.updated;
|
||||
}
|
||||
|
||||
export async function deleteNotification(id: string): Promise<void> {
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/notifications/${encodeURIComponent(id)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to delete notification");
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
|
||||
import {
|
||||
deleteNotification,
|
||||
getUnreadCount,
|
||||
listNotifications,
|
||||
markAllNotificationsRead,
|
||||
markNotificationRead,
|
||||
} from "./api";
|
||||
|
||||
// Notifications are per-user data, so the cache key MUST be scoped by user_id.
|
||||
// Otherwise switching accounts in the same tab would show the previous user's
|
||||
// inbox until the next fetch lands.
|
||||
function useNotificationKeys() {
|
||||
const userId = useMemo(() => getStoredAuth()?.user_id ?? "anonymous", []);
|
||||
return useMemo(() => {
|
||||
const root = ["notifications", userId] as const;
|
||||
return {
|
||||
root,
|
||||
list: (limit: number, unreadOnly: boolean) =>
|
||||
[...root, "list", { limit, unreadOnly }] as const,
|
||||
count: [...root, "unread-count"] as const,
|
||||
};
|
||||
}, [userId]);
|
||||
}
|
||||
|
||||
export function useNotifications(options: { limit?: number; unreadOnly?: boolean } = {}) {
|
||||
const { limit = 20, unreadOnly = false } = options;
|
||||
const keys = useNotificationKeys();
|
||||
return useQuery({
|
||||
queryKey: keys.list(limit, unreadOnly),
|
||||
queryFn: () => listNotifications({ limit, unreadOnly }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUnreadCount(refetchInterval = 30000) {
|
||||
const keys = useNotificationKeys();
|
||||
return useQuery({
|
||||
queryKey: keys.count,
|
||||
queryFn: () => getUnreadCount(),
|
||||
refetchInterval,
|
||||
// Keep the badge fresh when the user comes back to the tab without
|
||||
// waiting for the next interval tick.
|
||||
refetchOnWindowFocus: true,
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateNotifications() {
|
||||
const queryClient = useQueryClient();
|
||||
const keys = useNotificationKeys();
|
||||
return () => {
|
||||
void queryClient.invalidateQueries({ queryKey: keys.root });
|
||||
};
|
||||
}
|
||||
|
||||
export function useMarkNotificationRead() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => markNotificationRead(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useMarkAllNotificationsRead() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: () => markAllNotificationsRead(),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteNotification() {
|
||||
const invalidate = useInvalidateNotifications();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteNotification(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
@@ -0,0 +1,18 @@
|
||||
export interface NotificationItem {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
payload: Record<string, unknown>;
|
||||
read: boolean;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface NotificationListResponse {
|
||||
items: NotificationItem[];
|
||||
}
|
||||
|
||||
export interface NotificationUnreadCountResponse {
|
||||
unread: number;
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
CreateQuestionRequest,
|
||||
RecommendedQuestion,
|
||||
UpdateQuestionRequest,
|
||||
} from "./index";
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/recommended-questions`;
|
||||
|
||||
export async function listRecommendedQuestions(): Promise<RecommendedQuestion[]> {
|
||||
const res = await apiFetch(BASE());
|
||||
if (!res.ok) throw new Error(`Failed to load questions: ${res.statusText}`);
|
||||
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
||||
return data.questions;
|
||||
}
|
||||
|
||||
export async function createRecommendedQuestion(
|
||||
body: CreateQuestionRequest,
|
||||
): Promise<RecommendedQuestion> {
|
||||
const res = await apiFetch(BASE(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to create question: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<RecommendedQuestion>;
|
||||
}
|
||||
|
||||
export async function updateRecommendedQuestion(
|
||||
id: string,
|
||||
body: UpdateQuestionRequest,
|
||||
): Promise<RecommendedQuestion> {
|
||||
const res = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `Failed to update question: ${res.statusText}`);
|
||||
}
|
||||
return res.json() as Promise<RecommendedQuestion>;
|
||||
}
|
||||
|
||||
export async function deleteRecommendedQuestion(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${BASE()}/${id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error(`Failed to delete question: ${res.statusText}`);
|
||||
}
|
||||
|
||||
export async function reorderRecommendedQuestions(
|
||||
ids: string[],
|
||||
): Promise<RecommendedQuestion[]> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/recommended-questions-reorder`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to reorder questions: ${res.statusText}`);
|
||||
const data = (await res.json()) as { questions: RecommendedQuestion[] };
|
||||
return data.questions;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createRecommendedQuestion,
|
||||
deleteRecommendedQuestion,
|
||||
listRecommendedQuestions,
|
||||
reorderRecommendedQuestions,
|
||||
updateRecommendedQuestion,
|
||||
} from "./api";
|
||||
import type { CreateQuestionRequest, UpdateQuestionRequest } from "./index";
|
||||
|
||||
const QK = ["recommended-questions"] as const;
|
||||
|
||||
export function useRecommendedQuestions() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: QK,
|
||||
queryFn: listRecommendedQuestions,
|
||||
});
|
||||
return { questions: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useCreateRecommendedQuestion() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: CreateQuestionRequest) => createRecommendedQuestion(body),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateRecommendedQuestion() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: UpdateQuestionRequest }) =>
|
||||
updateRecommendedQuestion(id, body),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteRecommendedQuestion() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteRecommendedQuestion(id),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
|
||||
});
|
||||
}
|
||||
|
||||
export function useReorderRecommendedQuestions() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (ids: string[]) => reorderRecommendedQuestions(ids),
|
||||
onSuccess: () => void qc.invalidateQueries({ queryKey: QK }),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
export interface RecommendedQuestion {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface CreateQuestionRequest {
|
||||
title: string;
|
||||
content: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateQuestionRequest {
|
||||
title?: string;
|
||||
content?: string;
|
||||
sort_order?: number;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { createContext, useCallback, useContext } from "react";
|
||||
import { useNavigate, type NavigateOptions } from "react-router-dom";
|
||||
|
||||
export const RouteBaseContext = createContext("/page/workspace");
|
||||
export const RouteBaseProvider = RouteBaseContext.Provider;
|
||||
|
||||
export function useRouteBase() {
|
||||
return useContext(RouteBaseContext);
|
||||
}
|
||||
|
||||
export function useRouteNavigate() {
|
||||
const navigate = useNavigate();
|
||||
const base = useRouteBase();
|
||||
return useCallback(
|
||||
(path: string | number, opts?: NavigateOptions) => {
|
||||
if (typeof path === "string") {
|
||||
navigate(path.replace("/page/workspace", base), opts);
|
||||
} else {
|
||||
navigate(path);
|
||||
}
|
||||
},
|
||||
[navigate, base],
|
||||
);
|
||||
}
|
||||
@@ -28,8 +28,19 @@ export async function getSchedulerThread() {
|
||||
return parseResponse<SchedulerThread>(await apiFetch(`${baseUrl()}/thread`));
|
||||
}
|
||||
|
||||
export async function listScheduledTasks() {
|
||||
return parseResponse<ScheduledTask[]>(await apiFetch(baseUrl()));
|
||||
export interface ListScheduledTasksParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function listScheduledTasks(params: ListScheduledTasksParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
);
|
||||
}
|
||||
|
||||
export async function createScheduledTask(data: {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createScheduledTask,
|
||||
@@ -30,11 +35,16 @@ export function useSchedulerThread() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useScheduledTasks() {
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagId?: string | null } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks"],
|
||||
queryFn: listScheduledTasks,
|
||||
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,104 +1,181 @@
|
||||
/**
|
||||
* Convert markdown text to a Word (.docx) Blob in the browser.
|
||||
*
|
||||
* Implementation notes:
|
||||
* - Uses the `docx` npm package to construct an OOXML document client-side.
|
||||
* - Parser is intentionally lightweight: it covers the markdown features that
|
||||
* scheduled-task agent reports actually emit — ATX headings, paragraphs,
|
||||
* ordered/unordered lists, fenced code blocks, blockquotes, horizontal
|
||||
* rules, and inline `**bold**` / `*italic*` / `` `code` `` runs.
|
||||
* - We deliberately do NOT reach for a full markdown AST library (remark/
|
||||
* unified) to keep the bundle small. Edge cases (tables, footnotes, html)
|
||||
* degrade to plain text rather than failing loudly.
|
||||
* Designed for scheduled-task reports that often contain Chinese text,
|
||||
* markdown tables, and emojis. Compared to Word's stock heading styles we
|
||||
* deliberately:
|
||||
* - Force every run color to pure black (`#000000`) so headings, links,
|
||||
* and inline code never render as Word's default blue / hyperlink theme.
|
||||
* - Render markdown tables as real OOXML tables (full-width, auto-wrapping)
|
||||
* instead of falling back to plain text rows that overflow the page.
|
||||
* - Strip variation selectors, zero-width joiners, and other invisible
|
||||
* characters that often break Word renderers when emojis leak into the
|
||||
* report.
|
||||
*
|
||||
* Block grammar handled line-by-line:
|
||||
* - ATX heading: `^(#{1,6})\s+(.*)$`
|
||||
* - Fenced code: lines between ``` ``` are emitted verbatim in a mono font
|
||||
* - Blockquote: `^>\s?(.*)$`
|
||||
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
|
||||
* - Unordered list: `^\s*[-*+]\s+(.*)$`
|
||||
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
|
||||
* - Pipe table: contiguous lines starting with `|` plus a separator row
|
||||
* - Blank line: paragraph break
|
||||
* - Anything else: paragraph (with inline formatting)
|
||||
*
|
||||
* Inline grammar handled:
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
|
||||
*/
|
||||
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
type IRunOptions,
|
||||
} from "docx";
|
||||
|
||||
const HEADING_LEVELS = [
|
||||
HeadingLevel.HEADING_1,
|
||||
HeadingLevel.HEADING_2,
|
||||
HeadingLevel.HEADING_3,
|
||||
HeadingLevel.HEADING_4,
|
||||
HeadingLevel.HEADING_5,
|
||||
HeadingLevel.HEADING_6,
|
||||
];
|
||||
const BLACK = "000000";
|
||||
const BODY_FONT = { ascii: "Calibri", eastAsia: "Microsoft YaHei" };
|
||||
const MONO_FONT = { ascii: "Consolas", eastAsia: "Microsoft YaHei" };
|
||||
|
||||
type InlineSegment = { text: string; bold?: boolean; italic?: boolean; code?: boolean };
|
||||
const HEADING_SIZES_HALF_PT: Record<number, number> = {
|
||||
// Word "size" property is half-points. 32 = 16pt.
|
||||
1: 36,
|
||||
2: 32,
|
||||
3: 28,
|
||||
4: 26,
|
||||
5: 24,
|
||||
6: 22,
|
||||
};
|
||||
|
||||
const BODY_SIZE_HALF_PT = 22; // 11pt
|
||||
|
||||
type InlineSegment = {
|
||||
text: string;
|
||||
bold?: boolean;
|
||||
italic?: boolean;
|
||||
code?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop characters that Word renders inconsistently or that frequently smuggle
|
||||
* emoji glitches into corporate reports.
|
||||
*
|
||||
* Removes:
|
||||
* - Variation selectors (FE00–FE0F, E0100–E01EF)
|
||||
* - Zero-width joiner / non-joiner / space (200C, 200D, FEFF, 200B)
|
||||
* - Soft hyphen (00AD)
|
||||
* - Object replacement character (FFFC)
|
||||
*/
|
||||
function sanitizeText(text: string): string {
|
||||
return text
|
||||
.replace(/[︀-️]/g, "")
|
||||
.replace(/[-]/g, "")
|
||||
.replace(//g, "")
|
||||
.replace(//g, "")
|
||||
.replace(/[\u{E0100}-\u{E01EF}]/gu, "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Tokenise a single line of markdown into formatted segments.
|
||||
*
|
||||
* Recognised inline syntax:
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_
|
||||
* Order matters: code spans win over emphasis so backticks don't get parsed
|
||||
* as italic delimiters.
|
||||
* `code`, **bold**, __bold__, *italic*, _italic_, [text](url)
|
||||
* Markdown links are flattened to their text content — we deliberately do not
|
||||
* emit hyperlink runs because they pick up Word's blue theme color.
|
||||
*/
|
||||
function parseInlineSegments(text: string): InlineSegment[] {
|
||||
const cleaned = sanitizeText(text);
|
||||
// Strip markdown link syntax first so the regex below doesn't trip on `[...]`.
|
||||
const withoutLinks = cleaned.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1");
|
||||
|
||||
const tokens: InlineSegment[] = [];
|
||||
const pattern = /(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||
const pattern =
|
||||
/(`+)([^`]+)\1|\*\*([^*]+)\*\*|__([^_]+)__|\*([^*]+)\*|_([^_]+)_/g;
|
||||
let cursor = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = pattern.exec(text)) !== null) {
|
||||
while ((match = pattern.exec(withoutLinks)) !== null) {
|
||||
if (match.index > cursor) {
|
||||
tokens.push({ text: text.slice(cursor, match.index) });
|
||||
tokens.push({ text: withoutLinks.slice(cursor, match.index) });
|
||||
}
|
||||
if (match[2] !== undefined) {
|
||||
tokens.push({ text: match[2], code: true });
|
||||
} else if (match[3] !== undefined) {
|
||||
tokens.push({ text: match[3], bold: true });
|
||||
} else if (match[4] !== undefined) {
|
||||
tokens.push({ text: match[4], bold: true });
|
||||
} else if (match[5] !== undefined) {
|
||||
if (match[2] !== undefined) tokens.push({ text: match[2], code: true });
|
||||
else if (match[3] !== undefined) tokens.push({ text: match[3], bold: true });
|
||||
else if (match[4] !== undefined) tokens.push({ text: match[4], bold: true });
|
||||
else if (match[5] !== undefined)
|
||||
tokens.push({ text: match[5], italic: true });
|
||||
} else if (match[6] !== undefined) {
|
||||
else if (match[6] !== undefined)
|
||||
tokens.push({ text: match[6], italic: true });
|
||||
}
|
||||
cursor = pattern.lastIndex;
|
||||
}
|
||||
if (cursor < text.length) {
|
||||
tokens.push({ text: text.slice(cursor) });
|
||||
if (cursor < withoutLinks.length) {
|
||||
tokens.push({ text: withoutLinks.slice(cursor) });
|
||||
}
|
||||
return tokens.length > 0 ? tokens : [{ text }];
|
||||
return tokens.length > 0 ? tokens : [{ text: withoutLinks }];
|
||||
}
|
||||
|
||||
function segmentsToRuns(segments: InlineSegment[], extra?: Partial<IRunOptions>): TextRun[] {
|
||||
function segmentsToRuns(
|
||||
segments: InlineSegment[],
|
||||
extra?: Partial<IRunOptions>,
|
||||
): TextRun[] {
|
||||
return segments.map((seg) => {
|
||||
const opts: IRunOptions = {
|
||||
text: seg.text,
|
||||
bold: seg.bold,
|
||||
italics: seg.italic,
|
||||
color: BLACK,
|
||||
font: seg.code ? MONO_FONT : BODY_FONT,
|
||||
size: extra?.size ?? BODY_SIZE_HALF_PT,
|
||||
...extra,
|
||||
};
|
||||
if (seg.code) {
|
||||
opts.font = { name: "Consolas" };
|
||||
}
|
||||
return new TextRun(opts);
|
||||
});
|
||||
}
|
||||
|
||||
function makeParagraphFromInline(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
});
|
||||
}
|
||||
|
||||
function makeHeadingParagraph(level: number, text: string): Paragraph {
|
||||
const clamped = Math.max(1, Math.min(level, 6));
|
||||
return new Paragraph({
|
||||
heading: HEADING_LEVELS[Math.min(level, HEADING_LEVELS.length) - 1],
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
// We attach a heading level so the table-of-contents still works, but the
|
||||
// run-level color / font / size below override Word's blue theme.
|
||||
heading:
|
||||
({
|
||||
1: HeadingLevel.HEADING_1,
|
||||
2: HeadingLevel.HEADING_2,
|
||||
3: HeadingLevel.HEADING_3,
|
||||
4: HeadingLevel.HEADING_4,
|
||||
5: HeadingLevel.HEADING_5,
|
||||
6: HeadingLevel.HEADING_6,
|
||||
}[clamped] as (typeof HeadingLevel)[keyof typeof HeadingLevel]) ?? undefined,
|
||||
spacing: { before: 200, after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text), {
|
||||
bold: true,
|
||||
color: BLACK,
|
||||
size: HEADING_SIZES_HALF_PT[clamped] ?? BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function makeListItem(text: string, ordered: boolean, level: number): Paragraph {
|
||||
function makeListItem(
|
||||
text: string,
|
||||
ordered: boolean,
|
||||
level: number,
|
||||
): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 80 },
|
||||
children: segmentsToRuns(parseInlineSegments(text)),
|
||||
numbering: ordered
|
||||
? { reference: "ordered-list", level: Math.min(level, 8) }
|
||||
@@ -109,10 +186,13 @@ function makeListItem(text: string, ordered: boolean, level: number): Paragraph
|
||||
|
||||
function makeCodeParagraph(line: string): Paragraph {
|
||||
return new Paragraph({
|
||||
spacing: { after: 0 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: line || " ",
|
||||
font: { name: "Consolas" },
|
||||
text: sanitizeText(line) || " ",
|
||||
font: MONO_FONT,
|
||||
color: BLACK,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
],
|
||||
});
|
||||
@@ -120,45 +200,124 @@ function makeCodeParagraph(line: string): Paragraph {
|
||||
|
||||
function makeQuoteParagraph(text: string): Paragraph {
|
||||
return new Paragraph({
|
||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||
indent: { left: 720 },
|
||||
spacing: { after: 120 },
|
||||
children: segmentsToRuns(parseInlineSegments(text), { italics: true }),
|
||||
});
|
||||
}
|
||||
|
||||
function makeHorizontalRule(): Paragraph {
|
||||
return new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [new TextRun({ text: "────────" })],
|
||||
spacing: { before: 120, after: 120 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: "────────",
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const CELL_BORDER = {
|
||||
style: BorderStyle.SINGLE,
|
||||
size: 4,
|
||||
color: "BFBFBF",
|
||||
};
|
||||
|
||||
function makeTableCell(
|
||||
text: string,
|
||||
options: { header?: boolean } = {},
|
||||
): TableCell {
|
||||
const segments = parseInlineSegments(text);
|
||||
return new TableCell({
|
||||
margins: { top: 80, bottom: 80, left: 100, right: 100 },
|
||||
children: [
|
||||
new Paragraph({
|
||||
spacing: { after: 0 },
|
||||
children: segmentsToRuns(segments, {
|
||||
bold: options.header || undefined,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function splitTableRow(line: string): string[] {
|
||||
// Strip leading/trailing pipe and any escaped pipes (rare in agent output).
|
||||
const trimmed = line.replace(/^\s*\|/, "").replace(/\|\s*$/, "");
|
||||
return trimmed.split("|").map((cell) => cell.trim());
|
||||
}
|
||||
|
||||
function buildTable(headerLine: string, bodyLines: string[]): Table {
|
||||
const headerCells = splitTableRow(headerLine);
|
||||
const colCount = headerCells.length;
|
||||
const rows: TableRow[] = [];
|
||||
rows.push(
|
||||
new TableRow({
|
||||
tableHeader: true,
|
||||
children: headerCells.map((cell) =>
|
||||
makeTableCell(cell, { header: true }),
|
||||
),
|
||||
}),
|
||||
);
|
||||
for (const row of bodyLines) {
|
||||
const cells = splitTableRow(row);
|
||||
// Pad/truncate to match the header column count so Word renders evenly.
|
||||
const normalized = Array.from(
|
||||
{ length: colCount },
|
||||
(_, i) => cells[i] ?? "",
|
||||
);
|
||||
rows.push(
|
||||
new TableRow({
|
||||
children: normalized.map((c) => makeTableCell(c)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return new Table({
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
rows,
|
||||
borders: {
|
||||
top: CELL_BORDER,
|
||||
bottom: CELL_BORDER,
|
||||
left: CELL_BORDER,
|
||||
right: CELL_BORDER,
|
||||
insideHorizontal: CELL_BORDER,
|
||||
insideVertical: CELL_BORDER,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function isTableSeparator(line: string): boolean {
|
||||
// e.g. |---|:---:|---:| with optional spaces
|
||||
return /^\s*\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
|
||||
}
|
||||
|
||||
function looksLikeTableRow(line: string): boolean {
|
||||
return /^\s*\|.*\|\s*$/.test(line) || /^\s*\|.+/.test(line);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert markdown text into an array of docx Paragraph objects.
|
||||
*
|
||||
* Block grammar handled (line-by-line):
|
||||
* - ATX heading: `^(#{1,6})\s+(.*)$`
|
||||
* - Fenced code: lines between ``` ``` are emitted verbatim
|
||||
* - Blockquote: `^>\s?(.*)$`
|
||||
* - Ordered list: `^\s*(\d+)\.\s+(.*)$`
|
||||
* - Unordered list: `^\s*[-*+]\s+(.*)$`
|
||||
* - Horizontal rule: `^(---|\*\*\*|___)\s*$`
|
||||
* - Blank line: paragraph break
|
||||
* - Anything else: paragraph (with inline formatting)
|
||||
* Convert markdown text into an array of docx block elements (Paragraph | Table).
|
||||
*/
|
||||
function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
function markdownToBlocks(markdown: string): Array<Paragraph | Table> {
|
||||
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const blocks: Array<Paragraph | Table> = [];
|
||||
|
||||
let inFence = false;
|
||||
let paragraphBuffer: string[] = [];
|
||||
|
||||
const flushParagraph = () => {
|
||||
if (paragraphBuffer.length === 0) return;
|
||||
paragraphs.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||
blocks.push(makeParagraphFromInline(paragraphBuffer.join(" ")));
|
||||
paragraphBuffer = [];
|
||||
};
|
||||
|
||||
for (const rawLine of lines) {
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const rawLine = lines[i] ?? "";
|
||||
const line = rawLine.replace(/\t/g, " ");
|
||||
|
||||
if (line.trim().startsWith("```")) {
|
||||
@@ -167,7 +326,7 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
continue;
|
||||
}
|
||||
if (inFence) {
|
||||
paragraphs.push(makeCodeParagraph(line));
|
||||
blocks.push(makeCodeParagraph(line));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -179,36 +338,63 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
||||
if (heading) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHeadingParagraph(heading[1].length, heading[2].trim()));
|
||||
blocks.push(
|
||||
makeHeadingParagraph(heading[1]!.length, heading[2]!.trim()),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^(?:---|\*\*\*|___)\s*$/.test(line)) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeHorizontalRule());
|
||||
blocks.push(makeHorizontalRule());
|
||||
continue;
|
||||
}
|
||||
|
||||
const blockquote = /^>\s?(.*)$/.exec(line);
|
||||
if (blockquote) {
|
||||
flushParagraph();
|
||||
paragraphs.push(makeQuoteParagraph(blockquote[1]));
|
||||
blocks.push(makeQuoteParagraph(blockquote[1] ?? ""));
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table detection — a header row followed by a separator row.
|
||||
if (
|
||||
looksLikeTableRow(line) &&
|
||||
i + 1 < lines.length &&
|
||||
isTableSeparator(lines[i + 1] ?? "")
|
||||
) {
|
||||
flushParagraph();
|
||||
const headerLine = line;
|
||||
i += 2; // skip header + separator
|
||||
const body: string[] = [];
|
||||
while (i < lines.length && looksLikeTableRow(lines[i] ?? "")) {
|
||||
body.push(lines[i] ?? "");
|
||||
i++;
|
||||
}
|
||||
// Re-step back one so the outer loop's i++ lands on the next unprocessed
|
||||
// line. Without this, blank lines after the table get swallowed.
|
||||
i--;
|
||||
blocks.push(buildTable(headerLine, body));
|
||||
// Word's table rendering eats following spacing; inject a tiny gap.
|
||||
blocks.push(
|
||||
new Paragraph({ spacing: { after: 0 }, children: [new TextRun("")] }),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const orderedItem = /^(\s*)(\d+)\.\s+(.*)$/.exec(line);
|
||||
if (orderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(orderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(orderedItem[3], true, indent));
|
||||
const indent = Math.floor(orderedItem[1]!.length / 2);
|
||||
blocks.push(makeListItem(orderedItem[3]!, true, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
const unorderedItem = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
||||
if (unorderedItem) {
|
||||
flushParagraph();
|
||||
const indent = Math.floor(unorderedItem[1].length / 2);
|
||||
paragraphs.push(makeListItem(unorderedItem[2], false, indent));
|
||||
const indent = Math.floor(unorderedItem[1]!.length / 2);
|
||||
blocks.push(makeListItem(unorderedItem[2]!, false, indent));
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -216,33 +402,70 @@ function markdownToParagraphs(markdown: string): Paragraph[] {
|
||||
}
|
||||
|
||||
flushParagraph();
|
||||
return paragraphs;
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a markdown string into a .docx Blob suitable for download.
|
||||
*/
|
||||
export async function markdownToDocxBlob(markdown: string, title?: string): Promise<Blob> {
|
||||
const children: Paragraph[] = [];
|
||||
export async function markdownToDocxBlob(
|
||||
markdown: string,
|
||||
title?: string,
|
||||
): Promise<Blob> {
|
||||
const children: Array<Paragraph | Table> = [];
|
||||
if (title?.trim()) {
|
||||
children.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
children: [new TextRun({ text: title.trim(), bold: true })],
|
||||
spacing: { before: 0, after: 240 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: sanitizeText(title.trim()),
|
||||
bold: true,
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: 40, // 20pt
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
children.push(...markdownToParagraphs(markdown));
|
||||
children.push(...markdownToBlocks(markdown));
|
||||
|
||||
const doc = new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
color: BLACK,
|
||||
font: BODY_FONT,
|
||||
size: BODY_SIZE_HALF_PT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
numbering: {
|
||||
config: [
|
||||
{
|
||||
reference: "ordered-list",
|
||||
levels: [
|
||||
{ level: 0, format: "decimal", text: "%1.", alignment: AlignmentType.START },
|
||||
{ level: 1, format: "decimal", text: "%2.", alignment: AlignmentType.START },
|
||||
{ level: 2, format: "decimal", text: "%3.", alignment: AlignmentType.START },
|
||||
{
|
||||
level: 0,
|
||||
format: "decimal",
|
||||
text: "%1.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
{
|
||||
level: 1,
|
||||
format: "decimal",
|
||||
text: "%2.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
{
|
||||
level: 2,
|
||||
format: "decimal",
|
||||
text: "%3.",
|
||||
alignment: AlignmentType.START,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export type ScheduledTaskRole = "owner" | "subscriber";
|
||||
|
||||
export type ScheduledTaskSubscription = {
|
||||
@@ -27,6 +29,9 @@ export type ScheduledTask = {
|
||||
published_at?: string | null;
|
||||
role?: ScheduledTaskRole;
|
||||
_subscription?: ScheduledTaskSubscription | null;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
tags?: Tag[];
|
||||
};
|
||||
|
||||
export type PublishedScheduledTask = {
|
||||
@@ -37,6 +42,8 @@ export type PublishedScheduledTask = {
|
||||
timezone: string;
|
||||
published_at?: string | null;
|
||||
owner_user_id: string;
|
||||
creator_user_id?: string | null;
|
||||
creator_email?: string | null;
|
||||
};
|
||||
|
||||
export type SchedulerThread = {
|
||||
|
||||
@@ -4,12 +4,16 @@ export const DEFAULT_LOCAL_SETTINGS: LocalSettings = {
|
||||
notification: {
|
||||
enabled: true,
|
||||
},
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
display: {
|
||||
streamSpeedMeterEnabled: false,
|
||||
},
|
||||
};
|
||||
context: {
|
||||
model_name: undefined,
|
||||
mode: undefined,
|
||||
reasoning_effort: undefined,
|
||||
writing_mode: false,
|
||||
},
|
||||
};
|
||||
|
||||
export const LOCAL_SETTINGS_KEY = "deerflow.local-settings";
|
||||
export const THREAD_MODEL_KEY_PREFIX = "deerflow.thread-model.";
|
||||
@@ -22,6 +26,14 @@ export interface LocalSettings {
|
||||
notification: {
|
||||
enabled: boolean;
|
||||
};
|
||||
display: {
|
||||
/**
|
||||
* Whether to render a small token/s speed meter while a streaming
|
||||
* AI reply is in flight. Default off; controlled by the user from
|
||||
* the appearance settings page.
|
||||
*/
|
||||
streamSpeedMeterEnabled: boolean;
|
||||
};
|
||||
context: Omit<
|
||||
AgentThreadContext,
|
||||
| "thread_id"
|
||||
@@ -48,6 +60,10 @@ function mergeLocalSettings(settings?: Partial<LocalSettings>): LocalSettings {
|
||||
...DEFAULT_LOCAL_SETTINGS.notification,
|
||||
...settings?.notification,
|
||||
},
|
||||
display: {
|
||||
...DEFAULT_LOCAL_SETTINGS.display,
|
||||
...settings?.display,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,21 @@
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { apiFetch } from "@/core/api";
|
||||
|
||||
import type { Skill } from "./type";
|
||||
import type { Skill, SkillState } from "./type";
|
||||
|
||||
export async function loadSkills() {
|
||||
const skills = await apiFetch(`${getBackendBaseURL()}/api/skills`);
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
}
|
||||
|
||||
export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
const qs = new URLSearchParams();
|
||||
if (params.search) qs.set("search", params.search);
|
||||
if (params.tagId) qs.set("tag_id", params.tagId);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
);
|
||||
const json = await skills.json();
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
@@ -96,11 +107,14 @@ export async function validateSkillUpload(file: File): Promise<ValidateSkillUplo
|
||||
|
||||
export async function installSkillUpload(
|
||||
file: File,
|
||||
options: { overwrite?: boolean } = {},
|
||||
options: { overwrite?: boolean; targetCategory?: "public" | "custom" } = {},
|
||||
): Promise<InstallSkillResponse> {
|
||||
const form = new FormData();
|
||||
form.append("file", file, file.name);
|
||||
const query = options.overwrite ? "?overwrite=true" : "";
|
||||
const params = new URLSearchParams();
|
||||
if (options.overwrite) params.set("overwrite", "true");
|
||||
if (options.targetCategory) params.set("target_category", options.targetCategory);
|
||||
const query = params.size > 0 ? `?${params.toString()}` : "";
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/install-upload${query}`,
|
||||
{ method: "POST", body: form },
|
||||
@@ -110,3 +124,91 @@ export async function installSkillUpload(
|
||||
}
|
||||
return response.json() as Promise<InstallSkillResponse>;
|
||||
}
|
||||
|
||||
export interface SetSkillPublishedResponse {
|
||||
name: string;
|
||||
owner_user_id: string | null;
|
||||
published: boolean;
|
||||
}
|
||||
|
||||
export async function setSkillPublished(
|
||||
skillName: string,
|
||||
published: boolean,
|
||||
): Promise<SetSkillPublishedResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/published`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ published }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update published state");
|
||||
}
|
||||
return response.json() as Promise<SetSkillPublishedResponse>;
|
||||
}
|
||||
|
||||
export async function setSkillState(
|
||||
skillName: string,
|
||||
state: SkillState,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/state`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ state }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill state");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function setSkillPinned(
|
||||
skillName: string,
|
||||
pinned: boolean,
|
||||
): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}/pinned`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ pinned }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill pinned state");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export interface SkillContentResponse {
|
||||
name: string;
|
||||
category: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export async function getSkillContent(
|
||||
skillName: string,
|
||||
): Promise<SkillContentResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/content`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill content");
|
||||
}
|
||||
return response.json() as Promise<SkillContentResponse>;
|
||||
}
|
||||
|
||||
export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/custom/${encodeURIComponent(skillName)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to delete skill");
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,43 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
keepPreviousData,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import { enableSkill, installSkillUpload, validateSkillUpload } from "./api";
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
validateSkillUpload,
|
||||
} from "./api";
|
||||
import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills() {
|
||||
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills"],
|
||||
queryFn: () => loadSkills(),
|
||||
queryKey: ["skills", search, tagId],
|
||||
queryFn: () => loadSkills({ search, tagId }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
}
|
||||
|
||||
export function useSkillContent(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill-content", skillName],
|
||||
queryFn: () => getSkillContent(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
@@ -39,8 +65,56 @@ export function useValidateSkillUpload() {
|
||||
export function useInstallSkillUpload() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ file, overwrite }: { file: File; overwrite?: boolean }) =>
|
||||
installSkillUpload(file, { overwrite }),
|
||||
mutationFn: ({ file, overwrite, targetCategory }: { file: File; overwrite?: boolean; targetCategory?: "public" | "custom" }) =>
|
||||
installSkillUpload(file, { overwrite, targetCategory }),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPublished() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
skillName,
|
||||
published,
|
||||
}: {
|
||||
skillName: string;
|
||||
published: boolean;
|
||||
}) => setSkillPublished(skillName, published),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillState() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, state }: { skillName: string; state: SkillState }) =>
|
||||
setSkillState(skillName, state),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillPinned() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, pinned }: { skillName: string; pinned: boolean }) =>
|
||||
setSkillPinned(skillName, pinned),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteCustomSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (skillName: string) => deleteCustomSkill(skillName),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
},
|
||||
|
||||
@@ -1,7 +1,30 @@
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
import type { Tag } from "@/core/tags";
|
||||
|
||||
export type SkillState = "active" | "stale" | "archived";
|
||||
export type SkillSource = "agent" | "upload";
|
||||
|
||||
export interface Skill {
|
||||
name: string;
|
||||
description: string;
|
||||
category: string;
|
||||
license: string;
|
||||
enabled: boolean;
|
||||
// Multitenancy fields — owner_user_id is null for built-in / public skills.
|
||||
// ``builtin`` mirrors that nullness for explicit branching on the UI side.
|
||||
owner_user_id?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
pinned?: boolean;
|
||||
// Provenance — distinguishes skills created by an agent during a chat
|
||||
// (``agent``) from skills the user uploaded / authored manually (``upload``).
|
||||
source?: SkillSource;
|
||||
last_activity_at?: string | null;
|
||||
use_count?: number;
|
||||
view_count?: number;
|
||||
patch_count?: number;
|
||||
// Tags assigned to this skill (admin-managed).
|
||||
tags?: Tag[];
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export interface PromptPrefixSettings {
|
||||
enabled: boolean;
|
||||
switch_label: string;
|
||||
prompt_prefix: string;
|
||||
}
|
||||
|
||||
export interface PromptPrefixUpdate {
|
||||
enabled?: boolean;
|
||||
switch_label?: string;
|
||||
prompt_prefix?: string;
|
||||
}
|
||||
|
||||
export async function loadPromptPrefixSettings(): Promise<PromptPrefixSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load prompt prefix settings: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
|
||||
export async function updatePromptPrefixSettings(
|
||||
body: PromptPrefixUpdate,
|
||||
): Promise<PromptPrefixSettings> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/system-settings/prompt-prefix`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response
|
||||
.json()
|
||||
.catch(() => ({ detail: "Failed to update prompt prefix" }));
|
||||
throw new Error(detail.detail ?? "Failed to update prompt prefix");
|
||||
}
|
||||
return (await response.json()) as PromptPrefixSettings;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadPromptPrefixSettings,
|
||||
updatePromptPrefixSettings,
|
||||
type PromptPrefixUpdate,
|
||||
} from "./api";
|
||||
|
||||
const PROMPT_PREFIX_KEY = ["systemSettings", "promptPrefix"] as const;
|
||||
|
||||
export function usePromptPrefixSettings() {
|
||||
return useQuery({
|
||||
queryKey: PROMPT_PREFIX_KEY,
|
||||
queryFn: () => loadPromptPrefixSettings(),
|
||||
staleTime: 60_000,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnMount: false,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdatePromptPrefixSettings() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (body: PromptPrefixUpdate) => updatePromptPrefixSettings(body),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData(PROMPT_PREFIX_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
import type {
|
||||
Tag,
|
||||
TagCreateInput,
|
||||
TagListResponse,
|
||||
TagType,
|
||||
TagUpdateInput,
|
||||
} from "./types";
|
||||
|
||||
async function parseDetail(res: Response, fallback: string): Promise<Error> {
|
||||
const data = (await res.json().catch(() => ({}))) as { detail?: unknown };
|
||||
const detail = typeof data.detail === "string" && data.detail ? data.detail : null;
|
||||
return new Error(detail ?? `${fallback} (HTTP ${res.status})`);
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/tags`;
|
||||
|
||||
export async function listTags(tagType?: TagType): Promise<Tag[]> {
|
||||
const query = tagType ? `?tag_type=${encodeURIComponent(tagType)}` : "";
|
||||
const res = await apiFetch(`${base()}${query}`);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to list tags");
|
||||
const data = (await res.json()) as TagListResponse;
|
||||
return data.tags;
|
||||
}
|
||||
|
||||
export async function createTag(input: TagCreateInput): Promise<Tag> {
|
||||
const res = await apiFetch(base(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to create tag");
|
||||
return (await res.json()) as Tag;
|
||||
}
|
||||
|
||||
export async function updateTag(id: string, input: TagUpdateInput): Promise<Tag> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to update tag");
|
||||
return (await res.json()) as Tag;
|
||||
}
|
||||
|
||||
export async function deleteTag(id: string): Promise<void> {
|
||||
const res = await apiFetch(`${base()}/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to delete tag");
|
||||
}
|
||||
|
||||
export async function getResourceTags(
|
||||
targetType: TagType,
|
||||
targetId: string,
|
||||
): Promise<Tag[]> {
|
||||
const res = await apiFetch(
|
||||
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to load resource tags");
|
||||
const data = (await res.json()) as TagListResponse;
|
||||
return data.tags;
|
||||
}
|
||||
|
||||
export async function setResourceTags(
|
||||
targetType: TagType,
|
||||
targetId: string,
|
||||
tagIds: string[],
|
||||
): Promise<Tag[]> {
|
||||
const res = await apiFetch(
|
||||
`${base()}/assignments/${encodeURIComponent(targetType)}/${encodeURIComponent(targetId)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ tag_ids: tagIds }),
|
||||
},
|
||||
);
|
||||
if (!res.ok) throw await parseDetail(res, "Failed to update resource tags");
|
||||
const data = (await res.json()) as TagListResponse;
|
||||
return data.tags;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createTag,
|
||||
deleteTag,
|
||||
getResourceTags,
|
||||
listTags,
|
||||
setResourceTags,
|
||||
updateTag,
|
||||
} from "./api";
|
||||
import type { TagCreateInput, TagType, TagUpdateInput } from "./types";
|
||||
|
||||
// Tags are global (admin-managed), so the cache key is not user-scoped.
|
||||
const tagKeys = {
|
||||
root: ["tags"] as const,
|
||||
list: (tagType?: TagType) => ["tags", "list", tagType ?? "all"] as const,
|
||||
assignment: (targetType: TagType, targetId: string) =>
|
||||
["tags", "assignment", targetType, targetId] as const,
|
||||
};
|
||||
|
||||
export function useTags(tagType?: TagType) {
|
||||
return useQuery({
|
||||
queryKey: tagKeys.list(tagType),
|
||||
queryFn: () => listTags(tagType),
|
||||
});
|
||||
}
|
||||
|
||||
function useInvalidateTags() {
|
||||
const queryClient = useQueryClient();
|
||||
return () => {
|
||||
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
|
||||
};
|
||||
}
|
||||
|
||||
export function useCreateTag() {
|
||||
const invalidate = useInvalidateTags();
|
||||
return useMutation({
|
||||
mutationFn: (input: TagCreateInput) => createTag(input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateTag() {
|
||||
const invalidate = useInvalidateTags();
|
||||
return useMutation({
|
||||
mutationFn: ({ id, input }: { id: string; input: TagUpdateInput }) =>
|
||||
updateTag(id, input),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteTag() {
|
||||
const invalidate = useInvalidateTags();
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteTag(id),
|
||||
onSuccess: invalidate,
|
||||
});
|
||||
}
|
||||
|
||||
export function useResourceTags(targetType: TagType, targetId: string, enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: tagKeys.assignment(targetType, targetId),
|
||||
queryFn: () => getResourceTags(targetType, targetId),
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetResourceTags() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
targetType,
|
||||
targetId,
|
||||
tagIds,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
targetId: string;
|
||||
tagIds: string[];
|
||||
}) => setResourceTags(targetType, targetId, tagIds),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: tagKeys.root });
|
||||
// Resource list pages embed the tags, so refresh them too.
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["agents"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from "./api";
|
||||
export * from "./hooks";
|
||||
export * from "./types";
|
||||
Loaded 100 of 596 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user