Merge branch 'master' of http://192.168.0.200/knowledge-base-bj/deerflow-server into xhy
This commit is contained in:
commit
e2d61f4cfb
476 files changed
+103681
-9379
No files matched your search
@@ -47,11 +47,9 @@ offline-backend-20260505/bundle/start_backend.sh
|
||||
offline-backend-20260505/bundle/stop_backend.sh
|
||||
|
||||
# offline-backend-20260512 runtime artifacts (large files, local config, data)
|
||||
offline-backend-20260512/.env
|
||||
offline-backend-20260512/.env.example
|
||||
offline-backend-20260512/DEPLOY_BACKEND_OFFLINE_ZH.md
|
||||
offline-backend-20260512/SHA256SUMS
|
||||
offline-backend-20260512/config.yaml
|
||||
offline-backend-20260512/config.yaml.example
|
||||
offline-backend-20260512/data/
|
||||
offline-backend-20260512/deerflow-backend-offline-image-20260505.tar
|
||||
|
||||
@@ -0,0 +1,916 @@
|
||||
# AI 写作 — 后端开发文档
|
||||
|
||||
> **本文档目的**:帮助后端开发者快速理解 AI 写作子系统的代码结构、Graph 拓扑、数据流、持久化、容错策略和扩展点。
|
||||
>
|
||||
> **配套文档**:
|
||||
> - 前端:`frontend-web/docs/ai-writing-frontend-dev.md`
|
||||
> - 故障排查:`frontend-web/docs/ai-writing-说明.md`
|
||||
> - 前端 API 字段索引:`frontend-web/docs/ai-writing-assistant-backend-api.md`
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位与技术架构
|
||||
|
||||
### 产品定位
|
||||
**杂志社协作写作子系统**:由 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章,通过 4 个用户干预点参与决策。
|
||||
|
||||
### 技术栈
|
||||
- **编排框架**:LangGraph(独立 StateGraph,不复用主 lead agent 的 middleware 链)
|
||||
- **节点驱动**:异步 LLM 调用(`langchain_openai` 等),支持流式 token 推送到 SSE
|
||||
- **状态持久化**:LangGraph checkpointer(SQLite 文件,跨重启可恢复)
|
||||
- **业务数据**:MySQL `ai_writing_sessions` 表
|
||||
- **协议**:SSE(Server-Sent Events)推进度 + REST 控制流
|
||||
|
||||
### 与主 Agent 的关系
|
||||
**完全独立**。AI 写作 graph 不通过 `make_lead_agent` 也不走主 agent 的 18 层 middleware。它有自己的 StateGraph、自己的 checkpointer、自己的路由前缀(`/api/ai-writing/*`),跟主 agent 解耦。
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── app/gateway/
|
||||
│ ├── routers/
|
||||
│ │ └── ai_writing.py # FastAPI 路由 + 模块级状态字典 + SSE 生成器(1000+ 行,核心)
|
||||
│ ├── ai_writing_cleanup.py # 定期清理 + cron 调度(独立模块)
|
||||
│ └── app.py # lifespan 启动/停止清理任务、检测多 worker、关闭 checkpointer
|
||||
│
|
||||
└── packages/harness/deerflow/
|
||||
├── agents/ai_writing/
|
||||
│ ├── __init__.py # 暴露 AI_WRITING_GRAPH + 工厂函数
|
||||
│ ├── graph.py # ⭐ StateGraph 定义 + 4 个 pause 节点 + checkpointer 工厂(538 行)
|
||||
│ ├── state.py # AIWritingState TypedDict(121 行)
|
||||
│ │
|
||||
│ ├── nodes/ # ── Graph 节点 ──
|
||||
│ │ ├── _utils.py # LLM 工具:parse_json, ThinkTagParser, llm_json
|
||||
│ │ ├── intent_parser.py # 节点 1:意图解析
|
||||
│ │ ├── researcher.py # 节点 2:素材检索(支持流式 progress)
|
||||
│ │ ├── writer_outline.py # 节点 3:大纲规划
|
||||
│ │ ├── writer_draft.py # 节点 4:草稿生成(按章节流式)
|
||||
│ │ └── editor.py # 节点 5:编辑审核(3 维度并行流式)
|
||||
│ │
|
||||
│ ├── prompts/ # ── Prompt 模板 ──
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── researcher_prompts.py
|
||||
│ │ ├── writer_prompts.py
|
||||
│ │ └── editor_prompts.py
|
||||
│ │
|
||||
│ └── search/ # ── 素材检索 provider ──
|
||||
│ ├── base.py # SearchResult / SearchProvider 接口
|
||||
│ ├── duckduckgo_search.py
|
||||
│ ├── internet_search.py # 互联网检索(默认)
|
||||
│ ├── intranet_stub.py # 内网检索占位(按部署接入)
|
||||
│ └── demo_search.py # 离线 demo 数据
|
||||
│
|
||||
└── persistence/ai_writing_sessions/
|
||||
├── __init__.py # AIWritingSessionRepository 工厂
|
||||
├── model.py # ORM:AIWritingSessionRow
|
||||
└── sql.py # CRUD 实现(含清理用的 list_older_than / delete_by_ids)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Graph 拓扑(最重要)
|
||||
|
||||
### StateGraph 节点 + 边
|
||||
|
||||
```
|
||||
START
|
||||
│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ intent_parser │ 解析用户意图(确定 article_type / target_audience)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ researcher │ ← (re_search 回到这里)
|
||||
└────────┬────────┘
|
||||
↓ (after_researcher: ⼟材 ready)
|
||||
┌──────────────────────┐
|
||||
│ pause_material │ ⚙️ PAUSE-1:素材确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_search
|
||||
↓ └→ researcher
|
||||
┌─────────────────┐
|
||||
│ writer_outline │ ← (re_outline 回到这里)
|
||||
└────────┬────────┘
|
||||
↓
|
||||
┌──────────────────────┐
|
||||
│ pause_outline │ ⚙️ PAUSE-2:大纲确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ re_outline
|
||||
↓ └→ writer_outline
|
||||
┌─────────────────┐
|
||||
│ writer_draft │ ← (素材补充后回到这里)
|
||||
└─┬───────────────┘
|
||||
↓ (after_writer_draft)
|
||||
┌──────┴────────────────┐
|
||||
↓ ↓
|
||||
┌──────────────────┐ ┌────────────────────┐
|
||||
│ pause_section_ │ │ pause_draft │ ⚙️ PAUSE-3:草稿确认
|
||||
│ help(素材不足)│ │ │
|
||||
└─┬─────────────┬──┘ └─┬────────┬───┬─────┘
|
||||
│ supplement │ del/ │ │ │ to_editor
|
||||
│↓ │ loose │ │ user_revise
|
||||
research writer_ │ final ↓
|
||||
draft │ ize writer_draft
|
||||
│↓
|
||||
done
|
||||
│
|
||||
(to_editor)│
|
||||
↓
|
||||
┌─────────────────┐
|
||||
│ editor │ 3 维度并行流式审核
|
||||
└────────┬────────┘
|
||||
↓ (after_review)
|
||||
┌──────────────────────┐
|
||||
│ pause_review │ ⚙️ PAUSE-4:审核确认
|
||||
└─┬──────────┬─────────┘
|
||||
│ confirm │ revise
|
||||
│↓ └→ writer_draft
|
||||
done
|
||||
```
|
||||
|
||||
### 节点定义位置一览
|
||||
|
||||
| 节点名 | 实现文件 | 类型 | 流式? |
|
||||
|---|---|---|---|
|
||||
| `intent_parser` | `nodes/intent_parser.py` | async function | 否 |
|
||||
| `researcher` | `nodes/researcher.py` | async function | 是(research_progress) |
|
||||
| `pause_material` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_outline` | `nodes/writer_outline.py` | async function | 是(outline_chunk) |
|
||||
| `pause_outline` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `writer_draft` | `nodes/writer_draft.py` | async function | 是(draft_chunk,按章节) |
|
||||
| `pause_section_help` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `pause_draft` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
| `editor` | `nodes/editor.py` | async function | 是(review_chunk,3 路并行) |
|
||||
| `pause_review` | `graph.py` 顶部 | async, 调 `interrupt()` | — |
|
||||
|
||||
### 条件路由函数(graph.py)
|
||||
|
||||
| 函数 | 在哪个节点之后 | 路由依据 |
|
||||
|---|---|---|
|
||||
| `after_researcher` | researcher | 是否找到素材 |
|
||||
| `after_material_pause` | pause_material | `state["status"]` 是 researching 还是 writing_outline |
|
||||
| `after_outline_pause` | pause_outline | 同上,writing_outline 还是 writing_draft |
|
||||
| `after_writer_draft` | writer_draft | 是否有 blocked_sections |
|
||||
| `after_section_help_pause` | pause_section_help | status |
|
||||
| `after_draft_pause` | pause_draft | done / revising / reviewing |
|
||||
| `after_review` | editor | review_result.verdict 是 pass 还是 reject + revision_count 上限 |
|
||||
| `after_review_pause` | pause_review | done / writing_draft |
|
||||
|
||||
---
|
||||
|
||||
## 四、State 定义(state.py)
|
||||
|
||||
```python
|
||||
class AIWritingState(TypedDict):
|
||||
# —— 写作配置(启动时填充)——
|
||||
user_intent: str
|
||||
article_type: str
|
||||
target_audience: str
|
||||
word_count_target: int
|
||||
keyword_count: int # 1-10,默认 6
|
||||
writing_mode: str # "strict" | "loose"
|
||||
|
||||
# —— Agent 产出 ——
|
||||
material_package: Optional[MaterialPackage] # 素材收集专家
|
||||
current_outline: Optional[Outline] # 作家
|
||||
drafts: Annotated[List[DraftArticle], operator.add] # 累计所有版本
|
||||
current_draft: Optional[DraftArticle] # 最新草稿
|
||||
review_result: Optional[ReviewResult] # 编辑
|
||||
|
||||
# —— 用户干预 ——
|
||||
last_user_intervention: Optional[UserInterventionPayload]
|
||||
blocked_sections: Optional[List[BlockedSection]] # 素材不足的章节
|
||||
|
||||
# —— 流程控制 ——
|
||||
revision_count: int
|
||||
max_revisions: int # 默认 3
|
||||
status: WritingStatus
|
||||
|
||||
# —— SSE 推送队列 ——
|
||||
progress_events: Annotated[List[dict], operator.add] # 累加,不覆盖
|
||||
```
|
||||
|
||||
### State 字段的特殊语义
|
||||
|
||||
- **`Annotated[..., operator.add]`** — LangGraph 的累加 reducer,节点 return 时 `progress_events: [evt]` 会**追加**到现有列表,而不是替换
|
||||
- **TypedDict** — 运行时是普通 dict,类型注解仅供 IDE 提示
|
||||
- **节点输出** — 节点 return 的 dict 只需要包含**要更新的字段**,未指定的字段保持不变
|
||||
|
||||
---
|
||||
|
||||
## 五、路由层(app/gateway/routers/ai_writing.py)
|
||||
|
||||
### 14 个接口一览
|
||||
|
||||
| HTTP | 路径 | 用途 | 鉴权 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/ai-writing/start` | 启动写作,返回 session_id | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/stream` | SSE 进度流 | 用户 |
|
||||
| POST | `/api/ai-writing/{id}/resume` | 提交用户干预 | 用户 |
|
||||
| GET | `/api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 用户 |
|
||||
| GET | `/api/ai-writing/sessions` | 列出当前用户的历史会话 | 用户 |
|
||||
| GET | `/api/ai-writing/sessions/{id}` | 获取单个历史会话 | 用户 |
|
||||
| PATCH | `/api/ai-writing/sessions/{id}` | 重命名会话 | 用户 |
|
||||
| DELETE | `/api/ai-writing/sessions/{id}` | 删除会话 | 用户 |
|
||||
| PUT | `/api/ai-writing/sessions/{id}/transcript` | 保存对话时间线(**fire-and-forget**) | 用户 |
|
||||
| GET | `/api/ai-writing/article-types` | 列出文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/article-types` | 新增文章类型 | 用户(建议改成 admin) |
|
||||
| PATCH | `/api/ai-writing/article-types/{id}` | 更新文章类型 | 用户 |
|
||||
| DELETE | `/api/ai-writing/article-types/{id}` | 删除文章类型 | 用户 |
|
||||
| POST | `/api/ai-writing/admin/cleanup` | **手动触发清理** | admin only |
|
||||
|
||||
### 模块级状态字典(关键设计 — 也是 worker 限制的根源)
|
||||
|
||||
```python
|
||||
# 每个 session_id 对应一份内存状态。多 worker 部署时每个 worker 一份,互不共享。
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件(供重连重投)
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 动态文章类型列表
|
||||
_session_error_flags: dict[str, bool]
|
||||
_inflight_graph_tasks: set[asyncio.Task] # in-flight task 注册表(lifespan drain 用)
|
||||
_recovery_lock: asyncio.Lock # 跨 worker 会话恢复的并发锁
|
||||
```
|
||||
|
||||
⚠️ **关键限制**:这些都是 per-process 内存。多 worker 部署下,worker B 看不到 worker A 的状态 → SSE 事件分发失败。详见 `frontend-web/docs/ai-writing-说明.md` 第五、六章。
|
||||
|
||||
### `_stream_graph_segment`(graph 推进函数)
|
||||
|
||||
`/start` 和 `/resume` 都调用这个函数(用 `_spawn_graph_task` 包装),负责:
|
||||
1. 调 `graph.astream(input_or_command, config=config, stream_mode="updates")`
|
||||
2. 把每个 chunk 解析成 SSE 事件 put 进 `_event_queues[session_id]`
|
||||
3. 遇到 `__interrupt__` → 推 `await_user` 事件 → 返回(不关闭 queue)
|
||||
4. 遇到 END → 推 `done` 事件 → 关闭 queue(put None 哨兵)
|
||||
5. 任何异常 → 推 `error` 事件 → 关闭 queue
|
||||
6. finally 段:更新 DB 最终状态、清理所有模块字典
|
||||
|
||||
### SSE 生成器 `_sse_generator`
|
||||
|
||||
`GET /stream` 的实现,从 `_event_queues[session_id]` 拿事件 → `_sse(event, data)` 序列化 → yield 给 fastapi `StreamingResponse`。
|
||||
|
||||
**关键逻辑**:
|
||||
- 队列不存在时**等待 5 秒**,期间触发 `_recover_session`(跨 worker / 进程重启恢复)
|
||||
- `_sse()` 三层兜底:`default=str` → 手写 notice 帧 → 永不抛 TypeError
|
||||
- 收到 None 哨兵 → 关闭流
|
||||
|
||||
### `_recover_session`(跨 worker 恢复)
|
||||
|
||||
当 worker 内存里没有 session_id 但 sqlite checkpointer 有快照时:
|
||||
1. 从 checkpointer 读出 state snapshot
|
||||
2. 重建 `_event_queues[sid]` 队列
|
||||
3. 从 snapshot 提取当前 interrupt 负载,put 进队列
|
||||
4. 同时尽量恢复 `_session_repos`、`_session_user_ids` 等
|
||||
|
||||
返回三种状态:
|
||||
- `"interrupted"` — 图停在干预点,已重建队列
|
||||
- `"finished"` — 图已完成或中断在节点中间,推 done/error 事件后关闭
|
||||
- `"unrecoverable"` — sqlite 也没有这个 thread_id → 真的丢了
|
||||
|
||||
### `_spawn_graph_task` / `wait_for_inflight_graph_tasks`(优雅关闭)
|
||||
|
||||
- `_spawn_graph_task(coro)` — 启 task 并自动登记到 `_inflight_graph_tasks`,完成时反登记
|
||||
- `wait_for_inflight_graph_tasks(timeout)` — lifespan shutdown 等所有 task 跑到下一个 interrupt / END(默认 30s),超时则 cancel 强制结束
|
||||
|
||||
---
|
||||
|
||||
## 六、Graph 与持久化(graph.py)
|
||||
|
||||
### Lazy 单例 + 自动降级
|
||||
|
||||
```python
|
||||
AI_WRITING_GRAPH = None # 模块级槽位(测试可 monkeypatch)
|
||||
_async_checkpointer_singleton = None # checkpointer 单例
|
||||
|
||||
async def get_ai_writing_graph():
|
||||
"""懒加载 graph。第一次调用时按 config.yaml 决定 checkpointer。"""
|
||||
global AI_WRITING_GRAPH
|
||||
if AI_WRITING_GRAPH is not None:
|
||||
return AI_WRITING_GRAPH
|
||||
checkpointer = await _get_async_checkpointer()
|
||||
AI_WRITING_GRAPH = build_ai_writing_graph(checkpointer=checkpointer)
|
||||
return AI_WRITING_GRAPH
|
||||
```
|
||||
|
||||
### Checkpointer 优先级(_get_async_checkpointer)
|
||||
|
||||
```
|
||||
1. config.yaml 的 checkpointer 段
|
||||
├── type: sqlite → AsyncSqliteSaver(aiosqlite.connect(...))
|
||||
├── type: postgres → AsyncPostgresSaver(AsyncConnectionPool(...))
|
||||
└── type: memory → InMemorySaver
|
||||
|
||||
2. 任何初始化失败 → InMemorySaver(降级,打 warning)
|
||||
3. 未配置 checkpointer 段 → InMemorySaver(降级,打 warning)
|
||||
4. 未知 type(如 "mysql") → InMemorySaver(降级,打 warning)
|
||||
```
|
||||
|
||||
**核心契约**:`_get_async_checkpointer()` **绝不抛异常**,永远返回一个可用的 saver。这是「写作流程能完整继续」契约的关键。
|
||||
|
||||
### Shutdown
|
||||
|
||||
`shutdown_ai_writing_graph()` 在 lifespan shutdown 时调用:
|
||||
- 关闭 `aiosqlite.Connection` / `AsyncConnectionPool`
|
||||
- 清空 `AI_WRITING_GRAPH` / `_async_checkpointer_singleton`
|
||||
|
||||
调用顺序(详见 `app/gateway/app.py` lifespan):
|
||||
1. 停清理任务
|
||||
2. `wait_for_inflight_graph_tasks(30s)`
|
||||
3. `shutdown_ai_writing_graph()`
|
||||
|
||||
---
|
||||
|
||||
## 七、节点详解(nodes/)
|
||||
|
||||
### `nodes/_utils.py`(公共工具)
|
||||
|
||||
| 函数 / 类 | 用途 |
|
||||
|---|---|
|
||||
| `get_model(config, model_name)` | 从 config.configurable 拿 model_name 或回退默认 |
|
||||
| `parse_json(text, fallback)` | 鲁棒 JSON 解析(支持代码块、不完整 JSON、转义错误) |
|
||||
| `_extract_balanced(text)` | 找出第一对平衡的 `{...}` |
|
||||
| `_repair_json(text)` | 修复常见的 JSON 错误(缺尾引号等) |
|
||||
| `ThinkTagParser` | 流式解析 `<think>...</think>` 标签(思考 / 正文分流) |
|
||||
| `llm_json(system, user, fallback, config)` | 调 LLM 并解析 JSON 输出 |
|
||||
| `count_words(text)` | 中文 + 英文混合计字数 |
|
||||
|
||||
### `nodes/intent_parser.py` — 意图解析
|
||||
|
||||
- 调 LLM 把用户意图(`user_intent` 字符串)解析成结构化字段:`article_type`、`target_audience`、`word_count_target`
|
||||
- 用户已经在 `AIWritingRequest` 里指定的字段会**覆盖** LLM 推断
|
||||
- 输出:更新 state 的对应字段,推 `step_done` 事件
|
||||
|
||||
### `nodes/researcher.py` — 素材检索
|
||||
|
||||
- 根据 `user_intent` 用 LLM 生成 N 个关键词(N = `keyword_count`,默认 6)
|
||||
- 用 `search/*` 的 provider 并行检索
|
||||
- 流式推 `research_progress` 事件("正在搜索关键词 X 中...")
|
||||
- 输出:`material_package = {keywords, materials, summary}`,推 `materials_ready`
|
||||
|
||||
### `nodes/writer_outline.py` — 大纲规划
|
||||
|
||||
- 输入:`user_intent` + `material_package`
|
||||
- 调 LLM 流式生成大纲 JSON,期间推 `outline_chunk`(正文 + thinking 分流)
|
||||
- 解析最终 JSON → `current_outline`,推 `outline_ready`
|
||||
|
||||
### `nodes/writer_draft.py` — 草稿生成(最复杂)
|
||||
|
||||
- 按大纲**逐章节**调 LLM 生成正文,每章节独立流式推 `draft_chunk`
|
||||
- 检测 LLM 是否「拒答」(`_looks_like_refusal`),如果是则换提示重试
|
||||
- **严格模式下**:如果章节关联的素材不足,把该章节加入 `blocked_sections`,触发 `pause_section_help`
|
||||
- 完成所有章节后拼成 `current_draft.full_markdown`,推 `draft_ready`
|
||||
|
||||
### `nodes/editor.py` — 编辑审核(3 维度并行)
|
||||
|
||||
- 3 个维度(**fact / logic / language**)并行调 LLM
|
||||
- 每个维度独立流式推 `review_chunk`,完成后推 `review_item_done`
|
||||
- 三个都完成后聚合成 `review_result`,推 `review_ready`
|
||||
- 评分加权(fact 40% + logic 30% + language 30%),超过 `pass_threshold`(默认 80)verdict = `pass`
|
||||
|
||||
---
|
||||
|
||||
## 八、持久化(persistence/ai_writing_sessions/)
|
||||
|
||||
### `model.py` — ORM
|
||||
|
||||
```python
|
||||
class AIWritingSessionRow(Base):
|
||||
__tablename__ = "ai_writing_sessions"
|
||||
id: Mapped[str] # = session_id(uuid)
|
||||
user_id: Mapped[str | None] # NULL = no-auth 模式
|
||||
title: Mapped[str] # 从 user_intent 截取前 50 字
|
||||
user_intent: Mapped[str | None]
|
||||
status: Mapped[str] # in_progress / draft_ready / review_ready / done / error
|
||||
draft_title: Mapped[str | None]
|
||||
draft_markdown: Mapped[str | None]
|
||||
completed_interventions: Mapped[str | None] # JSON 字符串
|
||||
review_result: Mapped[str | None] # JSON 字符串
|
||||
transcript: Mapped[str | None] # PortableLongText(MySQL→LONGTEXT,4GB)
|
||||
created_at / updated_at: BeijingDateTime
|
||||
```
|
||||
|
||||
### `sql.py` — Repository
|
||||
|
||||
| 方法 | 用途 |
|
||||
|---|---|
|
||||
| `create(id, title, user_intent, user_id)` | 启动写作时建一行 |
|
||||
| `get(session_id, user_id)` | 取单个(带 user_id 权限校验) |
|
||||
| `list(user_id, limit)` | 列出当前用户的会话 |
|
||||
| `update(session_id, user_id, ...)` | 更新指定字段(其他保持不变) |
|
||||
| `delete(session_id, user_id)` | 删除(带权限校验) |
|
||||
| `list_older_than(cutoff, only_finished, limit)` | 清理用:列出过期 session_id |
|
||||
| `delete_by_ids(session_ids)` | 清理用:批量删除 |
|
||||
|
||||
### Alembic 迁移
|
||||
|
||||
- `20260517_03_ai_writing_transcript.py` — 加 transcript 列(TEXT)
|
||||
- `20260519_02_ai_writing_transcript_longtext.py` — MySQL 上把 transcript 改成 LONGTEXT(修 64KB 限制导致的 500)
|
||||
|
||||
---
|
||||
|
||||
## 九、定期清理(ai_writing_cleanup.py)
|
||||
|
||||
### 设计目标
|
||||
- 删超过 `retention_days`(默认 7)的旧会话
|
||||
- 联动清理:MySQL 业务行 + SQLite checkpointer 里的 thread 状态
|
||||
- **绝对不删正在写的会话**(活跃保护)
|
||||
|
||||
### 关键函数
|
||||
|
||||
```python
|
||||
async def run_cleanup_once(*, retention_days, only_finished=False, batch_size=500) -> dict
|
||||
```
|
||||
|
||||
返回 `{deleted_db, deleted_checkpoints, skipped_active}`。
|
||||
|
||||
执行流程:
|
||||
1. 调 `repo.list_older_than(cutoff, only_finished, batch_size)` 拿过期 id
|
||||
2. 从 `_event_queues.keys()` 拿活跃 session_id 集合 → **过滤掉所有仍在跑的**
|
||||
3. 对每个待删 id:调 `checkpointer.adelete_thread(id)` 删 checkpoint
|
||||
4. 调 `repo.delete_by_ids(ids)` 批量删 MySQL
|
||||
5. 循环直到本批 < batch_size
|
||||
|
||||
### Cron 调度
|
||||
|
||||
`cleanup_scheduler_loop()` 常驻后台任务:
|
||||
- 用 `croniter` 解析 `ai_writing.cleanup.cron`(默认 `"0 3 * * *"`,每天凌晨 3 点)
|
||||
- `asyncio.sleep` 到下次触发时间
|
||||
- 多 worker 用文件锁(`runtime_home() / ".ai_writing_cleanup.lock"`)做 leader election
|
||||
|
||||
### Admin 手动触发
|
||||
|
||||
`POST /api/ai-writing/admin/cleanup`:
|
||||
- 鉴权:`system_role == "admin"`
|
||||
- 可选覆盖 `retention_days` / `only_finished` / `batch_size`
|
||||
- 即使传 `retention_days: 0` 也不会秒杀活跃会话(活跃保护硬保证)
|
||||
|
||||
---
|
||||
|
||||
## 十、配置(config.yaml)
|
||||
|
||||
```yaml
|
||||
# Checkpointer:graph 状态持久化
|
||||
checkpointer:
|
||||
type: sqlite # sqlite | postgres | memory
|
||||
connection_string: .deer-flow/data/checkpoints.db
|
||||
|
||||
# AI 写作子系统
|
||||
ai_writing:
|
||||
# ── 清理调度 ─────────────────────────────────────────────
|
||||
cleanup:
|
||||
enabled: true
|
||||
retention_days: 7
|
||||
cron: "0 3 * * *" # 每天凌晨 3 点
|
||||
delete_only_finished: false
|
||||
batch_size: 500
|
||||
|
||||
# ── 素材收集专家(researcher)专用配置 ──────────────────
|
||||
# 这两个模型名必须出现在顶层 models[] 段里;留空 / 不配则回退当前写作主模型。
|
||||
# 拆出来是为了让「检索词生成 / 素材排序」这种结构化抽取任务可以走更轻量、
|
||||
# 不带思考输出的小模型,省钱省时间,也避开写作主模型的思考干扰。
|
||||
keyword_model: qwen3-4b-instruct # 检索词生成(EXTRACT_KEYWORDS_*)
|
||||
rank_model: qwen3-4b-instruct # 素材排序筛选(RANK_MATERIALS_*)
|
||||
|
||||
# 素材总量上限:合并去重后保留多少条素材进入大纲 / 草稿阶段
|
||||
# 取值范围 [1, 100],非法值或缺省回退 20。
|
||||
# 每个检索词分到的检索条数 = ceil(max_materials / keyword_count),
|
||||
# 所以这个数越大,每个关键词向 provider 拉的条数越多,整体耗时也越长。
|
||||
max_materials: 20
|
||||
|
||||
# ── 素材检索 provider ───────────────────────────────────
|
||||
# internet(默认,DuckDuckGo) / intranet(内网 ES) / tavily / demo
|
||||
search_provider: internet
|
||||
|
||||
# internet 模式:DuckDuckGo 区域代码(cn-zh / us-en / wt-wt 等)
|
||||
ddg_region: "cn-zh"
|
||||
|
||||
# intranet 模式:对接内网知识库 ES 检索接口
|
||||
# 接口契约(POST):
|
||||
# payload = {"query": "...", "knowledge_base_name": "<intranet_knowledge_base>", "ts": "yes"}
|
||||
# response = [{"page_content": "...", "metadata": {m_title, content, recUuid, m_publish, source1}}]
|
||||
# 实现见 packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples # 默认 samples,按部署的实际知识库名改
|
||||
```
|
||||
|
||||
### 配置项与代码的对应关系
|
||||
|
||||
| 配置键 | 默认值 | 取值范围 / 说明 | 读取位置 |
|
||||
|---|---|---|---|
|
||||
| `ai_writing.keyword_model` | 空(回退写作主模型) | 必须是 `models[]` 段里已声明的模型名 | `nodes/researcher.py` → `llm_json(..., model_name=keyword_model)` |
|
||||
| `ai_writing.rank_model` | 空(回退写作主模型) | 同上 | `nodes/researcher.py` → 排序分支 `llm_json(..., model_name=rank_model)` |
|
||||
| `ai_writing.max_materials` | `20` | `[1, 100]`,非法值回退 20;`_resolve_max_materials` 钳制 | `nodes/researcher.py:_DEFAULT_MAX_MATERIALS` |
|
||||
| `ai_writing.search_provider` | `"internet"` | `internet` / `intranet` / `tavily` / `demo` | `search/__init__.py:get_search_provider` |
|
||||
| `ai_writing.ddg_region` | `"cn-zh"` | DuckDuckGo 区域代码,仅 `internet` 模式生效 | 同上 |
|
||||
| `ai_writing.intranet_search_url` | `http://10.106.57.180:7863/knowledge_base/search_docs_es` | 仅 `intranet` 模式生效,POST JSON 接口 | `search/intranet_stub.py:_DEFAULT_ENDPOINT` |
|
||||
| `ai_writing.intranet_knowledge_base` | `"samples"` | 仅 `intranet` 模式生效,知识库 ID/名称 | 同上 |
|
||||
|
||||
### 配置如何传到节点
|
||||
|
||||
```
|
||||
config.yaml
|
||||
└─ ai_writing: { ... }
|
||||
│
|
||||
▼ _ai_writing_config() 取出整个 dict
|
||||
routers/ai_writing.py:_make_config()
|
||||
│ 注入到 config.configurable.ai_writing_config
|
||||
▼
|
||||
nodes/researcher.py
|
||||
cfg = config.get("configurable", {})
|
||||
ai_writing_config = cfg.get("ai_writing_config", {})
|
||||
keyword_model = ai_writing_config.get("keyword_model")
|
||||
rank_model = ai_writing_config.get("rank_model")
|
||||
max_materials = _resolve_max_materials(ai_writing_config.get("max_materials"))
|
||||
provider = get_search_provider(ai_writing_config) # ← search_provider 等也从这里读
|
||||
```
|
||||
|
||||
### 切换内网检索的最小步骤
|
||||
|
||||
1. `config.yaml` 改 `ai_writing.search_provider: intranet`
|
||||
2. 填 `intranet_search_url` 和 `intranet_knowledge_base`(部署侧给)
|
||||
3. 内网 ES 接口若返回结构异常 / 网络抖动,researcher 节点会收集 `provider.errors` + `provider.raw_responses`,作为非致命 `notice` 事件推前端时间线(折叠显示原始响应体),**不会中断写作流程**
|
||||
4. 健康检查:可通过 `IntranetSearchProvider.health_check()` 单测,或直接发一笔 `/start` 看 `research_progress` 是否报错
|
||||
|
||||
### 备注
|
||||
|
||||
- `keyword_model` / `rank_model` 只影响素材收集专家的 LLM 调用,**不影响**大纲、草稿、编辑审核三个节点(它们各自走自己的主模型)。
|
||||
- `max_materials` 是「合并去重后的总量上限」,不是每个关键词的检索条数;每个词的检索数会按 `ceil(max_materials / keyword_count)` 均分。`keyword_count` 由前端启动写作时传入(1~10,默认 6)。
|
||||
- `intranet` 模式不带 fallback;如果内网 ES 全挂,researcher 拿到空结果会直接进入「未检索到素材,可重新搜索或在无素材的情况下继续写作」分支。如果想让内网失败时退回互联网检索,需要在 `get_search_provider` 里手动用 `_FallbackSearchProvider(IntranetSearchProvider(...), DuckDuckGoSearchProvider(...))` 包一层。
|
||||
|
||||
ai_writing:
|
||||
keyword_model: deepseek-chat # 检索词生成专用模型,留空回退写作主模型
|
||||
rank_model: deepseek-chat # 素材排序专用模型,留空回退写作主模型
|
||||
max_materials: 50 # 合并去重后的素材总量上限,范围 [1, 100],默认 20
|
||||
|
||||
---
|
||||
|
||||
## 十一、SSE 事件协议(后端 → 前端契约)
|
||||
|
||||
后端通过 `_sse(event_type, data)` 推 `event: <type>\ndata: <json>\n\n` 格式。
|
||||
|
||||
### 推出事件的位置
|
||||
|
||||
| 事件 | 推出位置 |
|
||||
|---|---|
|
||||
| `connected` | `_sse_generator` 入口 |
|
||||
| `step_start` / `step_done` | 节点内部主动 emit |
|
||||
| `research_progress` | `researcher_node` 内的 `_emit` |
|
||||
| `materials_ready` | `_stream_graph_segment` 检测到 `material_package` 字段 |
|
||||
| `outline_chunk` | `writer_outline_node` 流式推(通过 `sse_queue`) |
|
||||
| `outline_ready` | `_stream_graph_segment` 检测到 `current_outline` |
|
||||
| `draft_chunk` | `writer_draft_node` 按章节流式推 |
|
||||
| `draft_ready` | `_stream_graph_segment` 检测到 `current_draft` |
|
||||
| `review_chunk` / `review_item_done` | `editor_node` 3 维度并行流式 |
|
||||
| `review_ready` | `_stream_graph_segment` 检测到 `review_result` |
|
||||
| `await_user` | `_stream_graph_segment` 捕获 `__interrupt__` |
|
||||
| `resumed` | pause 节点 return 时塞进 `progress_events` |
|
||||
| `notice` | 节点内手动推(非致命告警) |
|
||||
| `done` | `_stream_graph_segment` 正常结束 |
|
||||
| `error` | `_stream_graph_segment` except 分支 |
|
||||
|
||||
### 节点如何流式推 chunk
|
||||
|
||||
通过 `config.configurable.sse_queue`:
|
||||
|
||||
```python
|
||||
async def writer_outline_node(state, config):
|
||||
sse_queue = config.get("configurable", {}).get("sse_queue")
|
||||
sse_session_id = config.get("configurable", {}).get("sse_session_id")
|
||||
|
||||
async for chunk in llm.astream(prompt):
|
||||
if sse_queue:
|
||||
await sse_queue.put(("outline_chunk", {
|
||||
"session_id": sse_session_id,
|
||||
"text": chunk.content,
|
||||
"is_thinking": False,
|
||||
}))
|
||||
|
||||
return {"current_outline": parsed, ...}
|
||||
```
|
||||
|
||||
`sse_queue` 是路由层在 `_make_config` 里注入的 `_event_queues[session_id]`。
|
||||
|
||||
---
|
||||
|
||||
## 十二、扩展指南:常见开发任务
|
||||
|
||||
### 任务 1:新增一个 Graph 节点
|
||||
|
||||
**例**:在 `editor` 之后加一个「术语统一」节点。
|
||||
|
||||
1. **写节点**:在 `nodes/` 新建 `terminology.py`:
|
||||
```python
|
||||
async def terminology_node(state, config):
|
||||
# ... 读 current_draft 改术语
|
||||
return {
|
||||
"current_draft": new_draft,
|
||||
"progress_events": [{"type": "step_done", "agent_name": "术语统一", "message": "..."}],
|
||||
}
|
||||
```
|
||||
|
||||
2. **注册到 graph**:在 `graph.py` 的 `build_ai_writing_graph` 里:
|
||||
```python
|
||||
from .nodes.terminology import terminology_node
|
||||
g.add_node("terminology", terminology_node)
|
||||
# 改 editor 后的边
|
||||
g.add_edge("editor", "terminology")
|
||||
g.add_edge("terminology", "pause_review")
|
||||
```
|
||||
|
||||
3. **State 加字段**(如果需要):`state.py` 加新字段到 `AIWritingState`
|
||||
|
||||
4. **前端事件支持**(如果要新事件类型):见前端文档「扩展指南」
|
||||
|
||||
5. **测试**:在 `tests/` 加单元测试,至少测节点能正常 return 和 graph 拓扑能编译
|
||||
|
||||
### 任务 2:新增一个用户干预点
|
||||
|
||||
1. **写 pause 节点**:在 `graph.py` 顶部加:
|
||||
```python
|
||||
async def pause_xxx(state):
|
||||
user_input = interrupt({
|
||||
"pause_point": "xxx_confirm",
|
||||
"message": "...",
|
||||
"context_field": state["..."],
|
||||
})
|
||||
action = user_input.get("action", "confirm")
|
||||
if action == "confirm":
|
||||
return {"status": "next_status", "progress_events": [{"type": "resumed", ...}]}
|
||||
# ... 其他分支
|
||||
```
|
||||
|
||||
2. **路由层 ResumeRequest 加字段**(如果干预需要新字段):
|
||||
```python
|
||||
class ResumeRequest(BaseModel):
|
||||
# ...
|
||||
new_field: Optional[str] = None
|
||||
```
|
||||
|
||||
3. **`resume_writing` 路由把字段塞进 Command(resume=...)**:
|
||||
```python
|
||||
intervention = {
|
||||
# ...
|
||||
"newField": body.new_field,
|
||||
}
|
||||
```
|
||||
|
||||
4. **graph 拓扑加 pause 节点的边**:在 build_ai_writing_graph 里
|
||||
|
||||
5. **前端配合**:见前端文档
|
||||
|
||||
### 任务 3:新增一种素材检索 provider
|
||||
|
||||
1. 在 `agents/ai_writing/search/` 加新文件,实现 `SearchProvider` 接口(参考 `internet_search.py`):
|
||||
```python
|
||||
class MyProvider(SearchProvider):
|
||||
async def search(self, keyword: str, limit: int) -> list[SearchResult]:
|
||||
...
|
||||
```
|
||||
|
||||
2. 配置 `config.yaml`:
|
||||
```yaml
|
||||
ai_writing:
|
||||
search:
|
||||
provider: my_pkg.search:MyProvider
|
||||
# provider 特定的配置...
|
||||
```
|
||||
|
||||
3. `researcher_node` 通过 `config.configurable.ai_writing_config.search.provider` 用 `resolve_class` 动态加载
|
||||
|
||||
### 任务 4:修改 LLM Prompt
|
||||
|
||||
所有 prompt 集中在 `agents/ai_writing/prompts/`:
|
||||
- `researcher_prompts.py` — 检索专家
|
||||
- `writer_prompts.py` — 大纲 + 草稿
|
||||
- `editor_prompts.py` — 审核
|
||||
|
||||
改 prompt 后**强烈建议**:
|
||||
1. 在 `tests/` 加针对该 prompt 输出的回归测试(用 LLM mock 或固定输入)
|
||||
2. 跑现有测试确认拓扑兼容
|
||||
|
||||
### 任务 5:扩展持久化字段
|
||||
|
||||
例:给 `ai_writing_sessions` 加一个 `total_tokens` 列。
|
||||
|
||||
1. `model.py` 加列:
|
||||
```python
|
||||
total_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
```
|
||||
|
||||
2. **必须**新建 alembic 迁移:
|
||||
```bash
|
||||
PYTHONPATH=. uv run alembic revision -m "add total_tokens column"
|
||||
```
|
||||
然后填 upgrade/downgrade 逻辑(参考 `20260519_02_ai_writing_transcript_longtext.py`)
|
||||
|
||||
3. `sql.py` 的 `update()` / `to_dict()` 加字段处理
|
||||
|
||||
4. `routers/ai_writing.py` 的 `SessionResponse` 加字段
|
||||
|
||||
5. 测试:在 `tests/test_ai_writing_session_repo.py` 加新字段的 round-trip 测试
|
||||
|
||||
---
|
||||
|
||||
## 十三、容错策略(关键设计原则)
|
||||
|
||||
### 「写作流程能完整继续」契约
|
||||
|
||||
后端承诺:**除非 Pydantic 校验失败(4xx),AI 写作的 4 个核心接口任何后端组件失败都不阻断当次写作的完整执行**。
|
||||
|
||||
### 落实位置
|
||||
|
||||
| 失败点 | 兜底位置 | 行为 |
|
||||
|---|---|---|
|
||||
| sqlite checkpointer 初始化失败 | `_get_async_checkpointer` | 自动降级 InMemorySaver |
|
||||
| SSE JSON 序列化失败 | `_sse()` | 三层兜底(default=str → notice 帧) |
|
||||
| DB `create()` session 行失败 | `start_writing` | try/except 吞掉,session_id 仍返回 |
|
||||
| DB `update(draft)` 失败 | `_stream_graph_segment` | try/except 吞掉,graph 继续推 |
|
||||
| DB `update(review)` 失败 | 同上 | 同上 |
|
||||
| DB `update(transcript)` 失败 | `save_transcript` 路由 | 返回 `success=false`,不抛 5xx |
|
||||
| 文章类型加载失败 | `start_writing` | 退回 `[]` |
|
||||
| Graph 编译失败 | `start_writing` | 抛 503(极端情况,节点 import 错) |
|
||||
| Graph 运行时节点抛错 | `_stream_graph_segment` | except 推 error 事件,session 结束 |
|
||||
| Cleanup 误删活跃会话 | `run_cleanup_once` | 活跃保护硬过滤 |
|
||||
|
||||
### 容器优雅关闭
|
||||
|
||||
lifespan shutdown 顺序:
|
||||
1. 停 cleanup scheduler
|
||||
2. `wait_for_inflight_graph_tasks(30s)` — 等所有 in-flight 写作跑到下一个 checkpoint
|
||||
3. 超时未结束的 task `cancel()` + 2s ack 等待
|
||||
4. `shutdown_ai_writing_graph()` — 关闭 aiosqlite Connection / pg pool
|
||||
|
||||
---
|
||||
|
||||
## 十四、测试覆盖
|
||||
|
||||
| 测试文件 | 数量 | 覆盖范围 |
|
||||
|---|---|---|
|
||||
| `tests/test_ai_writing_session_repo.py` | 7 | Repo CRUD + 清理用的 list_older_than / delete_by_ids |
|
||||
| `tests/test_ai_writing_session_recovery.py` | 8 | `_recover_session` / `_extract_interrupt` / `resume_writing` 跨 worker 恢复 |
|
||||
| `tests/test_ai_writing_transcript_endpoint.py` | 5 | transcript 保存兜底 |
|
||||
| `tests/test_ai_writing_cleanup.py` | 26 | 清理 + drain + checkpointer 降级 + SSE 序列化 + admin 接口 + 多 worker 检测 |
|
||||
| `tests/test_portable_long_text.py` | 5 | LONGTEXT 列类型 + 迁移链 |
|
||||
|
||||
跑全部:
|
||||
```bash
|
||||
PYTHONPATH=. uv run pytest tests/test_ai_writing*.py tests/test_portable_long_text.py -v
|
||||
```
|
||||
|
||||
期望 **51 个测试全过**。
|
||||
|
||||
---
|
||||
|
||||
## 十五、关键调试 / 排查命令
|
||||
|
||||
### 看 graph 当前状态
|
||||
|
||||
```python
|
||||
# 进 python shell
|
||||
from deerflow.agents.ai_writing.graph import get_ai_writing_graph
|
||||
graph = await get_ai_writing_graph()
|
||||
snap = await graph.aget_state({"configurable": {"thread_id": "<session_id>"}})
|
||||
print(snap.values, snap.next, snap.tasks)
|
||||
```
|
||||
|
||||
### 直接读 sqlite checkpointer
|
||||
|
||||
```bash
|
||||
sqlite3 /app/.deer-flow/data/checkpoints.db
|
||||
.tables # 看表(checkpoints / checkpoint_writes 等)
|
||||
SELECT thread_id, checkpoint_ns, checkpoint_id FROM checkpoints
|
||||
WHERE thread_id = '<session_id>' LIMIT 5;
|
||||
```
|
||||
|
||||
### 查 MySQL 业务行
|
||||
|
||||
```sql
|
||||
SELECT id, status, draft_title, updated_at,
|
||||
JSON_LENGTH(transcript) AS transcript_size
|
||||
FROM ai_writing_sessions
|
||||
WHERE id = '<session_id>';
|
||||
```
|
||||
|
||||
### 查内存中的活跃会话(调试时手动加)
|
||||
|
||||
```python
|
||||
# 临时在路由加一个 admin 接口
|
||||
@router.get("/admin/inflight")
|
||||
async def admin_inflight():
|
||||
return {
|
||||
"event_queues": list(_event_queues.keys()),
|
||||
"inflight_tasks": len(_inflight_graph_tasks),
|
||||
"pending_pause": list(_session_pending_pause.keys()),
|
||||
}
|
||||
```
|
||||
|
||||
### 日志关键字
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing|AsyncSqliteSaver|checkpointer|recovered'
|
||||
```
|
||||
|
||||
期望看到(启动时):
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理调度起来
|
||||
|
||||
警告:
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 故障
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 警告(详见 `ai-writing-说明.md`)
|
||||
|
||||
---
|
||||
|
||||
## 十六、性能与限制
|
||||
|
||||
### 单 session 资源占用
|
||||
- 内存:~50KB(state + progress_events 累计)
|
||||
- sqlite checkpoint:每次 state update 写一行(一篇完整文章约 20-50 行)
|
||||
- LLM tokens:约 30K-100K(取决于素材量 + 字数 + 修订次数)
|
||||
|
||||
### 并发上限
|
||||
- 单 worker 内:理论上百级 session 并发(IO bound),实际 LLM API rate limit 才是瓶颈
|
||||
- 多 worker:**当前 in-memory dict 隔离,详见 ai-writing-说明.md 第六章**
|
||||
|
||||
### 长会话风险
|
||||
- progress_events 累加无界 → 长会话 state 可能膨胀到几 MB
|
||||
- transcript JSON 同理 → MySQL 行可能上 MB(但 LONGTEXT 列上限 4GB,影响读取性能)
|
||||
|
||||
### 优化方向
|
||||
- progress_events 节流(聚合 chunk 事件)
|
||||
- transcript 增量保存(diff 而非全量覆盖)
|
||||
- 节点级 LLM 调用加 retry(tenacity)
|
||||
|
||||
---
|
||||
|
||||
## 十七、与主 Agent 的关系
|
||||
|
||||
| 维度 | 主 Agent | AI 写作 |
|
||||
|---|---|---|
|
||||
| Graph 入口 | `make_lead_agent` | `AI_WRITING_GRAPH` |
|
||||
| Middleware | 18 层中间件 | 无(直接节点链) |
|
||||
| State | `ThreadState` | `AIWritingState` |
|
||||
| Checkpointer | `make_checkpointer()`(共享) | `_get_async_checkpointer()`(同一文件,不同 thread_id 命名空间) |
|
||||
| 路由前缀 | `/api/langgraph/*` `/api/threads/*` | `/api/ai-writing/*` |
|
||||
| 流式协议 | LangGraph stream_mode(messages-tuple/values) | 自定义 SSE 事件 |
|
||||
| 用户干预 | 通过 `ask_clarification` 工具 | 通过 LangGraph `interrupt()` |
|
||||
| 持久化业务表 | `threads_meta`、`runs` | `ai_writing_sessions` |
|
||||
|
||||
二者**完全独立**,仅在「共享同一个 sqlite checkpointer 文件」这一点上有间接关联(也可以拆开,性能层面没区别)。
|
||||
|
||||
---
|
||||
|
||||
## 十八、未来 RFC 方向
|
||||
|
||||
| 主题 | 描述 |
|
||||
|---|---|
|
||||
| 多 worker 部署支持 | 把 `_event_queues` 等搬 Redis Pub/Sub,参考 `ai-writing-说明.md` 方案 B |
|
||||
| Graph 拓扑可配置 | 现在节点链路写死,未来按 article_type 走不同拓扑(如「研报」vs「散文」) |
|
||||
| 节点级重试 | LLM 抽风时自动重试 N 次,参考 `tenacity` |
|
||||
| 素材跨 session 复用 | 同一用户多次写作可能用同一批素材 → 缓存层 |
|
||||
| 多模态素材 | 支持图片素材输入和图文混合输出 |
|
||||
| 中文标点 / 排版规则注入 | 在节点 prompt 里强化中文出版规范 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:相关文件速查
|
||||
|
||||
| 文件 | 行数 | 用途 |
|
||||
|---|---|---|
|
||||
| `routers/ai_writing.py` | 1006 | 路由 + 状态字典 + SSE + drain |
|
||||
| `agents/ai_writing/graph.py` | 538 | StateGraph + checkpointer 工厂 |
|
||||
| `agents/ai_writing/state.py` | 121 | AIWritingState 定义 |
|
||||
| `agents/ai_writing/nodes/_utils.py` | 242 | LLM 工具 |
|
||||
| `agents/ai_writing/nodes/intent_parser.py` | 131 | 节点:意图解析 |
|
||||
| `agents/ai_writing/nodes/researcher.py` | 271 | 节点:素材检索 |
|
||||
| `agents/ai_writing/nodes/writer_outline.py` | 147 | 节点:大纲规划 |
|
||||
| `agents/ai_writing/nodes/writer_draft.py` | 318 | 节点:草稿生成 |
|
||||
| `agents/ai_writing/nodes/editor.py` | 180 | 节点:编辑审核 |
|
||||
| `app/gateway/ai_writing_cleanup.py` | ~280 | 清理 + 调度 |
|
||||
| `persistence/ai_writing_sessions/sql.py` | ~200 | Repository |
|
||||
| `persistence/ai_writing_sessions/model.py` | ~35 | ORM |
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见踩坑
|
||||
|
||||
| 现象 | 根因 | 解决 |
|
||||
|---|---|---|
|
||||
| `/start` 报 500 | graph 编译失败(多半是 import 错) | 看启动日志 stack trace |
|
||||
| `/resume` 报 404 | session_id 不存在或 checkpointer 没快照 | 看 `ai_writing recover` 日志 |
|
||||
| transcript 500 | DB 列容量 / 网络抖 | ✅ 已修,前端 fire-and-forget |
|
||||
| SSE 卡住不动 | 多 worker SSE 隔离 / 反代超时 | 见 `ai-writing-说明.md` |
|
||||
| `draft_chunk` 丢失内容 | `ThinkTagParser` 在 `<think>` 标签边界处理 | 看 `_utils.py:ThinkTagParser.feed` 单测 |
|
||||
| graph 卡在某节点不退出 | LLM API 卡住 / 无限重试 | 看节点日志,加 timeout |
|
||||
| cleanup 没跑 | cron 表达式配错 / 文件锁被其他 worker 持有 | 看 `ai_writing_cleanup` 日志 |
|
||||
| 草稿在前端看不到但 MySQL 有 | 多 worker 导致 SSE 事件丢失 | 同上 |
|
||||
| 配 mysql 做 checkpointer 报错 | LangGraph 不支持 MySQL checkpointer | 用 sqlite 或 postgres |
|
||||
|
||||
---
|
||||
|
||||
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
|
||||
@@ -0,0 +1,645 @@
|
||||
# AI 写作后端新路线设计草案
|
||||
|
||||
## 1. 目标
|
||||
|
||||
为“弱模型多 Agent 写作工作流”建设一条独立后端路线,满足以下要求:
|
||||
|
||||
- 不影响现有同事正在开发的功能
|
||||
- 不强耦合现有 DeerFlow 通用对话主链路
|
||||
- 可独立部署、独立调试、独立回滚
|
||||
- 支持多阶段写作编排、人工介入、失败重试、流式事件输出
|
||||
|
||||
---
|
||||
|
||||
## 2. 推荐建设方式
|
||||
|
||||
优先建议:
|
||||
|
||||
- 在 `offline-backend-20260512` 下新建独立应用或独立模块
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
如果当前后端没有 `apps/` 结构,也可以采用:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
ai-writing-orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
重点不是目录名字,而是一定要把“多 Agent 写作编排”与现有通用聊天能力隔离开。
|
||||
|
||||
---
|
||||
|
||||
## 3. 技术路线建议
|
||||
|
||||
推荐组合:
|
||||
|
||||
- 编排框架:`LangGraph`
|
||||
- 模型调用:接你们内网模型网关
|
||||
- 检索层:接内网知识库 / 文档库 / 搜索服务
|
||||
- 状态存储:先用数据库或 Redis + DB,后续再演进
|
||||
- 事件输出:SSE 或流式 HTTP
|
||||
|
||||
推荐原因:
|
||||
|
||||
- `LangGraph` 适合状态机和多节点回退
|
||||
- 对弱模型最关键的是“强流程控制”,不是“自由 Agent 对话”
|
||||
- 后续加人工确认节点和 checkpoint 会更自然
|
||||
|
||||
---
|
||||
|
||||
## 4. 分层设计
|
||||
|
||||
建议按四层拆分。
|
||||
|
||||
## 4.1 API 层
|
||||
|
||||
职责:
|
||||
|
||||
- 接收前端请求
|
||||
- 鉴权
|
||||
- 参数校验
|
||||
- 返回 workflow 信息
|
||||
- 提供事件流出口
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
api/
|
||||
routes/
|
||||
workflows.py
|
||||
workflow_events.py
|
||||
schemas/
|
||||
workflow_request.py
|
||||
workflow_response.py
|
||||
```
|
||||
|
||||
## 4.2 Application 层
|
||||
|
||||
职责:
|
||||
|
||||
- 组织用例
|
||||
- 调度 workflow service
|
||||
- 管理人工确认动作
|
||||
- 处理重试和终止逻辑
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
application/
|
||||
services/
|
||||
workflow_service.py
|
||||
workflow_event_service.py
|
||||
human_action_service.py
|
||||
use_cases/
|
||||
create_workflow.py
|
||||
start_workflow.py
|
||||
confirm_outline.py
|
||||
rewrite_section.py
|
||||
approve_final.py
|
||||
```
|
||||
|
||||
## 4.3 Domain 层
|
||||
|
||||
职责:
|
||||
|
||||
- 定义工作流状态机
|
||||
- 定义 Agent 输入输出协议
|
||||
- 定义 artifact 与评分模型
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 4.4 Infrastructure 层
|
||||
|
||||
职责:
|
||||
|
||||
- 访问模型网关
|
||||
- 访问检索服务
|
||||
- 访问数据库
|
||||
- 发送事件
|
||||
- 缓存与日志
|
||||
|
||||
建议文件:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
llm/
|
||||
model_client.py
|
||||
retrieval/
|
||||
intranet_search_client.py
|
||||
knowledge_base_client.py
|
||||
persistence/
|
||||
workflow_repository.py
|
||||
artifact_repository.py
|
||||
streaming/
|
||||
sse_publisher.py
|
||||
logging/
|
||||
workflow_logger.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 工作流最小闭环
|
||||
|
||||
第一版建议只做如下状态机:
|
||||
|
||||
1. `collect_materials`
|
||||
2. `generate_outline`
|
||||
3. `await_outline_confirmation`
|
||||
4. `write_draft`
|
||||
5. `review_draft`
|
||||
6. `rewrite_if_needed`
|
||||
7. `complete`
|
||||
|
||||
终止规则:
|
||||
|
||||
- 编辑评分达标则完成
|
||||
- 否则进入重写
|
||||
- 最多重写 2 次
|
||||
- 超过次数则标记 `human_intervention_required`
|
||||
|
||||
---
|
||||
|
||||
## 6. Agent 模块设计
|
||||
|
||||
## 6.1 Researcher
|
||||
|
||||
输入:
|
||||
|
||||
- 主题
|
||||
- 用户要求
|
||||
- 检索范围
|
||||
- 可用资料源
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
程序职责:
|
||||
|
||||
- 先做检索
|
||||
- 再把检索结果裁剪后送给模型
|
||||
- 输出必须结构化
|
||||
|
||||
## 6.2 Outliner
|
||||
|
||||
输入:
|
||||
|
||||
- 用户要求
|
||||
- 研究结果
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
约束:
|
||||
|
||||
- 提纲优先,不写正文
|
||||
|
||||
## 6.3 Writer
|
||||
|
||||
输入:
|
||||
|
||||
- 已确认提纲
|
||||
- 素材卡片
|
||||
- 风格要求
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
建议:
|
||||
|
||||
- 按章节分段生成,不要一次长文直出
|
||||
|
||||
## 6.4 Editor
|
||||
|
||||
输入:
|
||||
|
||||
- 初稿
|
||||
- 提纲
|
||||
- 素材
|
||||
- 用户要求
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
规则:
|
||||
|
||||
- 先查事实,再查结构,再查文风
|
||||
|
||||
## 6.5 Supervisor
|
||||
|
||||
输入:
|
||||
|
||||
- 各阶段结果
|
||||
- 重试计数
|
||||
- 评分结果
|
||||
|
||||
输出:
|
||||
|
||||
- 下一跳节点
|
||||
- 是否需要人工确认
|
||||
- 是否结束
|
||||
|
||||
---
|
||||
|
||||
## 7. Prompt 组织建议
|
||||
|
||||
建议为每个 Agent 分开管理 prompt,不要写成一个超级 prompt。
|
||||
|
||||
建议目录:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
原则:
|
||||
|
||||
- 每个 Agent 一套职责
|
||||
- 每个 Agent 一套输出 schema
|
||||
- Prompt 版本可管理
|
||||
- 后续支持 A/B 调参
|
||||
|
||||
---
|
||||
|
||||
## 8. 弱模型适配策略
|
||||
|
||||
## 8.1 先检索后生成
|
||||
|
||||
不要让模型自己“想资料”,程序先检索、裁剪、去重后再喂给模型。
|
||||
|
||||
## 8.2 短上下文任务化
|
||||
|
||||
每个节点只给弱模型一小段明确任务:
|
||||
|
||||
- 研究归纳
|
||||
- 提纲整理
|
||||
- 单章节写作
|
||||
- 单章节审稿
|
||||
|
||||
## 8.3 结构化输出
|
||||
|
||||
优先 JSON 或 Pydantic schema,不要放任自然语言自由返回。
|
||||
|
||||
## 8.4 章节级重写
|
||||
|
||||
不要整篇回炉。编辑指出第 2 节有问题,就只重写第 2 节。
|
||||
|
||||
## 8.5 程序接管质量门槛
|
||||
|
||||
例如:
|
||||
|
||||
- 资料引用数不足,不进入下一步
|
||||
- 提纲章节数过少,直接重生
|
||||
- 编辑评分低于阈值,自动重写
|
||||
|
||||
---
|
||||
|
||||
## 9. 数据模型建议
|
||||
|
||||
## 9.1 Workflow
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `status`
|
||||
- `current_stage`
|
||||
- `user_id`
|
||||
- `thread_id`
|
||||
- `input_payload`
|
||||
- `current_artifact_id`
|
||||
- `retry_count`
|
||||
- `created_at`
|
||||
- `updated_at`
|
||||
|
||||
## 9.2 Workflow Artifact
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `artifact_type`
|
||||
- `stage`
|
||||
- `version`
|
||||
- `title`
|
||||
- `content`
|
||||
- `metadata`
|
||||
|
||||
## 9.3 Workflow Event
|
||||
|
||||
建议字段:
|
||||
|
||||
- `id`
|
||||
- `workflow_id`
|
||||
- `event_type`
|
||||
- `stage`
|
||||
- `agent_name`
|
||||
- `payload`
|
||||
- `created_at`
|
||||
|
||||
## 9.4 Human Action
|
||||
|
||||
建议字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `action_type`
|
||||
- `payload`
|
||||
- `operator`
|
||||
- `created_at`
|
||||
|
||||
---
|
||||
|
||||
## 10. API 草案
|
||||
|
||||
## 10.1 创建任务
|
||||
|
||||
`POST /api/ai-writing/workflows`
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"topic": "主题",
|
||||
"articleType": "行业分析",
|
||||
"audience": "管理层",
|
||||
"tone": "专业克制",
|
||||
"targetLength": 3000,
|
||||
"constraints": ["避免口语化"],
|
||||
"referenceScope": ["内网知识库", "项目复盘文档"]
|
||||
}
|
||||
```
|
||||
|
||||
返回:
|
||||
|
||||
```json
|
||||
{
|
||||
"workflowId": "wf_xxx",
|
||||
"status": "idle"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.2 启动任务
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/start`
|
||||
|
||||
## 10.3 查询任务
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}`
|
||||
|
||||
## 10.4 订阅事件
|
||||
|
||||
`GET /api/ai-writing/workflows/{id}/events`
|
||||
|
||||
返回事件流示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "stage_changed",
|
||||
"stage": "generating_outline",
|
||||
"message": "正在生成提纲"
|
||||
}
|
||||
```
|
||||
|
||||
## 10.5 确认提纲
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
|
||||
## 10.6 指定章节重写
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
|
||||
## 10.7 确认终稿
|
||||
|
||||
`POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
---
|
||||
|
||||
## 11. LangGraph 实现建议
|
||||
|
||||
## 11.1 推荐 graph 节点
|
||||
|
||||
- `load_input`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
## 11.2 推荐 state
|
||||
|
||||
建议 state 字段:
|
||||
|
||||
- `workflow_id`
|
||||
- `input_payload`
|
||||
- `materials`
|
||||
- `outline`
|
||||
- `outline_confirmed`
|
||||
- `draft_sections`
|
||||
- `merged_draft`
|
||||
- `review_result`
|
||||
- `rewrite_targets`
|
||||
- `final_article`
|
||||
- `retry_count`
|
||||
- `events`
|
||||
|
||||
## 11.3 条件边
|
||||
|
||||
建议条件边逻辑:
|
||||
|
||||
- 提纲未确认 -> 等待用户
|
||||
- 编辑通过 -> 完成
|
||||
- 编辑不通过且可重试 -> 重写
|
||||
- 编辑不通过且超限 -> 人工介入
|
||||
|
||||
---
|
||||
|
||||
## 12. 可观测性要求
|
||||
|
||||
第一版就建议带上以下能力:
|
||||
|
||||
- 每个 workflow 的事件日志
|
||||
- 每个 Agent 的输入输出摘要
|
||||
- 每次重试原因
|
||||
- 每个阶段耗时
|
||||
- 每次模型调用 token 与错误日志
|
||||
|
||||
后续排问题会非常依赖这些数据。
|
||||
|
||||
---
|
||||
|
||||
## 13. 测试建议
|
||||
|
||||
## 13.1 单元测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- stage 流转
|
||||
- retry policy
|
||||
- quality policy
|
||||
- 事件构造
|
||||
|
||||
## 13.2 集成测试
|
||||
|
||||
覆盖:
|
||||
|
||||
- 创建任务到完成的主链路
|
||||
- 提纲确认链路
|
||||
- 审稿失败后重写链路
|
||||
- 人工介入链路
|
||||
|
||||
## 13.3 Prompt 回归集
|
||||
|
||||
建议建立 10-20 个固定写作样例,做回归:
|
||||
|
||||
- 短文
|
||||
- 长文
|
||||
- 事实密集型文章
|
||||
- 宣传型文章
|
||||
- 分析型文章
|
||||
|
||||
---
|
||||
|
||||
## 14. 开发顺序建议
|
||||
|
||||
## Step 1:先做 domain 与 API 契约
|
||||
|
||||
先定:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- human action payload
|
||||
|
||||
## Step 2:先做 fake orchestrator
|
||||
|
||||
先不接真实模型,只模拟事件流,把前后端联调跑通。
|
||||
|
||||
## Step 3:接 Researcher + Outliner
|
||||
|
||||
优先把“研究 + 提纲”做出来,因为这是弱模型提升最大的环节。
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再接初稿和审稿重写。
|
||||
|
||||
## Step 5:接可观测性和缓存
|
||||
|
||||
最后再补生产化能力。
|
||||
|
||||
---
|
||||
|
||||
## 15. 风险控制建议
|
||||
|
||||
### 风险 1:直接复用现有线程流,导致协议污染
|
||||
|
||||
建议:
|
||||
|
||||
- workflow 用独立 route 和独立 event schema
|
||||
|
||||
### 风险 2:写作结果与右侧 artifact 模型不兼容
|
||||
|
||||
建议:
|
||||
|
||||
- 后端统一输出标准 markdown artifact
|
||||
|
||||
### 风险 3:弱模型频繁输出不合法 JSON
|
||||
|
||||
建议:
|
||||
|
||||
- 每个节点加 schema repair
|
||||
- 加轻量重试
|
||||
- 尽量减少复杂嵌套输出
|
||||
|
||||
### 风险 4:研究资料太多导致上下文爆炸
|
||||
|
||||
建议:
|
||||
|
||||
- 检索后先裁剪、摘要、分桶
|
||||
|
||||
---
|
||||
|
||||
## 16. 最终建议
|
||||
|
||||
后端第一版最重要的是把“状态机、事件流、人工确认、章节重写”这四件事做对,而不是一开始就追求很多花哨 Agent。
|
||||
|
||||
建议 MVP 只保留:
|
||||
|
||||
1. Researcher
|
||||
2. Outliner
|
||||
3. Writer
|
||||
4. Editor
|
||||
5. Supervisor
|
||||
|
||||
当这条链路稳定后,再继续扩展 fact-checker、style editor、compliance reviewer。
|
||||
@@ -0,0 +1,806 @@
|
||||
# AI 写作执行方案
|
||||
|
||||
## 1. 总体原则
|
||||
|
||||
本项目的最终落地原则:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制第二套页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,不侵入现有主链路
|
||||
3. 编排框架采用 `LangGraph`
|
||||
4. 写作工作流采用 `Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
5. Agent 间输出统一采用 `JSON Schema / Pydantic`
|
||||
6. 前后端通过 `SSE` 传递工作流事件
|
||||
7. 第一版只做最小闭环,不做过多花哨扩展
|
||||
|
||||
一句话说明:
|
||||
|
||||
用独立的新后端路线承载多 Agent 写作工作流,前端继续复用 `open-canvas`,实现上采用 `LangGraph + A/B/C Agent + SSE` 的最小闭环。
|
||||
|
||||
---
|
||||
|
||||
## 2. 项目边界
|
||||
|
||||
## 2.1 前端边界
|
||||
|
||||
继续使用当前页面:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
前端做的事情:
|
||||
|
||||
- 左侧增加“写作工作流模式”
|
||||
- 展示多 Agent 执行进度与中间产物
|
||||
- 提供提纲确认和指定章节重写
|
||||
- 将初稿、终稿同步到右侧 artifact
|
||||
|
||||
前端不做的事情:
|
||||
|
||||
- 不重写右侧编辑器
|
||||
- 不重写现有普通 chat 流程
|
||||
- 不把 workflow 状态散落在多个旧 context 里
|
||||
|
||||
## 2.2 后端边界
|
||||
|
||||
后端采用独立新路线:
|
||||
|
||||
推荐目录:
|
||||
|
||||
```text
|
||||
offline-backend-20260512/
|
||||
apps/
|
||||
ai_writing_orchestrator/
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
后端做的事情:
|
||||
|
||||
- 实现写作 workflow 状态机
|
||||
- 实现 A/B/C Agent 与 Supervisor
|
||||
- 接内网模型与内网检索
|
||||
- 输出标准事件流和文章 artifact
|
||||
|
||||
后端不做的事情:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把工作流逻辑塞到现有通用 thread 协议里
|
||||
|
||||
## 2.3 资料搜集边界
|
||||
|
||||
第一版处于互联网开发阶段,因此研究员的资料搜集先走互联网检索,不接内网检索服务。
|
||||
|
||||
第一版要求:
|
||||
|
||||
- 研究员可以调用互联网搜索能力收集公开资料
|
||||
- 研究结果必须保留来源 URL、标题、摘要、抓取时间
|
||||
- 检索层必须抽象成统一接口,不能把互联网搜索逻辑写死在 Agent prompt 或 graph 节点里
|
||||
|
||||
第二版预留:
|
||||
|
||||
- 后续替换或并行接入内网搜索接口
|
||||
- 后续接入内部知识库、文档库、FAQ、项目复盘库
|
||||
- 前端和工作流层不因检索源变化而改协议
|
||||
|
||||
统一设计要求:
|
||||
|
||||
- `Researcher` 只依赖“检索接口”,不依赖具体是互联网还是内网
|
||||
- `writing_graph` 只读取标准化后的素材结果
|
||||
- API、事件流、artifact 结构不区分互联网资料和内网资料
|
||||
|
||||
推荐检索抽象:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
```
|
||||
|
||||
建议接口:
|
||||
|
||||
```python
|
||||
class RetrievalProvider(Protocol):
|
||||
def search(self, query: str, top_k: int) -> list[RetrievedDocument]: ...
|
||||
|
||||
class RetrievedDocument(BaseModel):
|
||||
title: str
|
||||
url: str | None
|
||||
source_type: str
|
||||
snippet: str
|
||||
published_at: str | None = None
|
||||
fetched_at: str
|
||||
reliability: str | None = None
|
||||
```
|
||||
|
||||
第一版路由策略:
|
||||
|
||||
- 默认 `internet`
|
||||
- 配置项预留 `intranet`、`hybrid`
|
||||
|
||||
例如:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
```
|
||||
|
||||
这样第一版先跑公网资料,后续切换内网时,只替换 `retriever_router` 和对应 provider,不改 workflow 主逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 3. 第一版产品目标
|
||||
|
||||
用户在 `open-canvas` 中选择“写作工作流模式”后,可以完成以下流程:
|
||||
|
||||
1. 输入主题、体裁、读者、风格、篇幅
|
||||
2. 研究员搜集资料并输出素材卡片
|
||||
3. 提纲 Agent 生成提纲
|
||||
4. 用户确认提纲
|
||||
5. Writer 生成初稿
|
||||
6. Editor 给出问题清单与评分
|
||||
7. 不达标则重写一次或两次
|
||||
8. 终稿写入右侧 artifact
|
||||
|
||||
第一版成功标准:
|
||||
|
||||
- 不是“生成得像最强模型一样好”
|
||||
- 而是“明显优于单轮直出,并且流程可控、可回退、可人工介入”
|
||||
|
||||
## 3.1 第一版研究员目标
|
||||
|
||||
第一版 `Researcher` 的核心职责,不是“写文章”,而是“给 Writer 准备可信、可引用、可筛选的素材”。
|
||||
|
||||
第一版输入:
|
||||
|
||||
- 用户主题
|
||||
- 用户写作要求
|
||||
- 检索源配置,第一版默认 `internet`
|
||||
|
||||
第一版输出至少包含:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
每条素材最少要带:
|
||||
|
||||
- 来源标题
|
||||
- 来源 URL
|
||||
- 摘要
|
||||
- 可信度标签
|
||||
- 被引用的原始片段或整理后的事实点
|
||||
|
||||
第一版 Researcher 的程序流程建议:
|
||||
|
||||
1. 根据主题自动生成 3-5 个检索 query
|
||||
2. 调用互联网检索接口拿回候选文档
|
||||
3. 做去重、裁剪、初筛
|
||||
4. 让模型将结果整理成结构化素材卡片
|
||||
5. 将结构化结果返回给 Outliner 和 Writer
|
||||
|
||||
第一版先不做:
|
||||
|
||||
- 深度网页 crawling
|
||||
- 多轮自动追问式深研究
|
||||
- 内网知识库接入
|
||||
- 复杂引用格式生成
|
||||
|
||||
---
|
||||
|
||||
## 4. 最终工作流
|
||||
|
||||
## 4.1 Agent 角色
|
||||
|
||||
### `Researcher`
|
||||
|
||||
职责:
|
||||
|
||||
- 从配置的检索源检索信息
|
||||
- 产出结构化素材卡片
|
||||
- 标注来源、可信度、风险点
|
||||
|
||||
第一版说明:
|
||||
|
||||
- 默认走互联网公开资料检索
|
||||
- 必须预留后续切换到内网检索源的接口
|
||||
- 检索实现放在 `infrastructure/retrieval/`,不要写死在 prompt 中
|
||||
|
||||
第一版检索源字段建议:
|
||||
|
||||
- `source_type = "internet"`
|
||||
- 后续预留 `source_type = "intranet" | "hybrid"`
|
||||
|
||||
输出:
|
||||
|
||||
- `material_cards`
|
||||
- `facts`
|
||||
- `quotes`
|
||||
- `examples`
|
||||
- `risks`
|
||||
- `source_refs`
|
||||
|
||||
### `Outliner`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户要求和研究结果生成提纲
|
||||
|
||||
输出:
|
||||
|
||||
- `title_candidates`
|
||||
- `outline_sections`
|
||||
- `key_points_per_section`
|
||||
|
||||
### `Writer`
|
||||
|
||||
职责:
|
||||
|
||||
- 基于提纲和素材写初稿
|
||||
- 按章节写,不一次生成整篇长文
|
||||
|
||||
输出:
|
||||
|
||||
- `draft_sections`
|
||||
- `section_source_refs`
|
||||
- `full_draft_markdown`
|
||||
|
||||
### `Editor`
|
||||
|
||||
职责:
|
||||
|
||||
- 检查事实、结构、文风
|
||||
- 给出评分与重写建议
|
||||
|
||||
输出:
|
||||
|
||||
- `issues`
|
||||
- `severity`
|
||||
- `score`
|
||||
- `rewrite_instructions`
|
||||
- `decision`
|
||||
|
||||
### `Supervisor`
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程调度
|
||||
- 负责通过 / 重写 / 人工介入判断
|
||||
|
||||
## 4.2 状态机
|
||||
|
||||
第一版统一采用:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
## 4.3 流程闭环
|
||||
|
||||
最小闭环固定为:
|
||||
|
||||
1. `Research`
|
||||
2. `Outline`
|
||||
3. `HumanConfirmOutline`
|
||||
4. `Draft`
|
||||
5. `Review`
|
||||
6. `RewriteIfNeeded`
|
||||
7. `Complete`
|
||||
|
||||
重试规则:
|
||||
|
||||
- 最多重写 2 次
|
||||
- 达标则完成
|
||||
- 超限则进入人工介入
|
||||
|
||||
## 4.4 LangGraph 推荐节点
|
||||
|
||||
第一版推荐节点:
|
||||
|
||||
- `load_input`
|
||||
- `build_search_queries`
|
||||
- `retrieve_materials`
|
||||
- `normalize_materials`
|
||||
- `generate_outline`
|
||||
- `wait_for_outline_confirmation`
|
||||
- `write_sections`
|
||||
- `merge_sections`
|
||||
- `review_draft`
|
||||
- `rewrite_sections`
|
||||
- `publish_final_artifact`
|
||||
|
||||
其中和资料搜集直接相关的节点说明:
|
||||
|
||||
### `build_search_queries`
|
||||
|
||||
职责:
|
||||
|
||||
- 根据用户主题和目标文章类型,生成 3-5 个检索 query
|
||||
|
||||
### `retrieve_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 调用检索 provider 获取候选文档
|
||||
- 第一版默认走 `internet_search_client`
|
||||
- 后续可切到 `intranet_search_client`
|
||||
|
||||
### `normalize_materials`
|
||||
|
||||
职责:
|
||||
|
||||
- 做去重
|
||||
- 做摘要裁剪
|
||||
- 做来源字段统一
|
||||
- 输出标准 `RetrievedDocument[]`
|
||||
|
||||
---
|
||||
|
||||
## 5. 前端最终做法
|
||||
|
||||
## 5.1 目录建议
|
||||
|
||||
在现有 `open-canvas` 下新增:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
CanvasWorkflowPanel.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
```
|
||||
|
||||
## 5.2 左侧改造策略
|
||||
|
||||
`CanvasChatPanel` 只承担模式切换与容器职责:
|
||||
|
||||
- `chat` 模式:沿用现有普通聊天
|
||||
- `writing_pipeline` 模式:渲染 `CanvasWorkflowPanel`
|
||||
|
||||
新面板负责:
|
||||
|
||||
- 写作任务表单
|
||||
- 进度时间线
|
||||
- 事件列表
|
||||
- 中间产物抽屉
|
||||
- 提纲确认卡片
|
||||
|
||||
## 5.3 右侧衔接策略
|
||||
|
||||
继续复用现有 `CanvasArtifactPanel`。
|
||||
|
||||
同步规则:
|
||||
|
||||
- 提纲:可选同步到右侧预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖当前草稿
|
||||
- 终稿:作为最终 artifact
|
||||
|
||||
## 5.4 前端状态设计
|
||||
|
||||
新增:
|
||||
|
||||
- `workflowMode`
|
||||
- `workflowRunId`
|
||||
- `workflowStage`
|
||||
- `workflowStatus`
|
||||
- `workflowEvents`
|
||||
- `workflowArtifacts`
|
||||
- `workflowPendingAction`
|
||||
|
||||
注意:
|
||||
|
||||
- workflow 状态放进新的 `WritingWorkflowContext`
|
||||
- 不塞进现有 `CanvasGraphContext`
|
||||
|
||||
## 5.5 前端显示的资料信息
|
||||
|
||||
第一版左侧至少要能展示研究员产出的资料摘要:
|
||||
|
||||
- 来源标题
|
||||
- 来源域名或 URL
|
||||
- 资料摘要
|
||||
- 可信度标签
|
||||
- 是否被提纲或正文引用
|
||||
|
||||
建议在 `WritingWorkflowArtifactDrawer.tsx` 中增加“素材卡片”视图。
|
||||
|
||||
注意:
|
||||
|
||||
- 第一版显示公开互联网来源即可
|
||||
- 后续接内网时,这个展示层不变,只是来源字段从 URL 变成内网文档标识或内部链接
|
||||
|
||||
---
|
||||
|
||||
## 6. 后端最终做法
|
||||
|
||||
## 6.1 分层
|
||||
|
||||
推荐分层:
|
||||
|
||||
```text
|
||||
api/
|
||||
application/
|
||||
domain/
|
||||
infrastructure/
|
||||
prompts/
|
||||
tests/
|
||||
```
|
||||
|
||||
## 6.2 核心模块
|
||||
|
||||
建议核心文件:
|
||||
|
||||
```text
|
||||
domain/
|
||||
models/
|
||||
workflow.py
|
||||
workflow_stage.py
|
||||
workflow_event.py
|
||||
artifact.py
|
||||
review_result.py
|
||||
agents/
|
||||
researcher.py
|
||||
outliner.py
|
||||
writer.py
|
||||
editor.py
|
||||
supervisor.py
|
||||
graphs/
|
||||
writing_graph.py
|
||||
policies/
|
||||
retry_policy.py
|
||||
quality_policy.py
|
||||
```
|
||||
|
||||
## 6.3 检索层实现要求
|
||||
|
||||
第一版检索层必须独立成基础设施模块,不允许:
|
||||
|
||||
- 在 Agent prompt 中直接拼接“去网上找资料”
|
||||
- 在 graph 节点中散落请求第三方搜索的代码
|
||||
- 把互联网检索和内网检索混在一个无抽象的函数里
|
||||
|
||||
建议结构:
|
||||
|
||||
```text
|
||||
infrastructure/
|
||||
retrieval/
|
||||
base.py
|
||||
internet_search_client.py
|
||||
intranet_search_client.py
|
||||
retriever_router.py
|
||||
material_normalizer.py
|
||||
```
|
||||
|
||||
建议职责:
|
||||
|
||||
- `internet_search_client.py`
|
||||
- 第一版实际使用
|
||||
- 对接互联网搜索 API 或爬取网关
|
||||
|
||||
- `intranet_search_client.py`
|
||||
- 第一版只保留接口和空实现或 mock
|
||||
- 后续接企业内部接口
|
||||
|
||||
- `retriever_router.py`
|
||||
- 根据配置选择 provider
|
||||
|
||||
- `material_normalizer.py`
|
||||
- 统一清洗和标准化结果
|
||||
|
||||
## 6.4 Prompt 管理
|
||||
|
||||
统一独立存放:
|
||||
|
||||
```text
|
||||
prompts/
|
||||
researcher/
|
||||
system.txt
|
||||
output_schema.json
|
||||
outliner/
|
||||
system.txt
|
||||
output_schema.json
|
||||
writer/
|
||||
system.txt
|
||||
output_schema.json
|
||||
editor/
|
||||
system.txt
|
||||
output_schema.json
|
||||
supervisor/
|
||||
system.txt
|
||||
```
|
||||
|
||||
## 6.5 输出约束
|
||||
|
||||
每个 Agent 都必须:
|
||||
|
||||
- 优先输出 JSON / Pydantic schema
|
||||
- 失败时允许轻量 repair 和一次重试
|
||||
- 避免自由散文式输出
|
||||
|
||||
## 6.6 Researcher 的实现细则
|
||||
|
||||
Researcher 推荐由两部分组成:
|
||||
|
||||
1. 程序化检索
|
||||
2. 模型化整理
|
||||
|
||||
推荐伪流程:
|
||||
|
||||
```python
|
||||
def run_research(topic: str, article_type: str, provider: RetrievalProvider):
|
||||
queries = build_queries(topic, article_type)
|
||||
raw_docs = []
|
||||
for query in queries:
|
||||
raw_docs.extend(provider.search(query=query, top_k=5))
|
||||
docs = normalize_materials(raw_docs)
|
||||
docs = dedupe_materials(docs)
|
||||
docs = rank_materials(docs)
|
||||
return llm_extract_material_cards(docs)
|
||||
```
|
||||
|
||||
第一版建议:
|
||||
|
||||
- 先把 query 生成逻辑做简单
|
||||
- `top_k` 先控制在 5-10
|
||||
- 先做轻量去重和摘要裁剪
|
||||
- 不做很重的网页全文抓取
|
||||
|
||||
---
|
||||
|
||||
## 7. 前后端协议
|
||||
|
||||
## 7.1 API
|
||||
|
||||
第一版统一提供:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
|
||||
## 7.2 SSE 事件
|
||||
|
||||
统一事件类型:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
第一版前端不需要消费复杂嵌套事件,只要先把这 9 类打通。
|
||||
|
||||
## 7.3 配置项补充
|
||||
|
||||
建议增加以下配置项:
|
||||
|
||||
```yaml
|
||||
retrieval:
|
||||
provider: internet
|
||||
top_k: 8
|
||||
timeout_seconds: 15
|
||||
enable_content_fetch: false
|
||||
|
||||
workflow:
|
||||
max_revisions: 2
|
||||
|
||||
models:
|
||||
researcher: your-default-model
|
||||
outliner: your-default-model
|
||||
writer: your-default-model
|
||||
editor: your-default-model
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- 第一版 `provider=internet`
|
||||
- 第二版可切成 `intranet` 或 `hybrid`
|
||||
|
||||
---
|
||||
|
||||
## 8. 必须做 / 延后做 / 不建议现在做
|
||||
|
||||
## 8.1 必须做
|
||||
|
||||
### 架构
|
||||
|
||||
- 独立后端路线 `ai-writing-orchestrator`
|
||||
- 前端独立 `workflow/` 目录
|
||||
- LangGraph 状态机
|
||||
- `Researcher / Outliner / Writer / Editor / Supervisor`
|
||||
- 独立检索抽象层,第一版先落互联网 provider
|
||||
|
||||
### 协议
|
||||
|
||||
- 标准 workflow 状态枚举
|
||||
- 标准 SSE 事件枚举
|
||||
- 标准 artifact 结构
|
||||
- JSON / Pydantic 输出约束
|
||||
|
||||
### 功能
|
||||
|
||||
- 启动写作任务
|
||||
- 研究阶段
|
||||
- 提纲阶段
|
||||
- 提纲确认
|
||||
- 初稿生成
|
||||
- 审稿与最多 2 次重写
|
||||
- 终稿同步到右侧
|
||||
- 素材卡片展示
|
||||
- 来源 URL、标题、摘要、可信度落库或挂在 artifact metadata
|
||||
|
||||
### UI
|
||||
|
||||
- 模式切换
|
||||
- 时间线
|
||||
- 事件列表
|
||||
- 提纲确认卡片
|
||||
- 右侧终稿显示
|
||||
- 左侧素材卡片视图
|
||||
|
||||
## 8.2 延后做
|
||||
|
||||
- 章节级精准重写
|
||||
- 初稿多版本树
|
||||
- FactChecker
|
||||
- StyleEditor
|
||||
- ComplianceReviewer
|
||||
- 自动评分看板
|
||||
- 素材引用可视化高亮
|
||||
- 更复杂的人机协作节点
|
||||
- 内网知识库正式接入
|
||||
- 混合检索 `hybrid`
|
||||
- 网页全文抓取与深摘要
|
||||
|
||||
## 8.3 不建议现在做
|
||||
|
||||
- 直接接入现有 DeerFlow 主 agent 包
|
||||
- 同时试 MetaGPT、CrewAI、AutoGen 多套底座
|
||||
- 一开始就做 5 个以上 Agent
|
||||
- 一开始就做很复杂的文章版本管理
|
||||
- 一开始就把评分阈值和 prompt 写死得非常细
|
||||
|
||||
## 9. 最终开发顺序
|
||||
|
||||
## Step 1:先定契约
|
||||
|
||||
先一起拍板:
|
||||
|
||||
- stage 枚举
|
||||
- event 枚举
|
||||
- artifact 结构
|
||||
- confirm / rewrite 的 payload
|
||||
- retrieval provider 接口
|
||||
- RetrievedDocument 结构
|
||||
|
||||
## Step 2:先做 fake workflow
|
||||
|
||||
后端先不接真实模型,只吐 mock SSE 事件,前端把左侧工作台跑通。
|
||||
|
||||
## Step 3:先接互联网版 Researcher + Outliner
|
||||
|
||||
优先打磨“研究 + 提纲”,这是弱模型提升最大的部分。
|
||||
|
||||
这一阶段要求:
|
||||
|
||||
- `Researcher` 先走互联网检索
|
||||
- `retriever_router` 可配置
|
||||
- `intranet_search_client` 先留接口空壳
|
||||
|
||||
## Step 4:接 Writer + Editor
|
||||
|
||||
再补初稿与审稿重写闭环。
|
||||
|
||||
## Step 5:接右侧 artifact
|
||||
|
||||
把初稿和终稿稳定写入右侧。
|
||||
|
||||
## Step 6:补生产化
|
||||
|
||||
- 缓存
|
||||
- 日志
|
||||
- 错误回放
|
||||
- 配置化 prompt
|
||||
|
||||
## 10. 推荐任务拆分
|
||||
|
||||
## 前端
|
||||
|
||||
- `CanvasChatPanel` 模式切换
|
||||
- `workflow/` 目录与类型
|
||||
- 时间线 / 事件列表 / 确认卡片
|
||||
- SSE 订阅 hook
|
||||
- 右侧 artifact 映射
|
||||
- 素材卡片列表展示
|
||||
- 来源字段展示规范
|
||||
|
||||
## 后端
|
||||
|
||||
- workflow API
|
||||
- LangGraph graph
|
||||
- 5 个 Agent 模块
|
||||
- Prompt 目录
|
||||
- SSE 输出
|
||||
- 仓储与日志
|
||||
- `RetrievalProvider` 抽象
|
||||
- `internet_search_client`
|
||||
- `intranet_search_client` 预留接口
|
||||
- `material_normalizer`
|
||||
|
||||
## Prompt / 算法
|
||||
|
||||
- Researcher prompt
|
||||
- Outliner prompt
|
||||
- Writer prompt
|
||||
- Editor prompt
|
||||
- JSON schema
|
||||
- 失败样本回归
|
||||
|
||||
## 联调
|
||||
|
||||
- mock SSE
|
||||
- 真模型接入
|
||||
- artifact 同步校验
|
||||
- 提纲确认链路验证
|
||||
- 互联网资料来源字段联调
|
||||
- 素材卡片到提纲引用链路联调
|
||||
|
||||
---
|
||||
|
||||
## 11. 开发过程中的约束
|
||||
|
||||
开发过程中统一采用下面这套口径:
|
||||
|
||||
- 产品形态:当前 `open-canvas` 页面上的“写作工作流模式”
|
||||
- 技术底座:`LangGraph`
|
||||
- 核心模式:`Researcher -> Outliner -> Writer -> Editor -> Supervisor`
|
||||
- 后端方式:独立 `ai-writing-orchestrator`
|
||||
- 检索方式:第一版 `internet`,接口预留 `intranet`
|
||||
- 通信方式:`SSE`
|
||||
- 输出方式:`JSON Schema / Pydantic`
|
||||
- 第一版目标:先做最小闭环,不追求角色数量和复杂度
|
||||
|
||||
给开发团队的统一口径:
|
||||
|
||||
“我们在 `open-canvas` 上开发写作工作流模式,后端单开 `ai-writing-orchestrator`,第一版先用互联网资料检索,保留内网检索接入口,用 LangGraph 编排 Researcher、Outliner、Writer、Editor、Supervisor,把研究、提纲、初稿、审稿重写这条最小闭环做稳。”
|
||||
@@ -0,0 +1,538 @@
|
||||
# AI 写作 — 前端开发文档
|
||||
|
||||
> **本文档目的**:帮助前端开发者快速理解 AI 写作子系统的代码结构、数据流、状态机和扩展点。
|
||||
>
|
||||
> **配套文档**:
|
||||
> - `ai-writing-说明.md` — 故障排查与运维向解决方案
|
||||
> - `ai-writing-assistant-backend-api.md` — 后端 API 详细字段定义
|
||||
> - `ai-writing-assistant-source-map.md` — 旧版前端结构索引(已合并到本文档)
|
||||
|
||||
---
|
||||
|
||||
## 一、产品定位
|
||||
|
||||
AI 写作是 DeerFlow 的「**杂志社协作写作**」子产品,由 LangGraph 后端驱动 4 个 AI Agent(**素材收集专家 / 作家 / 编辑 / 系统**)协作完成一篇文章。用户通过 **4 个干预点**(素材确认、大纲确认、草稿确认、审核确认)参与决策,UI 实时呈现进度。
|
||||
|
||||
### UI 入口
|
||||
- 路由:`/canvas/ai-writing`(在 `WorkspaceRoutes.tsx` 注册)
|
||||
- 入口页:`src/open-canvas/pages/AIWritingPage.tsx`
|
||||
- 双栏布局:左 AI 写作面板(聊天 + 干预),右草稿预览
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
frontend-web/src/open-canvas/
|
||||
├── pages/
|
||||
│ └── AIWritingPage.tsx # 入口页(双栏 Resizable 布局)
|
||||
│
|
||||
├── api/ # ── 后端 API 适配层 ──
|
||||
│ ├── ai-writing.ts # /start /resume + SSE 订阅(核心)
|
||||
│ ├── ai-writing-sessions.ts # 历史会话 CRUD + transcript 保存
|
||||
│ └── ai-writing-article-types.ts # 文章类型 CRUD
|
||||
│
|
||||
├── hooks/ # ── 状态管理 ──
|
||||
│ ├── useAIWriting.ts # 写作主状态机(最重要)
|
||||
│ ├── useAIWritingSessions.ts # 历史会话列表 hook
|
||||
│ └── useArticleTypes.ts # 文章类型选项 hook
|
||||
│
|
||||
├── components/ai-writing/ # ── UI 组件 ──
|
||||
│ ├── AIWritingPanel.tsx # 左侧主面板(聊天 + 干预编排)
|
||||
│ ├── AIWritingDraftPanel.tsx # 右侧草稿预览面板
|
||||
│ ├── AIWritingPageHeader.tsx # 顶部标题/历史下拉
|
||||
│ ├── AIWritingForm.tsx # 写作请求表单(首次启动用)
|
||||
│ │
|
||||
│ ├── AIWritingTimeline.tsx # 时间线主容器
|
||||
│ ├── TimelineStep.tsx # 单条进度事件
|
||||
│ ├── CompletedInterventionCard.tsx # 已完成的干预记录
|
||||
│ │
|
||||
│ ├── InterventionCard.tsx # 4 个干预点 + 求助点的卡片(核心)
|
||||
│ ├── MaterialsCard.tsx # 素材确认
|
||||
│ ├── MaterialItem.tsx # 单个素材项
|
||||
│ ├── MaterialsToolbar.tsx # 素材筛选/搜索工具栏
|
||||
│ ├── OutlineCard.tsx # 大纲确认(含编辑)
|
||||
│ ├── OutlineSection.tsx # 大纲单章节
|
||||
│ ├── ReviewCard.tsx # 审核确认
|
||||
│ ├── IssueList.tsx # 审核意见列表
|
||||
│ ├── ScoreBar.tsx # 评分进度条
|
||||
│ │
|
||||
│ ├── OutlineStreamingView.tsx # 大纲流式打字机
|
||||
│ ├── DraftStreamingView.tsx # 草稿流式打字机
|
||||
│ ├── ReviewStreamingView.tsx # 审核流式打字机
|
||||
│ ├── WritingSkeletonCard.tsx # 加载骨架屏
|
||||
│ ├── RevisionSummaryCard.tsx # 修改前后对比摘要
|
||||
│ │
|
||||
│ ├── AIWritingHistoryDropdown.tsx # 历史会话下拉
|
||||
│ └── AIWritingHistoryView.tsx # 历史会话只读回显
|
||||
│
|
||||
└── types/
|
||||
└── ai-writing.ts # 全部类型定义(重要参考)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、数据流总览
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────┐
|
||||
│ AIWritingPanel │
|
||||
│ ┌──────────────────────────────────────────────────────────┐ │
|
||||
│ │ useAIWriting() 主状态 hook │ │
|
||||
│ │ ┌────────────────────────────────────────────────────┐ │ │
|
||||
│ │ │ startWriting() ─── POST /api/ai-writing/start │ │ │
|
||||
│ │ │ ↓ 返回 session_id │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ subscribeToProgress(sessionId) │ │ │
|
||||
│ │ │ ─── GET /api/ai-writing/{id}/stream (SSE) │ │ │
|
||||
│ │ │ ─── 持续推 ProgressEvent 给 handleEvent │ │ │
|
||||
│ │ │ ─── handleEvent reducer → setState │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ submitIntervention() │ │ │
|
||||
│ │ │ ─── POST /api/ai-writing/{id}/resume │ │ │
|
||||
│ │ │ ─── graph 续推 → 新事件继续走 SSE │ │ │
|
||||
│ │ │ │ │ │
|
||||
│ │ │ saveAIWritingTranscript()(fire-and-forget) │ │ │
|
||||
│ │ │ ─── PUT /api/ai-writing/sessions/{id}/transcript │ │
|
||||
│ │ └────────────────────────────────────────────────────┘ │ │
|
||||
│ └──────────────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 渲染: │
|
||||
│ ├ AIWritingForm(idle 状态) │
|
||||
│ ├ AIWritingTimeline(运行中) │
|
||||
│ │ ├ TimelineStep × N(progressEvents) │
|
||||
│ │ └ CompletedInterventionCard × N(已完成的干预) │
|
||||
│ ├ {Outline,Draft,Review}StreamingView(流式打字机) │
|
||||
│ ├ InterventionCard(当前 currentPause 存在时弹出) │
|
||||
│ └ WritingSkeletonCard(运行中无 pause 时的骨架屏) │
|
||||
└─────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、状态机(useAIWriting.ts)
|
||||
|
||||
### 状态字段(完整定义见 `types/ai-writing.ts`)
|
||||
|
||||
```typescript
|
||||
interface AIWritingState {
|
||||
// —— 核心标识 ——
|
||||
sessionId: string | null; // 后端返回的会话 id
|
||||
status: WritingStatus; // 当前阶段(13 种状态之一)
|
||||
isPaused: boolean; // 是否在干预点等待用户
|
||||
error: string | null;
|
||||
|
||||
// —— 配置 ——
|
||||
request: AIWritingRequest | null; // 用户提交的写作请求
|
||||
|
||||
// —— 累计数据 ——
|
||||
progressEvents: ProgressEvent[]; // SSE 事件全量历史(供时间线渲染)
|
||||
materials: AIMaterial[]; // 素材列表(素材就绪后填充)
|
||||
outline: AIOutline | null; // 大纲(大纲就绪后填充)
|
||||
currentDraftMarkdown: string; // 草稿正文(草稿就绪后填充)
|
||||
currentDraftTitle: string;
|
||||
reviewResult: ReviewResult | null;
|
||||
revisionCount: number;
|
||||
completedInterventions: CompletedIntervention[]; // 已完成的干预历史
|
||||
|
||||
// —— 干预 ——
|
||||
currentPause: ProgressEvent | null; // 当前挂起的 await_user 事件
|
||||
|
||||
// —— 流式视图状态 ——
|
||||
streamingDraftTitle: string;
|
||||
streamingDraftSections: StreamingDraftSection[];
|
||||
outlineStreamingText: string;
|
||||
outlineThinkingText: string;
|
||||
researchProgress: string;
|
||||
streamingReviewItems: StreamingReviewItem[];
|
||||
revisionSummary: RevisionSummary | null;
|
||||
pendingRevisionContext: { /* ... */ } | null;
|
||||
}
|
||||
```
|
||||
|
||||
### 状态转移图(写作状态机)
|
||||
|
||||
```
|
||||
┌──────┐
|
||||
│ idle │ ←─────────────────────────────────────┐
|
||||
└──┬───┘ │
|
||||
│ startWriting() │
|
||||
↓ │
|
||||
┌─────────────────┐ │
|
||||
│ researching │ │
|
||||
└──┬──────────────┘ │
|
||||
↓ materials_ready │
|
||||
┌─────────────────────────────┐ │
|
||||
│ awaiting_material_confirm │ ← await_user │
|
||||
└──┬──────────────────────────┘ │
|
||||
↓ submit(confirm) │
|
||||
┌─────────────────┐ │
|
||||
│ writing_outline │ ← (re_search 路径) │
|
||||
└──┬──────────────┘ │
|
||||
↓ outline_ready │
|
||||
┌─────────────────────────────┐ │
|
||||
│ awaiting_outline_confirm │ │
|
||||
└──┬──────────────────────────┘ │
|
||||
↓ submit(confirm) │
|
||||
┌─────────────────┐ │
|
||||
│ writing_draft │ ← (re_outline 路径) │
|
||||
└──┬──────────────┘ │
|
||||
│ │
|
||||
┌───────┴────────┐ │
|
||||
↓ ↓ │
|
||||
awaiting_section_ awaiting_draft_confirm │
|
||||
help (素材不足) └─┬───┬───┬──────────────┐ │
|
||||
│ (supplement) │ │ │ to_editor │ │
|
||||
│ ↑回到 research │ │ ↓ │ │
|
||||
│ │ │ reviewing │ │
|
||||
│ │ │ └─┬──────┐ │ │
|
||||
│ │ │ ↓ ↓ │ │
|
||||
│ │ │ awaiting_review_confirm │
|
||||
│ │ │ └─┬────────┐ │
|
||||
│ │ │ ↓ submit ↓ │
|
||||
│ │ │ revising done ───────────────┘
|
||||
│ │ │ │
|
||||
│ │ │ └─→ writing_draft (循环修订)
|
||||
│ │ │
|
||||
│ │ │ user_revise
|
||||
│ │ └─→ revising
|
||||
│ │
|
||||
│ │ finalize
|
||||
│ └─→ done
|
||||
│
|
||||
└──→ (delete/loose) writing_draft 继续
|
||||
```
|
||||
|
||||
### handleEvent reducer(核心)
|
||||
|
||||
`useAIWriting.ts` 里的 `handleEvent` 是个**纯 reducer**,根据 SSE 事件类型更新状态:
|
||||
|
||||
| 事件类型 | 主要影响的状态字段 |
|
||||
|---|---|
|
||||
| `connected` | 仅记录到 progressEvents |
|
||||
| `step_start` | 切到对应 `status`(如「编辑」→ reviewing) |
|
||||
| `step_done` | 仅记录 |
|
||||
| `research_progress` | `researchProgress`(实时检索进度文案) |
|
||||
| `materials_ready` | `materials`、清空 researchProgress |
|
||||
| `outline_chunk` | `outlineStreamingText` / `outlineThinkingText`(打字机) |
|
||||
| `outline_ready` | `outline`、清空流式文本 |
|
||||
| `draft_chunk` | `streamingDraftSections`(按章节拼接打字机) |
|
||||
| `draft_ready` | `currentDraftMarkdown` / `currentDraftTitle` |
|
||||
| `review_chunk` | `streamingReviewItems[i].streamingText`(逐条打字机) |
|
||||
| `review_item_done` | `streamingReviewItems[i]` 标记 isDone + 结构化结果 |
|
||||
| `review_ready` | `reviewResult` |
|
||||
| `await_user` | `currentPause = event`,`isPaused = true`,**弹出 InterventionCard** |
|
||||
| `resumed` | `currentPause = null` |
|
||||
| `notice` | 仅记录(非致命告警) |
|
||||
| `done` | `status = 'done'` |
|
||||
| `error` | `status = 'error'`,`error = event.message` |
|
||||
|
||||
---
|
||||
|
||||
## 五、API 层(api/)
|
||||
|
||||
### 1. `api/ai-writing.ts` — 核心写作 API
|
||||
|
||||
```typescript
|
||||
// 启动写作 → 返回 session_id
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string>
|
||||
|
||||
// 订阅 SSE 进度流 → 返回取消订阅函数
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
): () => void
|
||||
|
||||
// 提交用户干预 → 让 graph 续推
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void>
|
||||
```
|
||||
|
||||
**关键实现细节**:
|
||||
- **snake_case ↔ camelCase 转换**:后端返回 snake_case,前端类型是 camelCase。所有 normalize 函数(`normalizeMaterial`、`normalizeOutlineSection`...)集中在这一层做转换
|
||||
- **SSE 事件白名单**:`SSE_EVENT_TYPES` 数组列出所有合法事件名,未知事件会被静默忽略
|
||||
- **取消订阅**:`subscribeToProgress` 返回的函数会调用 `AbortController.abort()` 关闭 fetch stream
|
||||
- **重连机制**:当前**没有内置重连** — 如果 SSE 断开(网络抖动、反代超时),需要用户主动重启写作或刷新页面
|
||||
- 后端 `_recover_session` 能让 SSE 重连时从 checkpointer 恢复事件,但前端没主动重连逻辑
|
||||
|
||||
### 2. `api/ai-writing-sessions.ts` — 历史会话
|
||||
|
||||
```typescript
|
||||
// CRUD
|
||||
listAIWritingSessions() : Promise<AIWritingSessionSummary[]>
|
||||
getAIWritingSession(id) : Promise<AIWritingSessionSummary>
|
||||
renameAIWritingSession(id, title) : Promise<AIWritingSessionSummary>
|
||||
deleteAIWritingSession(id) : Promise<void>
|
||||
|
||||
// 时间线持久化(fire-and-forget,永不抛错)
|
||||
saveAIWritingTranscript(id, transcript) : Promise<boolean>
|
||||
```
|
||||
|
||||
**关键设计**:`saveAIWritingTranscript` 内部用 try/catch 吞掉所有异常(网络错误、HTTP 500 等),只 `console.warn` 不影响主流程。这是**「锦上添花」契约**的前端侧。
|
||||
|
||||
### 3. `api/ai-writing-article-types.ts` — 文章类型管理
|
||||
|
||||
| 函数 | 用途 |
|
||||
|---|---|
|
||||
| `listArticleTypes()` | 表单下拉框选项 |
|
||||
| `createArticleType()` | 管理员新增 |
|
||||
| `updateArticleType()` | 管理员编辑 |
|
||||
| `deleteArticleType()` | 管理员删除 |
|
||||
|
||||
---
|
||||
|
||||
## 六、组件树详解
|
||||
|
||||
### 顶层:`AIWritingPanel`(457 行,最大)
|
||||
|
||||
负责:
|
||||
1. 调用 `useAIWriting()` 拿主状态
|
||||
2. 调用 `useAIWritingSessions()` 拿历史列表
|
||||
3. 协调表单 / 时间线 / 干预卡 / 流式视图 / 草稿同步
|
||||
4. **自动滚底逻辑**:检测用户是否手动上划,决定是否跟随最新内容
|
||||
5. **草稿同步到右侧画布**:监听 `currentDraftMarkdown` 变化,调 `setArtifact` 更新右侧
|
||||
6. **历史会话回显**:当 `historySession` 非空时,渲染 `AIWritingHistoryView` 而不是实时面板
|
||||
|
||||
### 干预卡:`InterventionCard`(663 行,第二大)
|
||||
|
||||
按 `pausePoint` 渲染**不同的卡片**:
|
||||
|
||||
| pausePoint | 卡片 | 用户能做的 |
|
||||
|---|---|---|
|
||||
| `material_confirm` | `MaterialsCard` | 勾选保留素材 / 加关键词重新搜 |
|
||||
| `outline_confirm` | `OutlineCard` | 直接编辑大纲 / 给反馈让重写 |
|
||||
| `draft_confirm` | 草稿确认卡 | 定稿 / 给修订意见 / 提交编辑审核 |
|
||||
| `review_confirm` | `ReviewCard` | 勾选采纳的意见 / 强制通过 |
|
||||
| `section_help` | 求助卡 | 逐章选择:补充检索 / 删除 / 按通用知识续写 |
|
||||
|
||||
### 时间线:`AIWritingTimeline`
|
||||
|
||||
迭代 `progressEvents`,每个 `step_start` / `step_done` / `notice` 渲染成一条 `TimelineStep`。`CompletedInterventionCard` 穿插显示已完成的干预记录。
|
||||
|
||||
### 流式视图:`{Outline,Draft,Review}StreamingView`
|
||||
|
||||
- **OutlineStreamingView**:打字机渲染 `outlineStreamingText`,支持 `<think>...</think>` 折叠显示
|
||||
- **DraftStreamingView**:按章节流式拼接 `streamingDraftSections`,每章节独立打字机
|
||||
- **ReviewStreamingView**:3 条审核(事实 / 逻辑 / 语言)并行打字机,完成后切到结构化评分
|
||||
|
||||
### 历史回显:`AIWritingHistoryView`
|
||||
|
||||
只读模式,从 `AIWritingSessionSummary` 重建 UI。**当 `transcript` 字段有值时**,从里面拿到完整 `progressEvents`,UI 表现跟实时一模一样。`transcript` 没有时退回到只显示最终草稿。
|
||||
|
||||
---
|
||||
|
||||
## 七、SSE 事件协议(前后端契约)
|
||||
|
||||
后端通过 SSE 推 `event: <type>\ndata: <json>\n\n` 格式,前端用浏览器原生 `fetch + ReadableStream` 解析(不用 `EventSource`,因为 EventSource 不能自定义 header)。
|
||||
|
||||
### 解析流程
|
||||
|
||||
```
|
||||
ReadableStream → TextDecoder
|
||||
→ 按 "\n\n" 分帧
|
||||
→ 每帧匹配 /^event: (.+)\ndata: (.+)$/
|
||||
→ JSON.parse(data)
|
||||
→ normalize(parsed)(snake → camel)
|
||||
→ onEvent(normalized)
|
||||
```
|
||||
|
||||
### 事件载荷字段(不完全列表)
|
||||
|
||||
| 字段 | 出现在哪些事件 | 用途 |
|
||||
|---|---|---|
|
||||
| `type` | 全部 | 事件类别 |
|
||||
| `sessionId` | 全部 | 标识 |
|
||||
| `message` | 大多数 | 显示给用户的文案 |
|
||||
| `agentName` | step_* | 「素材收集专家」/「作家」/「编辑」/「系统」 |
|
||||
| `materials` | materials_ready | 素材数组 |
|
||||
| `outline` | outline_ready | 大纲对象 |
|
||||
| `draftMarkdown` / `draftTitle` | draft_ready | 草稿内容 |
|
||||
| `reviewResult` | review_ready | 完整审核结果 |
|
||||
| `pausePoint` | await_user | 哪个干预点 |
|
||||
| `pauseContext` | await_user | 干预所需的上下文(如待确认的素材列表) |
|
||||
| `blockedSections` | await_user (section_help) | 素材不足的章节列表 |
|
||||
| `title` / `detail` / `raw` | notice / error | 非致命告警的标题、详情、原始错误 |
|
||||
|
||||
---
|
||||
|
||||
## 八、扩展指南:常见开发任务
|
||||
|
||||
### 新增一个干预点
|
||||
|
||||
1. **前端类型扩展**:
|
||||
- 在 `types/ai-writing.ts` 的 `ProgressEvent.pausePoint` 联合类型里加新 case
|
||||
- 在 `WritingStatus` 加对应的 `awaiting_xxx`
|
||||
- 在 `CompletedIntervention.pausePoint` 加 case
|
||||
|
||||
2. **写 InterventionCard 分支**:在 `InterventionCard.tsx` 里加新 `pausePoint` 分支,做 UI + 提交逻辑
|
||||
|
||||
3. **后端配合**(详见后端开发文档):
|
||||
- `graph.py` 加 `pause_xxx_confirm` 节点
|
||||
- graph 拓扑加这个节点的边
|
||||
|
||||
4. **完成干预后的清理**:在 `useAIWriting.ts` 的 `submitIntervention` 函数里,**确保把这次干预 push 到 `completedInterventions`**,让历史能看到
|
||||
|
||||
### 新增一个 SSE 事件类型
|
||||
|
||||
1. `types/ai-writing.ts`:
|
||||
- `ProgressEventType` 联合类型加新值
|
||||
- `ProgressEvent` 接口加新字段(如果有载荷)
|
||||
|
||||
2. `api/ai-writing.ts`:
|
||||
- `SSE_EVENT_TYPES` 数组加新值(不加会被忽略)
|
||||
- `normalizeEvent` 函数里加字段转换(如果是 snake_case 需要 normalize)
|
||||
|
||||
3. `useAIWriting.ts` 的 `handleEvent`:加新 case 处理状态更新
|
||||
|
||||
4. 组件层:根据需要在时间线 / 干预卡 / 流式视图里渲染
|
||||
|
||||
### 修改一个流式打字机的行为
|
||||
|
||||
每个 streaming view 内部都是独立的:
|
||||
- 状态在 `useAIWriting.ts` 的 `streaming*` 系列字段
|
||||
- 渲染在 `*StreamingView.tsx`
|
||||
- 流式 chunk 处理在 `handleEvent` 的 `outline_chunk` / `draft_chunk` / `review_chunk` 分支
|
||||
|
||||
### 新增草稿到右侧画布的逻辑
|
||||
|
||||
`AIWritingPanel.tsx` 里 `useEffect` 监听 `state.currentDraftMarkdown` → 调 `setArtifact` 更新右侧 `CanvasGraphContext`。如果需要新的 artifact 类型,看 `contexts/CanvasGraphContext.tsx` 的 artifact 接口。
|
||||
|
||||
---
|
||||
|
||||
## 九、容错与边界处理
|
||||
|
||||
### 已落实的容错(不会阻断写作)
|
||||
- ✅ `saveAIWritingTranscript` 失败仅 `console.warn`
|
||||
- ✅ 历史会话列表加载失败仅 `console.warn`
|
||||
- ✅ 重命名/删除会话失败静默吞掉
|
||||
- ✅ 后端 SSE 推 `notice` / `error` 事件时,前端只渲染不中断
|
||||
- ✅ JSON parse 失败时 SSE 帧被静默丢弃(不影响后续帧)
|
||||
|
||||
### 已知边界(可能影响 UI 体验)
|
||||
- ⚠️ **SSE 断线无自动重连** — 网络抖动 / 反代超时后用户需要手动刷新
|
||||
- ⚠️ **多 worker 部署下 SSE 事件丢失**(详见 `ai-writing-说明.md`)—— 前端只能感知到「卡住」,没有重连恢复机制
|
||||
- ⚠️ `outline_chunk` / `draft_chunk` / `review_chunk` 的 `text` / `is_thinking` 字段是 snake_case,前端类型是 `text` / `isThinking` 但 normalize 时是**保留 snake_case 直接读**(参考 `useAIWriting.ts` 的 `(event as unknown as { text: string }).text`),这是历史包袱,未来 normalize 函数应统一处理
|
||||
|
||||
---
|
||||
|
||||
## 十、调试技巧
|
||||
|
||||
### 看 SSE 原始事件
|
||||
浏览器 DevTools → Network → 找 `/api/ai-writing/<id>/stream` 请求 → Response(注意是 EventStream 格式,不能直接展开 JSON)。
|
||||
|
||||
### 查当前 hook 状态
|
||||
在浏览器 console 里:
|
||||
```js
|
||||
// 需要安装 React DevTools,在 Components tab 找到 AIWritingPanel
|
||||
// 或在 useAIWriting 里临时加:
|
||||
useEffect(() => { (window as any).__aiwriting = state; }, [state]);
|
||||
// 然后在 console 里 __aiwriting 看
|
||||
```
|
||||
|
||||
### 模拟事件
|
||||
开发期可以注释掉 `subscribeToProgress`,手动调 `handleEvent({...})` 测试 reducer 行为。
|
||||
|
||||
### 看后端 transcript
|
||||
```
|
||||
GET /api/ai-writing/sessions/<id>
|
||||
```
|
||||
返回里 `transcript` 字段就是前端组装过的完整时间线 JSON。
|
||||
|
||||
---
|
||||
|
||||
## 十一、性能注意点
|
||||
|
||||
### 大数组渲染
|
||||
- `progressEvents` 在长写作里可能上千条 → `AIWritingTimeline` 应该考虑虚拟滚动(当前未实现,会议室会议讨论过)
|
||||
- `streamingDraftSections` 章节多时也是潜在瓶颈
|
||||
|
||||
### transcript PUT 频率
|
||||
- 当前每次 `progressEvents` 变化都会触发保存(实际上是 useEffect 监听)
|
||||
- 大量 chunk 事件会导致**每秒 N 次 PUT**,后端 MySQL 写压力大
|
||||
- **优化建议**:在 `useAIWriting.ts` 给 saveAIWritingTranscript 加 debounce(1-2 秒),或者只在 `await_user` / `done` / `error` 关键节点保存
|
||||
|
||||
### 浏览器内存
|
||||
- progressEvents 不主动清理 → 长会话可能占用几十 MB
|
||||
- 完成一篇文章后建议 `reset()` 清空状态
|
||||
|
||||
---
|
||||
|
||||
## 十二、相关后端接口(速查)
|
||||
|
||||
| 接口 | 用途 | 前端调用方 |
|
||||
|---|---|---|
|
||||
| `POST /api/ai-writing/start` | 启动写作 | `startWritingSession` |
|
||||
| `GET /api/ai-writing/{id}/stream` | SSE 进度流 | `subscribeToProgress` |
|
||||
| `POST /api/ai-writing/{id}/resume` | 提交干预 | `resumeWriting` |
|
||||
| `GET /api/ai-writing/{id}/state` | 查 graph 状态快照(调试用) | 暂未调用 |
|
||||
| `GET /api/ai-writing/sessions` | 历史列表 | `listAIWritingSessions` |
|
||||
| `GET /api/ai-writing/sessions/{id}` | 历史详情 | `getAIWritingSession` |
|
||||
| `PATCH /api/ai-writing/sessions/{id}` | 重命名 | `renameAIWritingSession` |
|
||||
| `DELETE /api/ai-writing/sessions/{id}` | 删除会话 | `deleteAIWritingSession` |
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | 保存时间线(fire-and-forget) | `saveAIWritingTranscript` |
|
||||
| `GET /api/ai-writing/article-types` | 文章类型下拉 | `listArticleTypes` |
|
||||
| `POST /api/ai-writing/admin/cleanup` | admin 手动清理 | 暂未在 UI 暴露 |
|
||||
|
||||
---
|
||||
|
||||
## 十三、未来优化方向
|
||||
|
||||
| 项目 | 说明 | 工作量 |
|
||||
|---|---|---|
|
||||
| transcript PUT 防抖 | 减少 MySQL 写压力 | 1 小时 |
|
||||
| SSE 自动重连 | 网络抖动 / 反代超时后能恢复 | 0.5 天 |
|
||||
| 时间线虚拟滚动 | 处理超长 progressEvents | 0.5 天 |
|
||||
| 干预卡组件解耦 | 663 行的 InterventionCard 拆成 5 个独立卡片 | 1 天 |
|
||||
| AbortController 串行化 | 避免重启写作时旧 SSE 还没断 | 0.5 天 |
|
||||
| 后端字段统一 camelCase | 消除 snake_case ↔ camelCase 转换层 | 需后端配合 |
|
||||
|
||||
---
|
||||
|
||||
## 附录 A:所有类型一览
|
||||
|
||||
完整定义在 `types/ai-writing.ts`,主要类型:
|
||||
|
||||
```typescript
|
||||
// 业务实体
|
||||
AIMaterial // 单个素材
|
||||
AIOutline // 大纲
|
||||
OutlineSection // 大纲单章节
|
||||
ReviewIssue // 审核问题
|
||||
ReviewResult // 完整审核结果
|
||||
BlockedSection // 素材不足的章节
|
||||
SectionDecision // 章节求助决策
|
||||
|
||||
// 流程
|
||||
WritingStatus // 13 种状态
|
||||
ProgressEventType // 17 种事件类型
|
||||
ProgressEvent // SSE 事件载荷
|
||||
UserInterventionPayload // 干预提交体
|
||||
AIWritingRequest // 启动写作的请求
|
||||
CompletedIntervention // 已完成的干预记录
|
||||
|
||||
// 流式视图
|
||||
StreamingDraftSection
|
||||
StreamingReviewItem
|
||||
RevisionSummary
|
||||
|
||||
// Hook 状态
|
||||
AIWritingState // useAIWriting 的完整状态
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 附录 B:常见踩坑
|
||||
|
||||
| 现象 | 原因 | 解决 |
|
||||
|---|---|---|
|
||||
| 前端类型 camelCase 但拿到 undefined | 后端 snake_case 没 normalize | 在 `api/ai-writing.ts` 的 normalize 函数里加字段 |
|
||||
| 进度卡住不动 | SSE 断了或落到错的 worker | 刷新页面,看 `ai-writing-说明.md` 多 worker 部分 |
|
||||
| 历史会话回显时间线缺失 | transcript 保存失败 | DB 列容量 / 后端日志查 `save_transcript` |
|
||||
| 修改大纲后没生效 | 用户 PATCH editedOutline 但没勾选 confirm | InterventionCard 大纲分支检查 action 字段 |
|
||||
| 草稿渲染换行错乱 | LLM 输出包含奇怪 markdown | `DraftStreamingView` 里 markdown parser 设置 |
|
||||
|
||||
---
|
||||
|
||||
如需补充内容,请直接编辑本文件,并在 git commit message 里注明改动原因。
|
||||
@@ -0,0 +1,472 @@
|
||||
# AI 写作前端改造清单
|
||||
|
||||
## 1. 目标
|
||||
|
||||
在不新增第二套页面的前提下,基于现有 `open-canvas` 页面,把左侧从“单轮聊天输入框”升级为“多 Agent 写作工作台”,右侧继续沿用当前 `artifact` 编辑与流式预览能力。
|
||||
|
||||
当前核心文件:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
---
|
||||
|
||||
## 2. 改造原则
|
||||
|
||||
- 不改右侧主编辑器交互心智
|
||||
- 不复制新页面
|
||||
- 不破坏现有普通 chat 模式
|
||||
- 新增“写作工作流模式”时尽量以增量状态字段和新组件完成
|
||||
- 所有 workflow 能力都应允许逐步灰度
|
||||
|
||||
---
|
||||
|
||||
## 3. 推荐前端结构
|
||||
|
||||
建议在 `frontend-web/src/open-canvas` 下新增以下目录:
|
||||
|
||||
```text
|
||||
frontend-web/src/open-canvas/
|
||||
workflow/
|
||||
api/
|
||||
writing-workflow.ts
|
||||
components/
|
||||
WritingWorkflowEntryForm.tsx
|
||||
WritingWorkflowTimeline.tsx
|
||||
WritingWorkflowEventList.tsx
|
||||
WritingWorkflowArtifactDrawer.tsx
|
||||
WritingWorkflowConfirmCard.tsx
|
||||
WritingWorkflowHeader.tsx
|
||||
contexts/
|
||||
WritingWorkflowContext.tsx
|
||||
hooks/
|
||||
useWritingWorkflow.ts
|
||||
useWritingWorkflowEvents.ts
|
||||
types/
|
||||
workflow.ts
|
||||
utils/
|
||||
workflow-event.ts
|
||||
```
|
||||
|
||||
说明:
|
||||
|
||||
- `open-canvas` 原有聊天、artifact、editor 目录不重构
|
||||
- 以 `workflow/` 作为新能力边界,减少对既有文件的侵入
|
||||
|
||||
---
|
||||
|
||||
## 4. 状态设计
|
||||
|
||||
## 4.1 新增的核心状态
|
||||
|
||||
建议新增:
|
||||
|
||||
- `workflowMode: "chat" | "writing_pipeline"`
|
||||
- `workflowRunId: string | null`
|
||||
- `workflowStage`
|
||||
- `workflowStatus: "idle" | "running" | "waiting_user" | "completed" | "failed"`
|
||||
- `workflowEvents: WritingWorkflowEvent[]`
|
||||
- `workflowArtifacts: WritingWorkflowArtifact[]`
|
||||
- `workflowPendingAction: WritingPendingAction | null`
|
||||
|
||||
## 4.2 推荐类型定义
|
||||
|
||||
建议在 `workflow/types/workflow.ts` 中先定义:
|
||||
|
||||
```ts
|
||||
export type WritingWorkflowStage =
|
||||
| "idle"
|
||||
| "collecting_materials"
|
||||
| "generating_outline"
|
||||
| "await_outline_confirmation"
|
||||
| "writing_draft"
|
||||
| "reviewing_draft"
|
||||
| "rewriting_draft"
|
||||
| "completed"
|
||||
| "failed";
|
||||
|
||||
export type WritingWorkflowEventType =
|
||||
| "workflow_started"
|
||||
| "stage_changed"
|
||||
| "agent_started"
|
||||
| "agent_output_chunk"
|
||||
| "artifact_generated"
|
||||
| "human_confirmation_required"
|
||||
| "retry_scheduled"
|
||||
| "workflow_completed"
|
||||
| "workflow_failed";
|
||||
```
|
||||
|
||||
第一版把类型先收紧,后续再扩展。
|
||||
|
||||
---
|
||||
|
||||
## 5. 组件拆分建议
|
||||
|
||||
## 5.1 左侧主面板改造
|
||||
|
||||
当前 `CanvasChatPanel` 建议只承担“模式路由”和布局容器职责,不继续无限堆逻辑。
|
||||
|
||||
建议改造为:
|
||||
|
||||
- `chat` 模式继续渲染现有消息列表 + 输入框
|
||||
- `writing_pipeline` 模式渲染新的工作流视图
|
||||
|
||||
建议拆分出:
|
||||
|
||||
- `CanvasChatConversation.tsx`
|
||||
- `CanvasChatInputBox.tsx`
|
||||
- `CanvasWorkflowPanel.tsx`
|
||||
|
||||
这样后续不会把 `CanvasChatPanel.tsx` 变成一个过大的组件。
|
||||
|
||||
## 5.2 新增组件职责
|
||||
|
||||
### `WritingWorkflowEntryForm.tsx`
|
||||
|
||||
负责采集:
|
||||
|
||||
- 主题
|
||||
- 文章类型
|
||||
- 目标读者
|
||||
- 语气风格
|
||||
- 目标篇幅
|
||||
- 是否需要引用资料
|
||||
- 禁写项
|
||||
- 补充说明
|
||||
|
||||
### `WritingWorkflowTimeline.tsx`
|
||||
|
||||
负责显示阶段:
|
||||
|
||||
- 研究中
|
||||
- 提纲生成中
|
||||
- 等待确认提纲
|
||||
- 正在写初稿
|
||||
- 正在审稿
|
||||
- 正在重写
|
||||
- 已完成
|
||||
|
||||
### `WritingWorkflowEventList.tsx`
|
||||
|
||||
负责显示细粒度事件:
|
||||
|
||||
- 哪个 Agent 开始执行
|
||||
- 当前在处理哪一步
|
||||
- 当前产生了什么中间结果
|
||||
- 是否重试
|
||||
|
||||
### `WritingWorkflowArtifactDrawer.tsx`
|
||||
|
||||
负责展示中间产物:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑意见
|
||||
- 重写稿
|
||||
|
||||
### `WritingWorkflowConfirmCard.tsx`
|
||||
|
||||
负责展示用户确认点:
|
||||
|
||||
- 确认提纲
|
||||
- 驳回提纲并附带修改意见
|
||||
- 继续写稿
|
||||
- 指定重写章节
|
||||
|
||||
---
|
||||
|
||||
## 6. Context 与 Hook 改造建议
|
||||
|
||||
## 6.1 `CanvasGraphContext`
|
||||
|
||||
当前它只维护:
|
||||
|
||||
- `messages`
|
||||
- `artifact`
|
||||
- `streamingArtifact`
|
||||
- `isStreaming`
|
||||
|
||||
建议不要把 workflow 状态直接塞进现有 `CanvasGraphContext`,否则会让语义过重。
|
||||
|
||||
推荐做法:
|
||||
|
||||
- 保留 `CanvasGraphContext` 继续服务右侧 artifact 和通用流式状态
|
||||
- 新增 `WritingWorkflowContext`
|
||||
|
||||
## 6.2 `WritingWorkflowContext`
|
||||
|
||||
建议承载:
|
||||
|
||||
- 当前 workflow 运行状态
|
||||
- 中间产物列表
|
||||
- 待确认操作
|
||||
- workflow 事件缓存
|
||||
- 启动 / 取消 / 确认 / 重写等 action
|
||||
|
||||
## 6.3 `useCanvasThreadStream`
|
||||
|
||||
当前这个 hook 已经负责:
|
||||
|
||||
- 创建 thread
|
||||
- 提交消息
|
||||
- 接收 thread stream
|
||||
- 将 `update_artifact` 同步到右侧
|
||||
|
||||
建议不要把“多 Agent workflow 状态机”也硬塞进去。
|
||||
|
||||
推荐方案:
|
||||
|
||||
- 保留 `useCanvasThreadStream` 给普通 chat 或兼容旧能力使用
|
||||
- 新增 `useWritingWorkflow`
|
||||
- 新增 `useWritingWorkflowEvents`
|
||||
|
||||
分工如下:
|
||||
|
||||
- `useCanvasThreadStream`:普通对话流
|
||||
- `useWritingWorkflow`:创建 workflow、提交表单、触发确认动作
|
||||
- `useWritingWorkflowEvents`:订阅 workflow 事件流
|
||||
|
||||
---
|
||||
|
||||
## 7. API 接入建议
|
||||
|
||||
## 7.1 前端 API 文件
|
||||
|
||||
新增:
|
||||
|
||||
- `workflow/api/writing-workflow.ts`
|
||||
|
||||
建议暴露方法:
|
||||
|
||||
- `createWorkflow(payload)`
|
||||
- `startWorkflow(id)`
|
||||
- `getWorkflow(id)`
|
||||
- `subscribeWorkflowEvents(id, handlers)`
|
||||
- `confirmOutline(id, payload)`
|
||||
- `rewriteSection(id, payload)`
|
||||
- `approveFinal(id)`
|
||||
|
||||
## 7.2 事件流形式
|
||||
|
||||
优先级建议:
|
||||
|
||||
1. `SSE`
|
||||
2. 流式 HTTP chunk
|
||||
3. 轮询兜底
|
||||
|
||||
理由:
|
||||
|
||||
- 事件驱动比整段消息流更适合 workflow UI
|
||||
- 可以更方便映射到时间线和事件列表
|
||||
|
||||
---
|
||||
|
||||
## 8. 与右侧 Artifact 的衔接
|
||||
|
||||
## 8.1 现有优势
|
||||
|
||||
当前 `CanvasArtifactPanel` 已具备:
|
||||
|
||||
- 流式展示内容
|
||||
- 最终 artifact 渲染
|
||||
- 右侧编辑体验成熟
|
||||
|
||||
这意味着多 Agent 工作流不需要再做第二套文章渲染,只要把最终阶段结果推成 artifact 即可。
|
||||
|
||||
## 8.2 建议接法
|
||||
|
||||
工作流各阶段结果按两类处理:
|
||||
|
||||
- 中间产物:进入左侧 `workflowArtifacts`
|
||||
- 可阅读正文:同步一份到右侧 `artifact`
|
||||
|
||||
推荐映射:
|
||||
|
||||
- 提纲:可选同步到右侧,供用户预览
|
||||
- 初稿:同步到右侧
|
||||
- 重写稿:覆盖或新增版本到右侧
|
||||
- 终稿:作为最终右侧主版本
|
||||
|
||||
## 8.3 版本策略
|
||||
|
||||
建议右侧版本保留:
|
||||
|
||||
- `outline`
|
||||
- `draft_v1`
|
||||
- `draft_v2`
|
||||
- `final`
|
||||
|
||||
如果现有 artifact 数据结构不方便承载完整版本树,第一版可先保留在 workflowArtifacts 中,右侧只展示当前激活版本。
|
||||
|
||||
---
|
||||
|
||||
## 9. 页面交互流程
|
||||
|
||||
## 9.1 用户首次进入
|
||||
|
||||
- 默认仍是现有 chat 模式
|
||||
- 在左侧增加一个显眼切换入口:`普通对话` / `写作工作流`
|
||||
|
||||
## 9.2 启动写作工作流
|
||||
|
||||
- 用户填写写作任务表单
|
||||
- 点击“开始生成”
|
||||
- 左侧切换到时间线 + 事件流视图
|
||||
- 右侧进入 loading 或素材 / 提纲预览态
|
||||
|
||||
## 9.3 等待提纲确认
|
||||
|
||||
- 时间线停在 `await_outline_confirmation`
|
||||
- 左侧出现确认卡片
|
||||
- 用户可执行:
|
||||
- 通过提纲
|
||||
- 要求调整提纲
|
||||
- 追加要求
|
||||
|
||||
## 9.4 生成初稿与审稿
|
||||
|
||||
- 左侧显示 Agent 事件
|
||||
- 右侧展示初稿内容
|
||||
- 审稿意见在左侧展示,同时高亮需要重写的章节
|
||||
|
||||
## 9.5 完成
|
||||
|
||||
- 左侧显示“终稿已完成”
|
||||
- 右侧进入可继续局部 AI 编辑状态
|
||||
|
||||
---
|
||||
|
||||
## 10. 分步开发清单
|
||||
|
||||
## Step 1:类型与假数据
|
||||
|
||||
目标:
|
||||
|
||||
- 先把 workflow 的类型、mock 数据、组件骨架搭好
|
||||
|
||||
任务:
|
||||
|
||||
- 新建 `workflow/types/workflow.ts`
|
||||
- 新建 `workflow/components/*`
|
||||
- 用本地 mock 事件驱动时间线和事件流
|
||||
|
||||
验收:
|
||||
|
||||
- 不接后端也能在页面中演示完整流程
|
||||
|
||||
## Step 2:模式切换
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 中接入 `chat / writing_pipeline` 双模式
|
||||
|
||||
任务:
|
||||
|
||||
- 拆出 `CanvasWorkflowPanel`
|
||||
- 增加模式切换 UI
|
||||
- 保证旧 chat 不回归
|
||||
|
||||
验收:
|
||||
|
||||
- 现有功能不受影响
|
||||
- 新模式可进入表单页
|
||||
|
||||
## Step 3:接入 workflow API
|
||||
|
||||
目标:
|
||||
|
||||
- 从 mock 切到真实 API
|
||||
|
||||
任务:
|
||||
|
||||
- 接 `create/start/get/events`
|
||||
- 实现基础错误态和重试态
|
||||
|
||||
验收:
|
||||
|
||||
- 前后端可联调跑一条主链路
|
||||
|
||||
## Step 4:接入确认节点
|
||||
|
||||
目标:
|
||||
|
||||
- 支持提纲确认和指定章节重写
|
||||
|
||||
任务:
|
||||
|
||||
- 做确认卡片
|
||||
- 做动作回传
|
||||
|
||||
验收:
|
||||
|
||||
- 用户能在流程中介入
|
||||
|
||||
## Step 5:打通右侧版本
|
||||
|
||||
目标:
|
||||
|
||||
- 让 workflow 产物稳定进入右侧编辑器
|
||||
|
||||
任务:
|
||||
|
||||
- 约定 artifact 映射
|
||||
- 接入版本切换或当前版本覆盖
|
||||
|
||||
验收:
|
||||
|
||||
- 终稿稳定显示在右侧
|
||||
|
||||
---
|
||||
|
||||
## 11. 建议修改点位
|
||||
|
||||
优先涉及文件:
|
||||
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasGraphContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasGraphContext.tsx)
|
||||
- [useCanvasThreadStream.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
|
||||
建议新增,不建议重写原有:
|
||||
|
||||
- `open-canvas/workflow/api/writing-workflow.ts`
|
||||
- `open-canvas/workflow/contexts/WritingWorkflowContext.tsx`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflow.ts`
|
||||
- `open-canvas/workflow/hooks/useWritingWorkflowEvents.ts`
|
||||
- `open-canvas/workflow/components/*`
|
||||
|
||||
---
|
||||
|
||||
## 12. 联调约定
|
||||
|
||||
前后端需要尽早统一:
|
||||
|
||||
- workflow id 命名
|
||||
- stage 枚举
|
||||
- event type 枚举
|
||||
- artifact type 枚举
|
||||
- user action payload
|
||||
- 错误码与可恢复动作
|
||||
|
||||
建议先由前端与后端共同确认一份 `workflow.ts` 契约文档,再开工。
|
||||
|
||||
---
|
||||
|
||||
## 13. 最终建议
|
||||
|
||||
前端最重要的不是“把 Agent 结果展示出来”,而是把“工作流过程可视化并允许人工介入”做清楚。
|
||||
|
||||
第一版建议先保证三件事:
|
||||
|
||||
1. 左侧能看见阶段进展
|
||||
2. 用户能确认提纲
|
||||
3. 右侧能稳定收到初稿和终稿
|
||||
|
||||
只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。
|
||||
@@ -0,0 +1,718 @@
|
||||
# AI 写作弱模型多 Agent 编排需求文档
|
||||
|
||||
## 1. 背景与目标
|
||||
|
||||
当前 `open-canvas` 页面右侧的成品展示与编辑体验已经比较成熟,但左侧模型直接生成文章的质量还不稳定。对于内网部署的弱性能大模型,单轮直出通常会出现以下问题:
|
||||
|
||||
- 结构松散,长文容易跑题
|
||||
- 素材不足,内容空泛
|
||||
- 事实依据弱,容易幻觉
|
||||
- 润色能力有限,语言风格不统一
|
||||
- 一次生成失败后缺少可控的纠偏机制
|
||||
|
||||
因此本次需求的核心目标不是继续优化“单个模型一次写完”,而是在内网用代码编排一个“小型杂志社工作流”,让弱模型通过多角色分工协作,产出明显优于单模型直出的文章质量。
|
||||
|
||||
建议的角色分工:
|
||||
|
||||
- `Agent A 研究员`:负责搜集内网素材、归纳事实、提取观点
|
||||
- `Agent B 作家`:基于选题、素材和结构写初稿
|
||||
- `Agent C 严苛编辑`:负责挑错、质检、润色、打回重写
|
||||
- `Supervisor / Orchestrator`:负责流程调度、状态记录、重试与终止判断
|
||||
|
||||
---
|
||||
|
||||
## 2. 本次建设边界
|
||||
|
||||
### 2.1 前端边界
|
||||
|
||||
前端继续使用现有 `open-canvas` 页面,不新开第二套编辑器页面。
|
||||
|
||||
当前相关入口:
|
||||
|
||||
- [CanvasResizableLayout.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/layout/CanvasResizableLayout.tsx)
|
||||
- [CanvasChatPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/chat/CanvasChatPanel.tsx)
|
||||
- [CanvasArtifactPanel.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/components/artifact/CanvasArtifactPanel.tsx)
|
||||
- [CanvasStreamContext.tsx](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx)
|
||||
- [runs.ts](/F:/react/deeflow-code/deerflow-server-master/frontend-web/src/open-canvas/api/runs.ts)
|
||||
|
||||
前端本期只做以下增强:
|
||||
|
||||
- 在左侧聊天区增加“写作工作流模式”
|
||||
- 让用户能看到多 Agent 阶段进度,而不是只看到最终回答
|
||||
- 支持结构化输入:主题、体裁、目标读者、篇幅、语气、参考资料、禁写项
|
||||
- 支持查看中间产物:提纲、素材卡片、初稿、编辑意见、终稿
|
||||
- 支持人工介入:确认提纲、确认初稿、接受编辑建议、手动重跑某一步
|
||||
|
||||
### 2.2 后端边界
|
||||
|
||||
后端必须单开新路线,不影响其他同事当前功能开发。
|
||||
|
||||
建议原则:
|
||||
|
||||
- 不直接改现有 DeerFlow 主流程
|
||||
- 不把多 Agent 逻辑硬塞进当前通用对话链路
|
||||
- 以独立服务或独立模块形式建设
|
||||
- 通过清晰 API 与当前前端页面对接
|
||||
|
||||
推荐落地位置:
|
||||
|
||||
- 基于仓库中的 `offline-backend-20260512` 单独开一条 `ai-writing-orchestrator` 路线
|
||||
- 或在后端新建独立目录,例如 `offline-backend-20260512/apps/ai_writing_orchestrator`
|
||||
|
||||
---
|
||||
|
||||
## 3. 为什么多 Agent 对弱模型更合适
|
||||
|
||||
弱模型最怕“一个 prompt 做完所有事”,因为这要求模型同时完成:
|
||||
|
||||
- 选题理解
|
||||
- 信息检索
|
||||
- 信息筛选
|
||||
- 结构规划
|
||||
- 长文写作
|
||||
- 自我校验
|
||||
- 风格润色
|
||||
|
||||
把这些任务拆开后,每个 Agent 只处理一个更短、更清晰、更容易被弱模型完成的子任务,通常会带来以下收益:
|
||||
|
||||
- 降低单次上下文复杂度
|
||||
- 降低一次性长输出失败率
|
||||
- 把“写得不好”变成“某一步做得不好”,便于重试
|
||||
- 可以在关键节点插入规则校验与人工确认
|
||||
- 可以用程序补足模型短板,而不是完全依赖模型智力
|
||||
|
||||
这套方案的本质不是“让模型变聪明”,而是“让系统更会组织模型工作”。
|
||||
|
||||
---
|
||||
|
||||
## 4. 可借鉴的开源项目
|
||||
|
||||
以下项目适合借鉴,但不建议整套照搬。建议以“借鉴状态机、角色协作、阶段拆分、重试与审稿机制”为主。
|
||||
|
||||
### 4.1 第一优先级:LangGraph
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 天然适合把写作流程建模成状态图
|
||||
- 支持多步骤、条件分支、重试、人工介入
|
||||
- 便于把“研究 -> 提纲 -> 初稿 -> 评审 -> 重写”固化成可控流程
|
||||
- 对“弱模型 + 强流程控制”很友好
|
||||
|
||||
可借鉴点:
|
||||
|
||||
- `StateGraph` 的状态驱动编排
|
||||
- 每一步单独定义输入、输出、失败处理
|
||||
- 可视化地表达“通过 / 打回 / 重试 / 终止”
|
||||
|
||||
参考:
|
||||
|
||||
- [langchain-ai/langgraph](https://github.com/langchain-ai/langgraph)
|
||||
- [langgraph supervisor pattern](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
|
||||
### 4.2 第二优先级:STORM
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 它本身就是“研究 + 提纲 + 成文 + 打磨”的长文系统
|
||||
- 对“先研究后写作”的思路非常契合
|
||||
- 很适合借鉴其“问题驱动研究”和“分阶段生成”的思想
|
||||
|
||||
根据其 GitHub README,STORM 将长文生成拆成两个大阶段:先研究并产出提纲,再基于提纲与资料写长文;其引擎进一步拆成知识整理、提纲生成、正文生成、文章润色四个模块。
|
||||
|
||||
这和我们的目标高度一致,尤其适合借鉴:
|
||||
|
||||
- 提纲先行
|
||||
- 素材引用先行
|
||||
- 分模块写作
|
||||
- 后置润色
|
||||
|
||||
参考:
|
||||
|
||||
- [stanford-oval/storm](https://github.com/stanford-oval/storm)
|
||||
|
||||
### 4.3 第三优先级:Deep Research Agent
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 明确支持本地模型与云模型混用
|
||||
- 已经包含研究、写作、质量校验、缓存、重试等能力
|
||||
- 很适合借鉴“研究阶段如何约束弱模型”
|
||||
|
||||
根据其 README,这个项目采用四个专职 Agent,并且带有可信度评分、质量验证、重试逻辑、缓存和 checkpoint。
|
||||
|
||||
特别适合借鉴:
|
||||
|
||||
- 素材缓存
|
||||
- 分阶段质量评分
|
||||
- 节点失败重试
|
||||
- 研究结果结构化存储
|
||||
|
||||
参考:
|
||||
|
||||
- [tarun7r/deep-research-agent](https://github.com/tarun7r/deep-research-agent)
|
||||
|
||||
### 4.4 可选借鉴:CrewAI
|
||||
|
||||
推荐原因:
|
||||
|
||||
- 角色扮演表达自然
|
||||
- 写“研究员 / 作家 / 编辑”这种协作关系很直观
|
||||
- Flow + Crew 的组织方式适合做 demo 和快速验证
|
||||
|
||||
不建议作为首选底座的原因:
|
||||
|
||||
- 更偏“Agent 角色编排体验”
|
||||
- 若要做企业内网稳定落地,状态管理和流程可控性通常不如 LangGraph 路线清晰
|
||||
|
||||
适合借鉴:
|
||||
|
||||
- 角色定义写法
|
||||
- Task 模板
|
||||
- 多角色协作 prompt 结构
|
||||
|
||||
参考:
|
||||
|
||||
- [crewAIInc/crewAI](https://github.com/crewAIInc/crewAI)
|
||||
|
||||
### 4.5 不建议新项目优先采用:AutoGen
|
||||
|
||||
原因:
|
||||
|
||||
- 截至最近公开信息,`microsoft/autogen` 已进入 maintenance mode
|
||||
- 官方建议新项目优先采用 `Microsoft Agent Framework`
|
||||
|
||||
如果要借鉴,只建议借鉴早期多 Agent 对话模式,不建议作为本项目主框架。
|
||||
|
||||
参考:
|
||||
|
||||
- [microsoft/autogen](https://github.com/microsoft/autogen)
|
||||
- [microsoft/agent-framework](https://github.com/microsoft/agent-framework)
|
||||
|
||||
### 4.6 结论
|
||||
|
||||
综合本项目需求,建议技术选型优先级如下:
|
||||
|
||||
1. `LangGraph` 作为主编排框架
|
||||
2. `STORM` 借鉴研究-提纲-成文-润色的模块划分
|
||||
3. `Deep Research Agent` 借鉴缓存、评分、重试、可观测性
|
||||
4. `CrewAI` 借鉴角色 prompt 设计与任务模板
|
||||
|
||||
---
|
||||
|
||||
## 5. 目标产品形态
|
||||
|
||||
用户在当前 `open-canvas` 页面中,不再只是“向模型提问写文章”,而是启动一个可视化的写作流水线。
|
||||
|
||||
理想交互流程:
|
||||
|
||||
1. 用户输入主题、用途、风格、读者、篇幅要求
|
||||
2. 系统先进入“研究阶段”
|
||||
3. 系统给出资料卡片与候选提纲
|
||||
4. 用户确认提纲或调整
|
||||
5. 系统生成初稿
|
||||
6. 严苛编辑给出问题清单
|
||||
7. 系统自动重写问题段落
|
||||
8. 系统输出终稿到右侧编辑区
|
||||
9. 用户可继续局部改写、扩写、精简
|
||||
|
||||
核心体验目标:
|
||||
|
||||
- 左侧像“编辑部工作台”
|
||||
- 右侧像“成文编辑器”
|
||||
- 用户知道系统现在进行到哪一步
|
||||
- 用户可以在关键节点接管,而不是只能等待最终结果
|
||||
|
||||
---
|
||||
|
||||
## 6. 多 Agent 流程设计
|
||||
|
||||
## 6.1 建议的最小可用流程 MVP
|
||||
|
||||
第一版不要一上来做很复杂,建议先做 4 步闭环:
|
||||
|
||||
1. `Research`
|
||||
- 输入:主题、用户要求、内网资料源
|
||||
- 输出:素材卡片列表、可引用事实列表、风险点列表
|
||||
|
||||
2. `Outline`
|
||||
- 输入:用户要求 + 素材卡片
|
||||
- 输出:结构化提纲
|
||||
|
||||
3. `Draft`
|
||||
- 输入:提纲 + 素材卡片
|
||||
- 输出:初稿
|
||||
|
||||
4. `ReviewAndRewrite`
|
||||
- 输入:初稿 + 用户要求 + 素材卡片
|
||||
- 输出:问题清单、改写稿、终稿
|
||||
|
||||
MVP 终止条件:
|
||||
|
||||
- 编辑评分达标则结束
|
||||
- 不达标最多重写 2 次
|
||||
- 超过次数则返回“需要人工介入”
|
||||
|
||||
## 6.2 第二阶段增强流程
|
||||
|
||||
在 MVP 跑通后,再增加:
|
||||
|
||||
- `Planner`:根据文章类型动态规划流程
|
||||
- `SectionWriter`:按章节分别写作,避免长文崩坏
|
||||
- `FactChecker`:逐段核对素材来源
|
||||
- `StyleEditor`:统一语气、标题风格、句式节奏
|
||||
- `ComplianceReviewer`:内网场景下做敏感词、保密、术语规范检查
|
||||
|
||||
## 6.3 建议的状态机
|
||||
|
||||
建议将流程状态固定为:
|
||||
|
||||
- `idle`
|
||||
- `collecting_materials`
|
||||
- `generating_outline`
|
||||
- `await_outline_confirmation`
|
||||
- `writing_draft`
|
||||
- `reviewing_draft`
|
||||
- `rewriting_draft`
|
||||
- `completed`
|
||||
- `failed`
|
||||
- `human_intervention_required`
|
||||
|
||||
这样前端和后端都可以围绕统一状态通信。
|
||||
|
||||
---
|
||||
|
||||
## 7. Agent 角色定义建议
|
||||
|
||||
## 7.1 Agent A 研究员
|
||||
|
||||
职责:
|
||||
|
||||
- 搜集内网知识库、文档库、FAQ、历史稿件中的相关信息
|
||||
- 输出结构化素材,而不是长篇自然语言
|
||||
- 对每条素材标注来源、可信度、适用角度
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `facts[]`
|
||||
- `quotes[]`
|
||||
- `examples[]`
|
||||
- `risks[]`
|
||||
- `source_refs[]`
|
||||
|
||||
约束建议:
|
||||
|
||||
- 禁止直接生成整篇文章
|
||||
- 不做风格化表达
|
||||
- 每条结论必须可回溯到来源
|
||||
|
||||
## 7.2 Agent B 作家
|
||||
|
||||
职责:
|
||||
|
||||
- 严格基于提纲和素材写稿
|
||||
- 按章节写,而不是一次写完整篇
|
||||
- 对每段附带素材引用 ID,便于后续审核
|
||||
|
||||
约束建议:
|
||||
|
||||
- 无素材不发挥
|
||||
- 每段只服务一个小节目标
|
||||
- 段落长度控制
|
||||
- 输出结构化草稿数据,而不仅是纯文本
|
||||
|
||||
## 7.3 Agent C 严苛编辑
|
||||
|
||||
职责:
|
||||
|
||||
- 检查逻辑跳跃、素材不足、事实不稳、表述空泛、风格不统一
|
||||
- 输出明确的“问题清单”和“改写指令”
|
||||
- 必要时打回指定章节,而不是整篇推翻
|
||||
|
||||
输出格式建议:
|
||||
|
||||
- `issues[]`
|
||||
- `severity`
|
||||
- `rewrite_instructions[]`
|
||||
- `accept/reject`
|
||||
|
||||
编辑原则建议:
|
||||
|
||||
- 优先挑事实问题,再挑结构问题,最后才是文风问题
|
||||
- 问题描述必须可执行,不能只说“写得不够好”
|
||||
|
||||
## 7.4 Supervisor
|
||||
|
||||
职责:
|
||||
|
||||
- 负责流程启动、状态流转、失败重试、记录日志
|
||||
- 判断是否需要回到上一步
|
||||
- 控制最大重试次数
|
||||
- 对外提供统一 API 与流式事件
|
||||
|
||||
---
|
||||
|
||||
## 8. 前端需求
|
||||
|
||||
## 8.1 保持当前页面结构不变
|
||||
|
||||
继续沿用当前左右布局:
|
||||
|
||||
- 左侧:聊天 + 流程进度 + 中间产物
|
||||
- 右侧:最终文章编辑与局部 AI 改写
|
||||
|
||||
不建议再复制一套新页面,以免造成维护分叉。
|
||||
|
||||
## 8.2 左侧新增 UI 模块
|
||||
|
||||
建议在 `CanvasChatPanel` 基础上新增:
|
||||
|
||||
- 写作任务表单
|
||||
- 工作流阶段时间线
|
||||
- Agent 执行日志面板
|
||||
- 中间产物抽屉
|
||||
- 人工确认卡片
|
||||
|
||||
建议新增的前端概念:
|
||||
|
||||
- `workflowMode`: `chat | writing_pipeline`
|
||||
- `workflowStage`
|
||||
- `workflowRunId`
|
||||
- `workflowEvents[]`
|
||||
- `workflowArtifacts[]`
|
||||
|
||||
## 8.3 需要展示的中间产物
|
||||
|
||||
至少支持展示:
|
||||
|
||||
- 素材卡片
|
||||
- 提纲
|
||||
- 初稿
|
||||
- 编辑问题单
|
||||
- 重写结果
|
||||
- 最终定稿
|
||||
|
||||
这些内容建议不仅显示在消息区,也保存到右侧可选版本中,便于回看。
|
||||
|
||||
## 8.4 人工介入节点
|
||||
|
||||
至少提供两个确认点:
|
||||
|
||||
- 提纲确认
|
||||
- 初稿确认或编辑后重跑
|
||||
|
||||
后续可扩展:
|
||||
|
||||
- 指定重写某个章节
|
||||
- 锁定某段不允许再改
|
||||
- 替换某条素材后重新生成
|
||||
|
||||
---
|
||||
|
||||
## 9. 后端需求
|
||||
|
||||
## 9.1 架构原则
|
||||
|
||||
后端新路线必须满足:
|
||||
|
||||
- 与现有通用对话链路解耦
|
||||
- 与现有 Agent 运行时解耦
|
||||
- 独立维护 Prompt、状态机、数据结构
|
||||
- 可以单独部署、单独回滚、单独压测
|
||||
|
||||
## 9.2 推荐模块划分
|
||||
|
||||
建议后端拆分为以下模块:
|
||||
|
||||
- `workflow_orchestrator`
|
||||
- `agent_registry`
|
||||
- `material_retriever`
|
||||
- `outline_generator`
|
||||
- `draft_writer`
|
||||
- `editor_reviewer`
|
||||
- `rewrite_engine`
|
||||
- `workflow_store`
|
||||
- `stream_event_adapter`
|
||||
|
||||
## 9.3 API 建议
|
||||
|
||||
建议最小 API:
|
||||
|
||||
- `POST /api/ai-writing/workflows`
|
||||
- 创建写作任务
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/start`
|
||||
- 启动流程
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}`
|
||||
- 查询当前状态
|
||||
|
||||
- `GET /api/ai-writing/workflows/{id}/events`
|
||||
- 获取事件流或轮询事件
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/confirm-outline`
|
||||
- 用户确认提纲
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/rewrite-section`
|
||||
- 指定章节重写
|
||||
|
||||
- `POST /api/ai-writing/workflows/{id}/approve-final`
|
||||
- 确认终稿
|
||||
|
||||
## 9.4 流式事件建议
|
||||
|
||||
为了与当前 `CanvasStreamContext` 风格兼容,建议后端输出结构化事件流,例如:
|
||||
|
||||
- `workflow_started`
|
||||
- `stage_changed`
|
||||
- `agent_started`
|
||||
- `agent_output_chunk`
|
||||
- `artifact_generated`
|
||||
- `human_confirmation_required`
|
||||
- `retry_scheduled`
|
||||
- `workflow_completed`
|
||||
- `workflow_failed`
|
||||
|
||||
前端只要订阅事件,就能把左侧工作台做出来。
|
||||
|
||||
---
|
||||
|
||||
## 10. 弱模型优化策略
|
||||
|
||||
这是本项目成功的关键,不能只靠“多 Agent”这四个字。
|
||||
|
||||
## 10.1 强约束输出格式
|
||||
|
||||
每个 Agent 尽量输出 JSON 或结构化对象,不要自由发挥。
|
||||
|
||||
原因:
|
||||
|
||||
- 便于程序消费
|
||||
- 便于后续 Agent 接续
|
||||
- 便于失败重试
|
||||
- 便于质量评估
|
||||
|
||||
## 10.2 长文拆成章节生成
|
||||
|
||||
不要让弱模型一次生成整篇长文,建议:
|
||||
|
||||
- 先出提纲
|
||||
- 再按章节逐段写
|
||||
- 最后统一润色
|
||||
|
||||
## 10.3 把检索和写作分离
|
||||
|
||||
研究员只负责找资料,作家只负责组织表达。不要让写作 Agent 同时去“边搜边编”。
|
||||
|
||||
## 10.4 程序兜底而不是模型兜底
|
||||
|
||||
程序需要承担:
|
||||
|
||||
- 去重
|
||||
- 引用格式化
|
||||
- 章节拼接
|
||||
- 长度控制
|
||||
- 评分阈值判断
|
||||
- 重试路由
|
||||
|
||||
## 10.5 小步快跑、失败可回退
|
||||
|
||||
每步都要能独立失败、独立重试、独立回放。
|
||||
|
||||
## 10.6 引入可量化评分
|
||||
|
||||
编辑 Agent 输出不能只是一段文字,建议给出评分维度:
|
||||
|
||||
- 相关性
|
||||
- 事实性
|
||||
- 结构完整度
|
||||
- 语言流畅度
|
||||
- 风格一致性
|
||||
|
||||
系统据此决定是否进入下一步。
|
||||
|
||||
---
|
||||
|
||||
## 11. 分阶段实施计划
|
||||
|
||||
## Phase 1:需求冻结与技术验证
|
||||
|
||||
目标:
|
||||
|
||||
- 确认文章类型范围
|
||||
- 确认内网资料来源
|
||||
- 选定主框架
|
||||
- 跑通最小多 Agent demo
|
||||
|
||||
交付物:
|
||||
|
||||
- 技术选型说明
|
||||
- 角色与状态机设计
|
||||
- 1 个主题的端到端 demo
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型可完成“研究 -> 提纲 -> 初稿 -> 编辑 -> 重写”闭环
|
||||
|
||||
## Phase 2:后端新路线搭建
|
||||
|
||||
目标:
|
||||
|
||||
- 建立独立 orchestrator
|
||||
- 建立 workflow state store
|
||||
- 建立事件流输出
|
||||
- 建立可配置 prompt 模板
|
||||
|
||||
交付物:
|
||||
|
||||
- 独立服务目录
|
||||
- 基础 API
|
||||
- 状态机运行骨架
|
||||
|
||||
验收标准:
|
||||
|
||||
- 不依赖现有主流程改造即可独立跑通
|
||||
|
||||
## Phase 3:前端接入当前页面
|
||||
|
||||
目标:
|
||||
|
||||
- 在 `CanvasChatPanel` 接入工作流模式
|
||||
- 增加阶段展示与人工确认
|
||||
- 中间产物可视化
|
||||
|
||||
交付物:
|
||||
|
||||
- 左侧工作流时间线
|
||||
- 中间产物展示面板
|
||||
- 流程控制按钮
|
||||
|
||||
验收标准:
|
||||
|
||||
- 用户可以在当前页面完成一次全流程写作
|
||||
|
||||
## Phase 4:质量优化
|
||||
|
||||
目标:
|
||||
|
||||
- 强化素材检索质量
|
||||
- 提高提纲质量
|
||||
- 引入章节级重写
|
||||
- 引入评分与回退机制
|
||||
|
||||
交付物:
|
||||
|
||||
- 质量评估规则
|
||||
- 重试策略
|
||||
- Prompt 调优包
|
||||
|
||||
验收标准:
|
||||
|
||||
- 弱模型效果明显优于单轮直出
|
||||
|
||||
## Phase 5:生产化
|
||||
|
||||
目标:
|
||||
|
||||
- 加缓存
|
||||
- 加日志
|
||||
- 加监控
|
||||
- 加失败告警
|
||||
- 加审计记录
|
||||
|
||||
交付物:
|
||||
|
||||
- 可观测性方案
|
||||
- 运行日志与回放
|
||||
- 配置中心或 prompt 管理机制
|
||||
|
||||
验收标准:
|
||||
|
||||
- 可长期维护并可定位问题
|
||||
|
||||
---
|
||||
|
||||
## 12. 团队协作与隔离方案
|
||||
|
||||
为了不影响其他同事,建议采用以下协作原则:
|
||||
|
||||
- 新后端目录独立开发
|
||||
- 新接口使用独立路由前缀
|
||||
- 前端只在 `open-canvas` 内增量开发
|
||||
- 不修改现有公共线程协议,必要时增加新事件类型
|
||||
- Prompt、Agent 配置、评分规则独立存放
|
||||
|
||||
推荐分工:
|
||||
|
||||
- 前端 1 人:负责左侧工作流 UI 与右侧版本接入
|
||||
- 后端 1-2 人:负责 orchestrator、Agent 执行、事件流
|
||||
- 算法/Prompt 1 人:负责角色 prompt、评分规则、失败样本调优
|
||||
|
||||
---
|
||||
|
||||
## 13. 风险与规避
|
||||
|
||||
### 风险 1:Agent 太多,成本和耗时暴涨
|
||||
|
||||
规避:
|
||||
|
||||
- MVP 只保留 3 个业务 Agent + 1 个 Supervisor
|
||||
- 长文按章节写,失败只重写局部
|
||||
|
||||
### 风险 2:弱模型在自由对话场景中失控
|
||||
|
||||
规避:
|
||||
|
||||
- 全流程结构化
|
||||
- 每步只做一件事
|
||||
- 强约束输入输出格式
|
||||
|
||||
### 风险 3:内网检索质量不足,导致垃圾进垃圾出
|
||||
|
||||
规避:
|
||||
|
||||
- 先把资料源治理好
|
||||
- 对素材做来源、可信度、去重处理
|
||||
|
||||
### 风险 4:前后端协议频繁变化影响联调
|
||||
|
||||
规避:
|
||||
|
||||
- 先定义事件模型和 artifact 模型
|
||||
- 先 mock 数据,再并行开发
|
||||
|
||||
### 风险 5:影响现有功能稳定性
|
||||
|
||||
规避:
|
||||
|
||||
- 新路线、新接口、新状态机
|
||||
- 不侵入现有主链路
|
||||
|
||||
---
|
||||
|
||||
## 14. 推荐结论
|
||||
|
||||
本项目推荐的总体策略:
|
||||
|
||||
1. 前端继续基于当前 `open-canvas` 页面开发,不复制新页面
|
||||
2. 后端单独开 `ai-writing-orchestrator` 新路线,避免影响现有同事
|
||||
3. 主框架优先采用 `LangGraph`
|
||||
4. 流程设计借鉴 `STORM`
|
||||
5. 质量控制借鉴 `Deep Research Agent`
|
||||
6. 角色设计与任务模板可参考 `CrewAI`
|
||||
7. 第一版只做 4 步 MVP:研究、提纲、初稿、编辑重写
|
||||
|
||||
一句话总结:
|
||||
|
||||
不是继续“调一个更强的 prompt”,而是把弱模型放进一个可控的编辑部流水线,让系统帮它把复杂写作拆开完成。
|
||||
|
||||
---
|
||||
|
||||
## 15. 参考链接
|
||||
|
||||
- [LangGraph](https://github.com/langchain-ai/langgraph)
|
||||
- [LangGraph Supervisor](https://github.com/langchain-ai/langgraph-supervisor-py)
|
||||
- [STORM](https://github.com/stanford-oval/storm)
|
||||
- [Deep Research Agent](https://github.com/tarun7r/deep-research-agent)
|
||||
- [CrewAI](https://github.com/crewAIInc/crewAI)
|
||||
- [Microsoft Agent Framework](https://github.com/microsoft/agent-framework)
|
||||
- [AutoGen](https://github.com/microsoft/autogen)
|
||||
@@ -0,0 +1,461 @@
|
||||
# AI 写作 — 故障排查与解决方案说明
|
||||
|
||||
> **本文档目的**:完整记录 AI 写作子系统在内网 Docker 部署后出现的问题、根因、已落地的修复、剩余风险、以及未来的解决方案路径。
|
||||
>
|
||||
> **适用读者**:维护 AI 写作功能的前端/后端开发者、内网部署运维。
|
||||
>
|
||||
> **更新日期**:2026-05-20
|
||||
|
||||
---
|
||||
|
||||
## 一、问题背景
|
||||
|
||||
### 起因
|
||||
内网 Docker 部署后,AI 写作出现两类报错:
|
||||
|
||||
| 接口 | 报错 | 频率 |
|
||||
|---|---|---|
|
||||
| `PUT /api/ai-writing/sessions/{id}/transcript` | HTTP 500 | 经常 |
|
||||
| `POST /api/ai-writing/{id}/resume` | HTTP 4xx(用户记忆为 400,实际可能是 404) | 经常,且没有 Docker 重启时也发生 |
|
||||
|
||||
### 这两个接口的作用
|
||||
|
||||
#### 1. `PUT /sessions/{id}/transcript` — 对话时间线持久化
|
||||
- **职责**:保存前端组装的完整对话时间线(progressEvents + completedInterventions)到 MySQL
|
||||
- **调用时机**:前端在写作过程中**反复 PUT**(事件累计、干预完成、草稿生成等节点全量覆盖)
|
||||
- **存到哪**:MySQL 的 `ai_writing_sessions.transcript` 列(LONGTEXT)
|
||||
- **用途**:用户**关闭页面后再回来 / 刷新 / 看历史会话**时,从 DB 拉回这段时间线,UI 上回显出「跟实时一样的对话历史」
|
||||
- **定位**:**纯锦上添花** — 失败了用户当下写作完全不受影响,只是历史回显会缺失/不完整
|
||||
|
||||
#### 2. `POST /{id}/resume` — 写作流程的核心控制接口
|
||||
- **职责**:提交用户在干预点的决策,让 LangGraph 从挂起的 interrupt 点继续推进
|
||||
- **调用时机**:用户在 4 个 interrupt 点(素材确认 / 大纲确认 / 草稿确认 / 审核确认)做出决定后
|
||||
- **传什么**:用户的决策内容(确认的素材 id、修改后的大纲、用户的修订意见、章节求助决策…)
|
||||
- **定位**:**流程主干** — 它失败,用户就**真的卡死**在干预点
|
||||
|
||||
---
|
||||
|
||||
## 二、根因分析
|
||||
|
||||
### 根因 1:`transcript` 列容量不足 + 路由层无兜底
|
||||
- MySQL `TEXT` 列上限 64 KB
|
||||
- 用户写作过程中累计 `progressEvents`(含每个流式 chunk)+ `completedInterventions` 后,JSON blob 经常超过 64 KB
|
||||
- `session.commit()` 抛 `DataError: Data too long for column 'transcript'`
|
||||
- 原路由没有 try/except,异常直接冒泡成 HTTP 500
|
||||
|
||||
### 根因 2:内存中的会话状态在多 worker 部署下隔离
|
||||
后端用了大量模块级 dict 维护会话状态:
|
||||
|
||||
```python
|
||||
_event_queues: dict[str, asyncio.Queue] # SSE 事件队列
|
||||
_session_pending_pause: dict[str, tuple] # 挂起的 await_user 事件
|
||||
_session_model_names: dict[str, str] # 用户选的模型
|
||||
_session_interventions: dict[str, list] # 累计干预历史
|
||||
_session_repos: dict[str, repo] # DB 仓库引用
|
||||
_session_user_ids: dict[str, str] # 用户 id
|
||||
_session_article_types: dict[str, list] # 文章类型列表
|
||||
```
|
||||
|
||||
**uvicorn / gunicorn 多 worker 是多进程共享 socket**,OS 内核决定哪个 worker 接到请求,应用层和 nginx 都管不着。所以:
|
||||
|
||||
```
|
||||
T0: POST /start → worker A 创建 _event_queues[sid] = QueueA
|
||||
graph 推进任务跑在 worker A
|
||||
T1: GET /stream → 落 worker B,没有 QueueA 的引用
|
||||
→ 触发 _recover_session,从 sqlite 重建 QueueB
|
||||
→ SSE 推 QueueB 但事件都在 QueueA 里
|
||||
T2: POST /resume → 落 worker C,同样无法看到 A/B 的状态
|
||||
```
|
||||
|
||||
**结果**:SSE 时断时续、resume 偶发 404「会话状态已丢失」。
|
||||
|
||||
### 根因 3:Graph 用了内存 checkpointer
|
||||
原代码:
|
||||
```python
|
||||
# ai_writing/graph.py
|
||||
checkpointer = MemorySaver()
|
||||
```
|
||||
进程内的 `MemorySaver` 重启即丢,**容器重启或单 worker 进程被回收**后所有进行中会话都拿不到。
|
||||
|
||||
### 根因 4:路径配错
|
||||
`config.yaml` 里 sqlite 路径写成了 Windows 绝对路径 `D:\cursor-work\...`,Linux 容器里根本无法解析。
|
||||
|
||||
---
|
||||
|
||||
## 三、已落地的修复(已合入 dhk-1 分支)
|
||||
|
||||
### 修复 1:`transcript` 列改 LONGTEXT + 路由兜底
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/persistence/types.py` | 新增 `PortableLongText` 类型(MySQL→LONGTEXT,SQLite/PG→TEXT) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/model.py` | `transcript` 列改成 `PortableLongText` |
|
||||
| `packages/harness/deerflow/persistence/migrations/versions/20260519_02_ai_writing_transcript_longtext.py` | Alembic 迁移:MySQL 上 `ALTER ... type_=LONGTEXT()` |
|
||||
| `app/gateway/routers/ai_writing.py` | `save_transcript` 整段 try/except 包裹,**任何异常都返回 `{success: false, reason}`,永远不抛 5xx** |
|
||||
|
||||
**结果**:transcript 500 永远不会再发生。前端已经做了 fire-and-forget 容错,失败只 `console.warn`,写作流程不阻断。
|
||||
|
||||
### 修复 2:Checkpointer 改为持久化 SQLite
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `packages/harness/deerflow/agents/ai_writing/graph.py` | `MemorySaver()` → 懒加载 `AsyncSqliteSaver` |
|
||||
| 同上 | 新增 `get_ai_writing_graph()` / `get_ai_writing_checkpointer()` / `shutdown_ai_writing_graph()` |
|
||||
| `offline-backend-20260512/config.yaml` | sqlite 路径改成相对路径 `.deer-flow/data/checkpoints.db` |
|
||||
|
||||
**关键容错**:sqlite 初始化失败(磁盘满 / 权限 / aiosqlite 未装 / 损坏)会**自动降级 InMemorySaver**,写作流程能跑完,但跨重启不可恢复。
|
||||
|
||||
### 修复 3:跨 worker / 进程重启会话恢复
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_recover_session(session_id, request)` |
|
||||
|
||||
当 worker 内存里没有该 session 时,从 sqlite checkpointer 读快照,重建 `_event_queues`、重新注入 `await_user` 事件。
|
||||
|
||||
**效果**:worker B 收到的 resume 请求,能从 sqlite 找到 worker A 写下的 checkpoint,**resume 接口本身能跑通**(但 SSE 事件流仍隔离 — 见下文剩余风险)。
|
||||
|
||||
### 修复 4:多层失败兜底,保证写作流程能继续
|
||||
|
||||
| 改动 | 作用 |
|
||||
|---|---|
|
||||
| `_sse(event, data)` 加 `default=str` + notice 帧降级 | JSON 序列化失败不会让 SSE 断开 |
|
||||
| 所有 DB 写入(草稿/审核/干预/status)已包 try/except | DB 异常吞掉,graph 继续推 |
|
||||
| 文章类型加载失败 → 退回 `[]` | intent_parser 仍能跑 |
|
||||
| `/start` 加 503 兜底 | graph 构建失败时给前端可读错误而不是 500 |
|
||||
|
||||
### 修复 5:定期清理 + admin 触发接口
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/ai_writing_cleanup.py` | 新文件:cron 调度 + 联动清理(MySQL 行 + sqlite checkpointer thread) |
|
||||
| `packages/harness/deerflow/persistence/ai_writing_sessions/sql.py` | 新增 `list_older_than()` / `delete_by_ids()` |
|
||||
| `app/gateway/app.py` | lifespan 启动/停止清理任务 |
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `POST /api/ai-writing/admin/cleanup` 手动触发接口(admin only) |
|
||||
| `config.yaml` | 新增 `ai_writing.cleanup` 段(默认每天凌晨 3 点跑,保留 7 天) |
|
||||
|
||||
**关键安全机制**:清理永远跳过 `_event_queues` 里仍在跑的会话(**活跃保护**),即使 admin 手动传 `retention_days: 0` 也不会秒杀正在写作的用户。
|
||||
|
||||
### 修复 6:优雅关闭 — In-flight Drain
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/routers/ai_writing.py` | 新增 `_inflight_graph_tasks` + `_spawn_graph_task()` + `wait_for_inflight_graph_tasks(timeout)` |
|
||||
| `app/gateway/app.py` | lifespan shutdown 顺序:取消清理 → **等 in-flight 写作 task 收尾(30s 超时)** → 关 checkpointer connection |
|
||||
|
||||
容器收到 SIGTERM 时,给正在跑的写作 30 秒窗口跑到下一个 interrupt / END,已生成的 checkpoint 都安全落盘。
|
||||
|
||||
### 修复 7:多 worker 配置陷阱检测
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `app/gateway/app.py` | lifespan startup 检测 `WEB_CONCURRENCY > 1` 或 `/proc` 里同名 uvicorn 进程数 > 1 → 打 ERROR 日志 |
|
||||
|
||||
下次启动容器看 docker logs 第一屏就能看到强提醒。
|
||||
|
||||
---
|
||||
|
||||
## 四、当前状态评估
|
||||
|
||||
### transcript 500
|
||||
✅ **彻底修复**,跟 worker 数无关。LONGTEXT 列 + 路由兜底双保险。
|
||||
|
||||
### resume 报错
|
||||
⚠️ **大幅缓解**:
|
||||
- 接口本身(在 worker > 1 下)能跑通
|
||||
- sqlite 共享让 worker B 也能恢复 worker A 创建的 session
|
||||
|
||||
❌ **未彻底解决**:
|
||||
- SSE 事件流仍 per-worker 隔离
|
||||
- 用户可能看到「进度时有时无、需要刷新页面才能看到草稿」
|
||||
- 但**草稿、审核结果**都落到 MySQL,刷新 `/sessions/{id}` 总能看到最终成品
|
||||
|
||||
### 「写作流程能不能跑完」
|
||||
✅ **能**。最坏情况下:
|
||||
- 用户感受:进度断断续续,需要刷新几次
|
||||
- 数据保证:草稿和审核都落 MySQL,**写出来的内容不会丢**
|
||||
|
||||
---
|
||||
|
||||
## 五、剩余风险
|
||||
|
||||
### 风险 A:多 worker 下 SSE 事件流隔离 ⚠️ 最重要
|
||||
|
||||
**症状**:用户 SSE 长连接绑在 worker B,但 graph 推进任务跑在 worker A,事件 put 进 A 的 queue → SSE 收不到。
|
||||
|
||||
**为什么不能用 `--workers 1` 解决**:业务团队反馈其他业务功能需要多 worker。
|
||||
|
||||
**解决方案见第六章**。
|
||||
|
||||
### 风险 B:容器优雅关闭时 in-flight 写作进度
|
||||
|
||||
**当前缓解**:30 秒 drain timeout 让 graph 跑到下一个 checkpoint。
|
||||
|
||||
**完美方案**(如有需求):无法 100% 防护,只能尽量缩短「未落 checkpoint」窗口。
|
||||
|
||||
### 风险 C:节点级别 LLM 抽风
|
||||
|
||||
某个写作节点调用 LLM 时网络抖动 / 限流 → astream 抛错 → session 结束。
|
||||
|
||||
**当前缓解**:astream level 已 try/except,推 error 事件。用户从 checkpoint 重试。
|
||||
|
||||
**改进方向**(不在本次修复范围):
|
||||
- 给节点加 `tenacity.retry` 重试装饰器
|
||||
- 节点抛错时把已生成的部分塞进 progress_events
|
||||
|
||||
### 风险 D:跨 worker 恢复时降级丢失
|
||||
|
||||
worker B 从 sqlite 恢复 worker A 的 session 时,以下数据回退到默认:
|
||||
|
||||
| 数据 | 默认值 | 影响 |
|
||||
|---|---|---|
|
||||
| `_session_model_names` | `""` | 退回默认模型 |
|
||||
| `_session_interventions` | `[]` | 本次 resume 之前的干预历史从内存里丢(MySQL 里有副本,但内存累计不全) |
|
||||
| `_session_article_types` | `[]` | 没影响(graph 早过了 intent_parser) |
|
||||
|
||||
**改进方向**:恢复时从 MySQL 反向填充内存元数据(约 20 行代码)。
|
||||
|
||||
---
|
||||
|
||||
## 六、Worker > 1 限制下的解决方案路径
|
||||
|
||||
### 三条路径对比
|
||||
|
||||
| 方案 | 是否引入新依赖 | 代码改动 | 运维改动 | 适用场景 |
|
||||
|---|---|---|---|---|
|
||||
| **B. Redis Pub/Sub 共享状态** | ⚠️ 需 Redis | ~150 行 | docker-compose 加 redis | 已有 Redis 或愿意上 |
|
||||
| **C. AI 写作独立单 worker 进程** | 无 | ~30 行 | docker-compose 加一个容器 / 进程 + nginx 转发 | 不想上 Redis |
|
||||
| **F. 多容器单 worker + nginx sticky** | 无 | ~5 行(nginx config) | 把 1 个多 worker 容器拆成 N 个单 worker 容器 | 已经在做容器编排 |
|
||||
|
||||
### 方案 B:Redis Pub/Sub(最彻底)
|
||||
|
||||
**改造点**:
|
||||
|
||||
```
|
||||
之前(per-worker memory dict) 之后(Redis 共享)
|
||||
───────────────────────── ─────────────────────
|
||||
_event_queues[sid] = asyncio.Queue() → Redis Pub/Sub channel "ai_writing:events:{sid}"
|
||||
_session_pending_pause → Redis Hash "ai_writing:pause"
|
||||
_session_model_names → Redis Hash "ai_writing:meta:model"
|
||||
_session_interventions → Redis Hash + JSON
|
||||
_session_repos / _session_user_ids → Redis Hash
|
||||
```
|
||||
|
||||
**关键设计**:
|
||||
1. `/start` 在 worker A:graph 推进任务跑在 A,事件 publish 到 Redis channel
|
||||
2. `/stream` 在 worker B:subscribe Redis channel,把消息转 SSE 推前端
|
||||
3. `/resume` 在 worker C:用 Redis 分布式锁保证「同一 session 同时只有一个 worker 驱动 graph」
|
||||
|
||||
**优点**:根治。任何 worker 数、任何部署形态都能跑。
|
||||
|
||||
**缺点**:
|
||||
- 引入 Redis 依赖(运维负担)
|
||||
- 代码改动较大(约 150 行)
|
||||
- 需要处理 Redis 断连 / 分布式锁释放等边缘情况
|
||||
|
||||
### 方案 C:AI 写作独立服务(次推荐)
|
||||
|
||||
**做法**:在同一个容器里多起一个 uvicorn 进程,专门处理 `/api/ai-writing/*`,**显式 `--workers 1`**。
|
||||
|
||||
```bash
|
||||
# 主 gateway(多 worker,处理其他业务)
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8001 --workers 4
|
||||
|
||||
# AI 写作专用单 worker
|
||||
uvicorn app.gateway.app:app --host 0.0.0.0 --port 8002 --workers 1
|
||||
```
|
||||
|
||||
nginx 配置:
|
||||
```nginx
|
||||
location /api/ai-writing/ {
|
||||
proxy_pass http://localhost:8002;
|
||||
proxy_buffering off;
|
||||
proxy_read_timeout 3600s;
|
||||
}
|
||||
|
||||
location / {
|
||||
proxy_pass http://localhost:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:
|
||||
- 零代码改动
|
||||
- 不引入新依赖
|
||||
- AI 写作彻底单 worker,问题根除
|
||||
|
||||
**缺点**:
|
||||
- 容器内多一个进程
|
||||
- 内存占用 +1 份 Python runtime(约 200 MB)
|
||||
|
||||
### 方案 F:多容器单 worker + nginx sticky
|
||||
|
||||
**做法**:把当前 1 个 4 worker 容器,拆成 4 个 1 worker 容器,nginx 用 `ip_hash` 做 sticky。
|
||||
|
||||
```yaml
|
||||
# docker-compose.yml
|
||||
services:
|
||||
gateway-1: { ports: ["8001:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-2: { ports: ["8002:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-3: { ports: ["8003:8001"], command: ["...", "--workers", "1"] }
|
||||
gateway-4: { ports: ["8004:8001"], command: ["...", "--workers", "1"] }
|
||||
|
||||
nginx:
|
||||
# upstream 配 ip_hash,让同一个客户端粘到同一个容器
|
||||
```
|
||||
|
||||
```nginx
|
||||
upstream gateway_pool {
|
||||
ip_hash;
|
||||
server gateway-1:8001;
|
||||
server gateway-2:8001;
|
||||
server gateway-3:8001;
|
||||
server gateway-4:8001;
|
||||
}
|
||||
```
|
||||
|
||||
**优点**:零代码改动,标准做法
|
||||
|
||||
**缺点**:
|
||||
- 改动 docker-compose
|
||||
- 客户端 IP 不均匀时负载不均衡(但 AI 写作低并发场景影响小)
|
||||
|
||||
### 推荐顺序
|
||||
|
||||
1. **方案 C(AI 写作独立单 worker 进程)** — 改动最小、风险最低、立刻可上
|
||||
2. **方案 F(多容器 sticky)** — 如果你们已经在做多容器编排
|
||||
3. **方案 B(Redis)** — 长期方案,等真正需要水平扩容时再上
|
||||
|
||||
---
|
||||
|
||||
## 七、运维操作清单
|
||||
|
||||
### 部署前验证
|
||||
|
||||
```bash
|
||||
# 1) 确认 .deer-flow 数据卷挂出来了(持久化的命脉)
|
||||
docker exec <gateway> ls -la /app/.deer-flow/data/
|
||||
# 应该能看到 checkpoints.db 文件(首次启动后会自动创建)
|
||||
|
||||
# 2) 确认 alembic 迁移跑完了
|
||||
docker exec <gateway> sh -c "cd /app/backend && PYTHONPATH=. uv run alembic current"
|
||||
# 应该看到包含 20260519_02 的 head
|
||||
|
||||
# 3) 确认 transcript 列已扩容
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SHOW COLUMNS FROM ai_writing_sessions LIKE 'transcript';"
|
||||
# Type 列应该是 longtext
|
||||
|
||||
# 4) 查 worker 数
|
||||
docker exec <gateway> ps -ef | grep -E 'uvicorn|gunicorn'
|
||||
# 如果 > 1,且没做任何方案 C/F/B,启动日志会打 ERROR 警告
|
||||
```
|
||||
|
||||
### 启动时关注的日志
|
||||
|
||||
```bash
|
||||
docker logs <gateway> 2>&1 | head -100 | grep -iE 'ai_writing|checkpointer|worker'
|
||||
```
|
||||
|
||||
期望看到:
|
||||
- `ai_writing: 使用 AsyncSqliteSaver (xxx.db)` — 持久化生效
|
||||
- `ai_writing_cleanup: 已启动 cron=...` — 清理任务起来了
|
||||
|
||||
警告:
|
||||
- `⚠ 检测到 WEB_CONCURRENCY=N` — 多 worker 配置警告
|
||||
- `ai_writing: 持久化 checkpointer 不可用,退回 InMemorySaver` — sqlite 初始化失败,需立刻排查
|
||||
|
||||
### 用户报告「会话丢失」时的排查
|
||||
|
||||
```bash
|
||||
# 1) 查 sqlite 里这个 session 的快照
|
||||
docker exec <gateway> sh -c "sqlite3 /app/.deer-flow/data/checkpoints.db \
|
||||
\"SELECT thread_id, checkpoint_ns FROM checkpoints WHERE thread_id='<session_id>' LIMIT 5;\""
|
||||
|
||||
# 2) 查 MySQL 里这条会话
|
||||
docker exec <mysql> mysql -uroot -p<pwd> deerflow -e \
|
||||
"SELECT id, status, draft_title, updated_at FROM ai_writing_sessions WHERE id='<session_id>';"
|
||||
|
||||
# 3) 看 gateway 日志里这个 session 的活动轨迹
|
||||
docker logs <gateway> 2>&1 | grep '<session_id>' | tail -50
|
||||
|
||||
# 4) 看是否有 recover_session 调用
|
||||
docker logs <gateway> 2>&1 | grep 'ai_writing: recovered' | tail -20
|
||||
|
||||
# 5) 看是否有节点异常
|
||||
docker logs <gateway> 2>&1 | grep -E 'ai_writing graph segment failed|ai_writing.*error' | tail -20
|
||||
```
|
||||
|
||||
### 手动触发清理(admin only)
|
||||
|
||||
```bash
|
||||
# 用 admin 账号登录后,取 cookie,调清理接口
|
||||
curl -X POST 'http://<gateway>/api/ai-writing/admin/cleanup' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Cookie: <admin session cookie>' \
|
||||
-d '{"retention_days": 7}'
|
||||
```
|
||||
|
||||
返回:
|
||||
```json
|
||||
{
|
||||
"deleted_db": 12,
|
||||
"deleted_checkpoints": 12,
|
||||
"skipped_active": 1,
|
||||
"elapsed_seconds": 0.234,
|
||||
"params": { "retention_days": 7, "only_finished": false, "batch_size": 500 }
|
||||
}
|
||||
```
|
||||
|
||||
`skipped_active` > 0 表示有正在跑的会话被活跃保护拦下来,不会被误删。
|
||||
|
||||
---
|
||||
|
||||
## 八、相关文件索引
|
||||
|
||||
### 后端
|
||||
- `offline-backend-20260512/backend/app/gateway/routers/ai_writing.py` — 路由 + 内存状态字典 + drain
|
||||
- `offline-backend-20260512/backend/app/gateway/ai_writing_cleanup.py` — 定期清理
|
||||
- `offline-backend-20260512/backend/app/gateway/app.py` — lifespan / 多 worker 检测
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py` — Graph 定义 + 持久化 checkpointer
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/ai_writing_sessions/` — MySQL repo
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/types.py` — `PortableLongText`
|
||||
- `offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260519_02_*.py` — LONGTEXT 迁移
|
||||
- `offline-backend-20260512/config.yaml` — `checkpointer:` + `ai_writing.cleanup:` 配置
|
||||
|
||||
### 前端
|
||||
- `frontend-web/src/open-canvas/pages/AIWritingPage.tsx` — 页面顶部有快速排查注释
|
||||
- `frontend-web/src/open-canvas/api/ai-writing-sessions.ts` — transcript 保存(已做 fire-and-forget 容错)
|
||||
- `frontend-web/src/open-canvas/api/ai-writing.ts` — resume / stream
|
||||
|
||||
### 测试
|
||||
- `tests/test_ai_writing_cleanup.py` — 清理 + drain + checkpointer 降级 + SSE 序列化(21 个测试)
|
||||
- `tests/test_ai_writing_session_recovery.py` — 跨 worker 会话恢复(8 个测试)
|
||||
- `tests/test_ai_writing_session_repo.py` — Repo(7 个测试)
|
||||
- `tests/test_ai_writing_transcript_endpoint.py` — transcript 兜底(5 个测试)
|
||||
- `tests/test_portable_long_text.py` — LONGTEXT 列类型(5 个测试)
|
||||
|
||||
总测试:**51 个,全过**。
|
||||
|
||||
---
|
||||
|
||||
## 九、变更历史
|
||||
|
||||
| 日期 | Commit | 内容 |
|
||||
|---|---|---|
|
||||
| 2026-05-19 | c3d0c82 | 报错兜底(transcript LONGTEXT 迁移 + 路由 try/except + session 恢复) |
|
||||
| 2026-05-20 | (本次) | checkpointer 持久化 + 定期清理 + admin 接口 + 多层兜底 + drain + 多 worker 检测 |
|
||||
|
||||
---
|
||||
|
||||
## 十、决策摘要(给未来的你/同事)
|
||||
|
||||
> **如果你现在第一次接手这个问题**,按这个清单往下走:
|
||||
>
|
||||
> 1. ✅ **transcript 500 不用管** — 已彻底修复
|
||||
> 2. ⚠️ **resume 报错的根因是多 worker** — sqlite 持久化已减轻,但 SSE 事件流仍隔离
|
||||
> 3. ⚠️ **写作能跑完,但用户体验断断续续** — 草稿数据安全,刷新就能看到
|
||||
> 4. 🎯 **要彻底解决,从方案 C / F / B 选一个**(建议从 C 开始)
|
||||
> 5. 🚨 **务必确认 `.deer-flow` 数据卷挂出来了** — 否则 sqlite 持久化白做
|
||||
> 6. 📊 **关注的日志关键字**:`ai_writing:`、`checkpointer`、`AsyncSqliteSaver`、`WEB_CONCURRENCY`
|
||||
|
||||
如有疑问,先看本文件的「七、运维操作清单」做基础排查,再问当前维护者。
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,260 +1,312 @@
|
||||
# Open Canvas 集成进度文档
|
||||
|
||||
> 更新时间:2026-05-16
|
||||
> 最后更新:2026-05-16
|
||||
> 当前分支:dhk-1
|
||||
|
||||
---
|
||||
|
||||
## 一、整体进度概览
|
||||
|
||||
### Canvas 系统(双栏写作/编码工作区)
|
||||
|
||||
| 阶段 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| 前端阶段一 | 路由与目录骨架 | ✅ 已完成 |
|
||||
| 前端阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 前端阶段三 | 类型与状态协议 | ✅ 已完成(随阶段一一并完成) |
|
||||
| 后端阶段一 | ThreadState 扩展 | ✅ 已完成 |
|
||||
| 后端阶段二 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 前端阶段四 | API 适配层 | ✅ 已完成 |
|
||||
| 前端阶段五 | 线程与流式状态接入 | ✅ 已完成 |
|
||||
| 前端阶段六 | Artifact 渲染器增强(CodeMirror / Markdown) | ✅ 已完成 |
|
||||
| 前后端阶段七 | 局部选区改写 | ✅ 已完成 |
|
||||
| 前后端阶段八 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 前后端阶段九 | 本地模型支持完善 | ✅ 已完成 |
|
||||
| 阶段一 | 前端路由与目录骨架 | ✅ 已完成 |
|
||||
| 阶段二 | Canvas 双栏布局 | ✅ 已完成 |
|
||||
| 阶段三 | 前端 artifact 类型定义 | ✅ 已完成 |
|
||||
| 阶段四 | 后端 ThreadState 扩展 | ✅ 已完成 |
|
||||
| 阶段五 | canvas_agent 注册 + 最小节点闭环 | ✅ 已完成 |
|
||||
| 阶段六 | 前后端流式同步 | ✅ 已完成 |
|
||||
| 阶段七 | 版本历史导航 | ✅ 已完成 |
|
||||
| 阶段八 | 局部选区改写(Writing Editor 级) | ✅ 已完成 |
|
||||
| 阶段九 | 快捷动作系统 | ✅ 已完成 |
|
||||
| 阶段十 | 本地模型选择支持 | ✅ 已完成 |
|
||||
|
||||
**当前状态**:全部 9 个阶段已完成 🎉
|
||||
### AI Writing Editor(文本 artifact 编辑器内置 AI 改写)
|
||||
|
||||
| 步骤 | 名称 | 状态 |
|
||||
|------|------|------|
|
||||
| Step 1 | Tiptap 编辑器核心 | ✅ 已完成 |
|
||||
| Step 2 | 写作工具栏 + 模型选择器 | ✅ 已完成 |
|
||||
| Step 3a | 气泡菜单(选区 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 3b | 段落悬停按钮(整段 AI 改写入口) | ✅ 已完成 |
|
||||
| Step 4 | AI 改写菜单 + SSE 流式接口 | ✅ 已完成 |
|
||||
| Step 5 | 候选结果预览 + 替换/插入 | ✅ 已完成 |
|
||||
| Step 6 | 后端 `POST /api/writing/rewrite`(SSE,思考分离) | ✅ 已完成 |
|
||||
| Step 7 | 视觉精修(字体、间距、排版) | ✅ 已完成 |
|
||||
|
||||
**当前状态:全部功能已完成 🎉**
|
||||
|
||||
---
|
||||
|
||||
## 二、已完成工作详情
|
||||
|
||||
### 2.1 前端路由接入
|
||||
|
||||
修改了 `frontend-web/src/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 个文件)
|
||||
### 2.1 前端核心文件结构
|
||||
|
||||
```
|
||||
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_*
|
||||
├── api/
|
||||
│ ├── client.ts # LangGraph SDK 客户端实例
|
||||
│ ├── models.ts # 模型列表 API
|
||||
│ ├── rewrite.ts # AI 改写 SSE 流(streamRewrite,parseSse)
|
||||
│ ├── runs.ts # streamCanvasRun(老的直接 API,已停用)
|
||||
│ └── threads.ts # createCanvasThread、getCanvasThread
|
||||
├── components/
|
||||
│ ├── artifact/
|
||||
│ │ ├── CanvasArtifactHeader.tsx # 右栏顶部:标题、语言、版本导航
|
||||
│ │ ├── CanvasArtifactPanel.tsx # 右栏:流式预览 / 编辑器 / 空状态
|
||||
│ │ ├── CanvasArtifactVersionNav.tsx # prev/next 版本切换
|
||||
│ │ ├── CanvasEmptyState.tsx # 5 种快速开始模板
|
||||
│ │ ├── CanvasQuickActionBar.tsx # 快捷动作按钮栏
|
||||
│ │ ├── CodeArtifactRenderer.tsx # CodeMirror 代码编辑器
|
||||
│ │ ├── TextArtifactRenderer.tsx # Tiptap 文本编辑器 + AI 改写流程
|
||||
│ │ ├── WritingAiMenu.tsx # AI 改写动作菜单(polish/expand/shorten/tone)
|
||||
│ │ ├── WritingBubbleMenu.tsx # 选区气泡菜单(portal 实现)
|
||||
│ │ ├── WritingParagraphMenu.tsx # 段落悬停 AI 改写按钮
|
||||
│ │ ├── WritingRewritePreview.tsx # 改写结果预览面板(含思考过程展开)
|
||||
│ │ └── WritingToolbar.tsx # 格式工具栏(含模型选择器)
|
||||
│ ├── chat/
|
||||
│ │ └── CanvasChatPanel.tsx # 左栏:消息列表 + 输入框
|
||||
│ ├── layout/
|
||||
│ │ ├── CanvasHeader.tsx # 顶部栏:返回、折叠、流式指示
|
||||
│ │ ├── CanvasResizableLayout.tsx # 左右可拖拽双栏(含 CanvasStreamProvider)
|
||||
│ │ └── CanvasShell.tsx # 顶层壳:加载已有线程状态
|
||||
│ └── selectors/
|
||||
│ └── CanvasModelSelector.tsx # 模型下拉选择器
|
||||
├── contexts/
|
||||
│ ├── CanvasGraphContext.tsx # artifact、streamingArtifact、isStreaming
|
||||
│ ├── CanvasStreamContext.tsx # 共享 useCanvasThreadStream 实例(sendMessage)
|
||||
│ ├── CanvasThreadContext.tsx # threadId、modelName
|
||||
│ └── CanvasUIContext.tsx # isChatCollapsed、writingSelection
|
||||
├── editor/
|
||||
│ ├── TiptapEditor.tsx # EditorContent 包装器
|
||||
│ └── TiptapExtensions.ts # StarterKit + Underline + Placeholder + Markdown
|
||||
├── hooks/
|
||||
│ ├── useCanvasArtifact.ts # artifact 初始化工具
|
||||
│ ├── useCanvasQuickActions.ts # 快捷动作触发(走 CanvasStreamContext)
|
||||
│ ├── useCanvasSelection.ts # 文字/代码选区状态
|
||||
│ ├── useCanvasStream.ts # 旧直接 API 流(已停用,保留备用)
|
||||
│ ├── useCanvasThread.ts # 线程加载工具
|
||||
│ ├── useCanvasThreadStream.ts # LangGraph SDK useStream hook(主流)
|
||||
│ ├── useWritingEditor.ts # Tiptap editor 实例管理
|
||||
│ └── useWritingRewrite.ts # AI 改写状态机(ThinkTagParser、AbortController)
|
||||
├── pages/
|
||||
│ ├── CanvasHomePage.tsx # 欢迎页 + 快速开始
|
||||
│ └── CanvasThreadPage.tsx # Provider 装配层
|
||||
├── routes/
|
||||
│ └── OpenCanvasRoutes.tsx # /page/canvas/* 子路由
|
||||
├── types/
|
||||
│ ├── artifact.ts # CanvasArtifact、CanvasArtifactText、CanvasArtifactCode
|
||||
│ ├── model.ts # CanvasModel
|
||||
│ ├── quick-action.ts # QuickAction、QuickActionId
|
||||
│ ├── selection.ts # CanvasTextSelection、CanvasCodeSelection
|
||||
│ └── thread.ts # CanvasMessage、CanvasThreadValues
|
||||
└── utils/
|
||||
├── apply-rewrite.ts # applyReplace、applyInsert(Tiptap 事务)
|
||||
├── artifact.ts # getCurrentArtifactContent、setCurrentArtifactIndex 等
|
||||
├── quick-actions.ts # buildQuickActionMessage(含 artifact 内容注入)
|
||||
├── selection.ts # buildMessageWithSelection
|
||||
├── stream.ts # 流式工具
|
||||
└── thread.ts # 线程工具
|
||||
```
|
||||
|
||||
### 2.3 核心类型定义
|
||||
### 2.2 后端核心文件结构
|
||||
|
||||
```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;
|
||||
};
|
||||
```
|
||||
offline-backend-20260512/backend/
|
||||
├── packages/harness/deerflow/agents/
|
||||
│ ├── canvas_agent/
|
||||
│ │ ├── __init__.py
|
||||
│ │ ├── agent.py # make_canvas_agent():LangGraph graph 工厂
|
||||
│ │ ├── prompt.py # build_canvas_system_prompt():含 selection_rewrite + quick_actions
|
||||
│ │ └── tools.py # update_artifact_tool:创建/追加 artifact 版本
|
||||
│ └── thread_state.py # ThreadState(含 artifact 字段、CanvasArtifact TypedDict)
|
||||
└── app/gateway/
|
||||
├── app.py # 注册 writing router
|
||||
└── routers/
|
||||
└── writing.py # POST /api/writing/rewrite(SSE 流式)
|
||||
```
|
||||
|
||||
### 2.4 Context 结构
|
||||
**langgraph.json** 注册了两个 graph:
|
||||
- `lead_agent` → 主聊天 Agent
|
||||
- `canvas_agent` → Canvas 写作 Agent
|
||||
|
||||
```tsx
|
||||
// CanvasThreadPage.tsx 的装配顺序
|
||||
<CanvasThreadProvider initialThreadId={thread_id}>
|
||||
<CanvasGraphProvider>
|
||||
<CanvasUIProvider>
|
||||
<CanvasShell />
|
||||
</CanvasUIProvider>
|
||||
</CanvasGraphProvider>
|
||||
</CanvasThreadProvider>
|
||||
### 2.3 关键 Context 层级
|
||||
|
||||
```
|
||||
CanvasThreadPage
|
||||
├── CanvasThreadProvider # threadId、modelName
|
||||
│ ├── CanvasGraphProvider # artifact、streamingArtifact、isStreaming
|
||||
│ │ └── CanvasUIProvider # isChatCollapsed、writingSelection
|
||||
│ │ └── CanvasShell
|
||||
│ │ └── CanvasResizableLayout
|
||||
│ │ └── CanvasStreamProvider ← 持有 useCanvasThreadStream 单例
|
||||
│ │ ├── CanvasChatPanel # 读 useCanvasStreamContext()
|
||||
│ │ └── CanvasArtifactPanel
|
||||
│ │ └── CanvasQuickActionBar # 读 useCanvasStreamContext()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、已解决的技术难题
|
||||
## 三、关键架构决策与注意事项
|
||||
|
||||
### 3.1 react-resizable-panels v4.6.2 API 差异
|
||||
### 3.1 流式架构:单例流(重要)
|
||||
|
||||
此版本与旧版 API 不同,必须使用:
|
||||
**问题**:原先 `CanvasChatPanel` 调 `useCanvasThreadStream()`,`CanvasQuickActionBar` 调 `useCanvasStream()`(旧的直接 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 高度链(防止底部内容被裁剪)
|
||||
**解决方案**:新增 `CanvasStreamContext`,在 `CanvasResizableLayout` 中调用 `useCanvasThreadStream()` 一次,通过 Context 共享给所有子组件。`CanvasChatPanel` 和 `useCanvasQuickActions` 都从 `useCanvasStreamContext()` 取 `sendMessage`。
|
||||
|
||||
```
|
||||
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)
|
||||
useCanvasStream(旧,已停用) ← 不再被调用,保留备用
|
||||
useCanvasThreadStream(新,LangGraph SDK) ← 通过 CanvasStreamContext 统一共享
|
||||
```
|
||||
|
||||
**`min-h-0` 是必须的**,否则 flex 子项不会收缩,底部内容被推出屏幕外。
|
||||
### 3.2 artifact 内容注入机制(重要)
|
||||
|
||||
### 3.3 折叠/展开按钮
|
||||
**问题**:LangGraph `create_agent` 只把 `state.messages` 传给 LLM,`state.artifact` 字段不会自动可见。Agent 做 expand/shorten 等操作时看不到要改写的内容。
|
||||
|
||||
`CanvasHeader.tsx` 中通过 `useCanvasUIContext()` 控制 `isChatCollapsed`,
|
||||
`CanvasResizableLayout.tsx` 中用 `useEffect` 监听并调用 `setLayout`:
|
||||
**解决方案**:在每次 `sendMessage` 时,把当前 artifact 内容以 `<artifact>` 标签嵌入人类消息末尾。
|
||||
|
||||
```tsx
|
||||
useEffect(() => {
|
||||
layoutRef.current?.setLayout(
|
||||
isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 }
|
||||
);
|
||||
}, [isChatCollapsed]);
|
||||
- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `<artifact>` 时)
|
||||
- 快捷动作:`buildQuickActionMessage(id, content)` 在构建时就包含内容
|
||||
- 聊天面板显示时:`message-list-item.tsx` 调 `stripArtifactTag()` 剥掉标签,用户看不到
|
||||
|
||||
```typescript
|
||||
// utils/quick-actions.ts
|
||||
export function buildQuickActionMessage(id, content) {
|
||||
const body = isCodeArtifact(content) ? content.code : content.fullMarkdown;
|
||||
return `${instruction}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
|
||||
}
|
||||
|
||||
// core/messages/utils.ts
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "").trim();
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 ThinkTagParser(AI 改写思考过程解析)
|
||||
|
||||
后端支持两种思考格式:
|
||||
- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输
|
||||
- DeepSeek:把思考过程内联在文本里:`<think>...</think>content`
|
||||
|
||||
前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `<think>` 标签。
|
||||
|
||||
### 3.4 版本历史:本地切换
|
||||
|
||||
`CanvasArtifactVersionNav` 的 prev/next 只修改本地 `CanvasGraphContext.artifact.currentIndex`,不写回后端。刷新后 currentIndex 重置为后端存储的最新值(这是有意的简化设计)。
|
||||
|
||||
### 3.5 Writing Editor 流程(三阶段状态机)
|
||||
|
||||
```
|
||||
idle → menu → preview → idle
|
||||
↑ ↓
|
||||
openAiMenu closeAll
|
||||
```
|
||||
|
||||
- **idle**:正常编辑
|
||||
- **menu**:显示 `WritingAiMenu`(选择 polish/expand/shorten/tone)
|
||||
- **preview**:显示 `WritingRewritePreview`(SSE 流式结果 + 思考过程 + 替换/插入按钮)
|
||||
|
||||
---
|
||||
|
||||
## 四、下一步:后端 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 注册位置 |
|
||||
| react-resizable-panels v4 API 变更(`orientation` 不是 `direction`,`groupRef` 不是 `ref`,`setLayout` 参数为 `{id: size}` 对象) | 参考 v4 文档,逐一修正 |
|
||||
| Flex 高度链底部裁剪 | 在 wrapper div 加 `min-h-0`,配合 `flex-1 overflow-hidden` |
|
||||
| Tiptap v3 BubbleMenu 已从 React 组件改为纯 Extension | 自定义 portal 实现:监听 `selectionUpdate`,手动计算位置 |
|
||||
| 段落悬停按钮鼠标桥接:鼠标经过 6px 间隙时按钮消失 | 文档级 `mousemove` 监听 + 380ms 延迟隐藏,判断鼠标是否在扩展热区内 |
|
||||
| 弹窗超出屏幕底部 | 检测 `spaceBelow` vs `spaceAbove`,空间不足时翻转到光标上方 |
|
||||
| DeepSeek 在文本流里混入 `<think>` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 |
|
||||
| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state |
|
||||
| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 |
|
||||
| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `<artifact>` 标签,显示时剥离 |
|
||||
| `<artifact>` 标签在聊天面板里显示 | `message-list-item.tsx` 调 `stripArtifactTag()` |
|
||||
|
||||
---
|
||||
|
||||
## 六、架构决策记录
|
||||
## 五、当前已知限制(非 bug,设计取舍)
|
||||
|
||||
| 决策 | 内容 |
|
||||
1. **版本切换不持久化**:切换 artifact 版本后刷新页面会回到最新版本
|
||||
2. **useCanvasStream 孤立**:旧的直接 API 流 hook 保留但未被调用,可后续清理
|
||||
3. **CanvasShell 的 setMessages 冗余**:加载旧线程时 `setMessages` 写入 CanvasGraphContext,但 `CanvasChatPanel` 读的是 `thread.messages`(来自 useStream + fetchStateHistory)。setArtifact 仍有效
|
||||
4. **artifact 内容随消息发送**:大文档每条消息都附带全文,token 消耗较大。当前规模可接受
|
||||
|
||||
---
|
||||
|
||||
## 六、快速开始下一次工作
|
||||
|
||||
### 启动服务
|
||||
|
||||
```bash
|
||||
# 后端(从 offline-backend-20260512/backend/)
|
||||
make dev # 启动 Gateway API on port 8001
|
||||
|
||||
# 前端(从 frontend-web/)
|
||||
pnpm dev # 启动 Vite on port 5174
|
||||
```
|
||||
|
||||
### 访问路径
|
||||
|
||||
- 主页:`http://localhost:5174`
|
||||
- Canvas 入口:点击左侧导航 → Canvas,或直接访问 `http://localhost:5174/page/canvas`
|
||||
|
||||
### 类型检查
|
||||
|
||||
```bash
|
||||
cd frontend-web && pnpm typecheck
|
||||
# 预期:只有 CodeArtifactRenderer.tsx 的 CodeMirror 版本冲突错误(预存 bug,不影响运行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、如需继续开发的潜在方向
|
||||
|
||||
| 方向 | 描述 | 优先级 |
|
||||
|------|------|------|
|
||||
| 版本切换持久化 | 将 currentIndex 变更写回后端 ThreadState | 低 |
|
||||
| artifact 内容 token 优化 | 仅发送 diff 或用 summary 代替全文 | 低 |
|
||||
| 清理 useCanvasStream | 删除未被调用的旧 hook | 低 |
|
||||
| 代码 artifact 的 AI 改写 | CodeArtifactRenderer 也接入 WritingAiMenu 流程 | 中 |
|
||||
| Canvas 选区→聊天(高级) | 用户在右栏选中文字后在左栏输入指令,自动带选区上下文 | 中 |
|
||||
| Tiptap 扩展(表格、任务列表) | 增加 Table、TaskList extension | 低 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关键文件速查
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| 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 级局部流 |
|
||||
| `frontend-web/docs/openCanvas-progress.md` | **本文档** |
|
||||
| `frontend-web/src/open-canvas/contexts/CanvasStreamContext.tsx` | 流单例 Context |
|
||||
| `frontend-web/src/open-canvas/hooks/useCanvasThreadStream.ts` | 主流 hook(LangGraph SDK) |
|
||||
| `frontend-web/src/open-canvas/hooks/useWritingRewrite.ts` | AI 改写状态机 |
|
||||
| `frontend-web/src/open-canvas/api/rewrite.ts` | 改写 SSE 客户端 |
|
||||
| `frontend-web/src/open-canvas/utils/quick-actions.ts` | 快捷动作消息构建 |
|
||||
| `frontend-web/src/core/messages/utils.ts` | `stripArtifactTag` 在此 |
|
||||
| `frontend-web/src/components/workspace/messages/message-list-item.tsx` | 调用 stripArtifactTag 的位置 |
|
||||
| `frontend-web/src/styles/globals.css` | Tiptap 写作编辑器样式(`.tiptap-writing-editor`) |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/agent.py` | canvas_agent 工厂 |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/tools.py` | update_artifact_tool |
|
||||
| `offline-backend-20260512/backend/packages/harness/deerflow/agents/canvas_agent/prompt.py` | 系统提示词 |
|
||||
| `offline-backend-20260512/backend/app/gateway/routers/writing.py` | AI 改写后端接口 |
|
||||
| `offline-backend-20260512/backend/langgraph.json` | canvas_agent 注册 |
|
||||
|
||||
---
|
||||
|
||||
## 七、开始新对话的推荐提示词
|
||||
## 九、开始新对话的推荐提示词
|
||||
|
||||
```
|
||||
我们在集成 open-canvas 到 DeerFlow 项目。
|
||||
参考进度文档:frontend-web/docs/openCanvas-progress.md
|
||||
目前前端阶段一/二/三已完成,下一步是后端 ThreadState 扩展。
|
||||
请从第四节"下一步:后端 ThreadState 扩展"开始继续。
|
||||
我们在开发 DeerFlow 的 Canvas 写作模式(open-canvas 集成)。
|
||||
详细进度见:frontend-web/docs/openCanvas-progress.md
|
||||
|
||||
当前全部功能已完成,包括:
|
||||
- Canvas 双栏布局、canvas_agent、版本历史
|
||||
- AI Writing Editor(Tiptap + SSE 改写 + 思考过程展开)
|
||||
- 快捷动作(通过 CanvasStreamContext 统一流)
|
||||
- artifact 内容注入(消息里嵌入 <artifact> 标签,显示时剥离)
|
||||
|
||||
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
|
||||
```
|
||||
@@ -0,0 +1,109 @@
|
||||
# AI 智能写作 — 手工测试清单
|
||||
|
||||
> 测试环境:后端 `http://localhost:8001`,前端 `http://localhost:5174`
|
||||
> 每项测试前确认后端已启动(`make dev`)、前端已启动(`pnpm dev`)
|
||||
|
||||
---
|
||||
|
||||
## T1 — 启动写作任务
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 1.1 | 打开 Canvas 页面,点击右上角「AI 写作」按钮 | AIWritingPanel 在右侧展开,显示写作配置表单 |
|
||||
| 1.2 | 填写:意图「新能源汽车市场趋势分析」,类型「分析报告」,目标读者「行业研究人员」,字数「1200」 | 表单验证通过,提交按钮可点击 |
|
||||
| 1.3 | 点击「开始写作」 | 按钮变为 loading 状态;时间轴出现第一条「素材收集专家 · 开始检索素材」进度条目 |
|
||||
| 1.4 | 意图字段留空后点击提交 | 表单校验阻止提交,提示「请填写写作意图」 |
|
||||
|
||||
---
|
||||
|
||||
## T2 — SSE 进度流(时间轴)
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 2.1 | 任务启动后观察时间轴 | 每隔几秒新增进度条目,agent_name 标签分别出现「素材收集专家」「作家」「编辑」 |
|
||||
| 2.2 | 打开浏览器 DevTools → Network,过滤 `stream` | 看到一个 `text/event-stream` 请求长连接,持续收到 `data:` 帧 |
|
||||
| 2.3 | 断网 5 秒后恢复 | 面板显示「连接中断」提示(或 error 事件),不崩溃 |
|
||||
|
||||
---
|
||||
|
||||
## T3 — PAUSE-1 素材确认
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 3.1 | 等待素材收集专家完成检索,出现 PAUSE-1 干预卡 | 卡片标题「素材确认」,列出 ≥1 条素材,每条有复选框、标题、来源、相关度分数色条 |
|
||||
| 3.2 | 全选所有素材,点击「确认素材,继续写作」 | 干预卡禁用,时间轴新增「作家 · 开始规划大纲」 |
|
||||
| 3.3 | 重新发起任务,PAUSE-1 出现后取消勾选全部素材,点击「重新检索」 | 向后端发送 `action: re_search`;时间轴回到素材收集专家节点重新检索 |
|
||||
| 3.4 | PAUSE-1 出现后填写「追加关键词」输入框(如「锂电池」)并按 Enter,再点「追加搜索」 | 标签出现「锂电池」;后端收到 `action: append_search, extraKeywords: ["锂电池"]` |
|
||||
| 3.5 | 验证相关度色条颜色:分数 ≥0.8 绿色 / 0.5–0.8 橙色 / <0.5 红色 | 三种颜色均可在不同素材中观察到 |
|
||||
|
||||
---
|
||||
|
||||
## T4 — PAUSE-2 大纲确认
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 4.1 | PAUSE-2 出现后检查大纲卡 | 显示文章标题 + ≥2 个章节,每节有节标题与要点列表 |
|
||||
| 4.2 | 点击文章标题,内联编辑改为「2025 新能源趋势报告」,按 Enter | 标题实时变更,不刷新页面 |
|
||||
| 4.3 | 展开某章节,点击一条要点,修改文字,按 Blur 失焦 | 要点文字更新 |
|
||||
| 4.4 | 点击「+ 新增章节」,填写章节标题 | 大纲末尾追加新章节 |
|
||||
| 4.5 | 点击「确认大纲,开始写作」 | 编辑后的大纲随 `action: confirm_outline` 提交;时间轴出现「作家 · 开始撰写草稿」 |
|
||||
| 4.6 | PAUSE-2 出现后点击「重新规划大纲」 | 显示意见输入框;填写「请增加政策分析章节」后提交;后端收到 `action: re_outline, outlineFeedback: ...` |
|
||||
|
||||
---
|
||||
|
||||
## T5 — PAUSE-3 草稿确认
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 5.1 | 作家完成草稿,PAUSE-3 出现 | 干预卡显示修改次数(如「第 1 稿」);右侧 Artifact 面板同步显示 Markdown 草稿内容 |
|
||||
| 5.2 | 点击「定稿,提交编辑审核」 | 进入编辑节点;时间轴出现「编辑 · 开始审核」 |
|
||||
| 5.3 | 点击「提出修改意见」,填写意见后提交 | 作家节点重新生成草稿;修改次数 +1 |
|
||||
| 5.4 | 连续打回 3 次,观察第 4 次 PAUSE-3 | revisionCount 正确递增显示 |
|
||||
|
||||
---
|
||||
|
||||
## T6 — PAUSE-4 审核结果确认
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 6.1 | 编辑审核完成,PAUSE-4 出现 | ReviewCard 显示:综合得分、事实/逻辑/语言三项分数、及格线(默认 75)、verdict 徽章(通过=绿 / 打回=红)|
|
||||
| 6.2 | verdict=pass 时,点击「接受审核,定稿」 | 流程结束;时间轴出现「完成」;右侧面板保留最终草稿 |
|
||||
| 6.3 | verdict=reject 时,查看问题列表 | major 问题排在 minor 前;每条显示类别徽章、位置、问题描述、建议 |
|
||||
| 6.4 | verdict=reject 时,点击「强制定稿」 | 向后端发送 `action: force_finalize`;流程结束 |
|
||||
| 6.5 | 添加「额外说明」文字后点击「接受审核,定稿」 | extra_notes 包含在提交的 intervention payload 中(DevTools → Network 验证) |
|
||||
|
||||
---
|
||||
|
||||
## T7 — 错误与边界场景
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 7.1 | 后端未启动时点击「开始写作」 | 面板显示错误提示「无法连接到服务器」,不白屏 |
|
||||
| 7.2 | 写作进行中点击「停止/重置」 | 时间轴清空,表单恢复可编辑,后端流被中断(Network 连接关闭)|
|
||||
| 7.3 | 素材列表为空(后端返回 0 条)时 PAUSE-1 | 干预卡显示「暂无素材」提示,「确认」按钮仍可点击 |
|
||||
| 7.4 | 大纲只有 1 个章节时,点击删除该章节 | 删除按钮禁用或弹出「至少保留一个章节」提示 |
|
||||
| 7.5 | 弱模型返回格式错误的 JSON(手动 mock 后端) | 前端收到 fallback 结构,不崩溃;时间轴正常继续 |
|
||||
|
||||
---
|
||||
|
||||
## T8 — 跨阶段状态一致性
|
||||
|
||||
| # | 步骤 | 预期结果 |
|
||||
|---|------|----------|
|
||||
| 8.1 | PAUSE-2 时展开大纲卡,刷新页面(F5) | 页面恢复;如写作任务 ID 持久化,状态可恢复(否则回到初始状态,均可接受)|
|
||||
| 8.2 | 同时打开两个浏览器标签,各自启动独立写作任务 | 两个任务互不干扰,各自有独立 task_id |
|
||||
| 8.3 | 完整跑通一次全流程(T1→T2→T3→T4→T5→T6 pass) | 右侧 Artifact 面板显示完整定稿文章,时间轴所有节点状态为「完成」 |
|
||||
|
||||
---
|
||||
|
||||
## 后端单元测试(补充确认)
|
||||
|
||||
```bash
|
||||
# 在 offline-backend-20260512/backend/ 下运行
|
||||
PYTHONPATH=. uv run pytest tests/test_ai_writing_json_parse.py -v # 16 tests
|
||||
PYTHONPATH=. uv run pytest tests/test_ai_writing_graph.py -v # 19 tests
|
||||
```
|
||||
|
||||
两个测试文件均应全部通过(0 failed)。
|
||||
|
||||
npm install @tiptap/core @tiptap/starter-kit @tiptap/extension-underline @tiptap/extension-placeholder
|
||||
@@ -0,0 +1,243 @@
|
||||
# 素材专家检索 —— 内网接口对接说明
|
||||
|
||||
本文档说明 AI 智能写作中「素材收集专家」的检索机制、内网知识库接口的对接逻辑、
|
||||
涉及的代码文件,以及常见错误的排查方式。
|
||||
|
||||
---
|
||||
|
||||
## 1. 检索来源切换
|
||||
|
||||
素材专家支持多种检索来源,通过**后端配置文件**统一切换,**无需改动任何代码**。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml`
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 检索来源开关:internet=互联网检索(默认)/ intranet=内网知识库 / tavily / demo
|
||||
search_provider: internet
|
||||
# 以下两项仅在 search_provider=intranet 时生效
|
||||
intranet_search_url: http://10.106.57.180:7863/knowledge_base/search_docs_es
|
||||
intranet_knowledge_base: samples
|
||||
```
|
||||
|
||||
| `search_provider` 取值 | 含义 |
|
||||
|---|---|
|
||||
| `internet` | 互联网检索(DuckDuckGo),失败时回退到 demo 演示素材。**当前默认值** |
|
||||
| `intranet` | 内网知识库检索(调用 `search_docs_es` 接口) |
|
||||
| `tavily` | Tavily 互联网检索 |
|
||||
| `demo` | 内置演示素材,仅用于本地调试 |
|
||||
|
||||
> 内网接口联通后,把 `search_provider` 改为 `intranet` 即可切换。
|
||||
> 配置文件支持热加载(`get_app_config()` 按 mtime 自动重载),改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 1.5 检索关键词数量
|
||||
|
||||
素材专家的检索词数量由**用户在写作表单中指定**(「检索关键词数量」输入框,
|
||||
范围 `1~10`,默认 `6`)。数量越多素材越全面,但检索与排序更耗时。
|
||||
|
||||
- 该参数随 `POST /api/ai-writing/start` 的 `keyword_count` 字段传入,写入 `AIWritingState.keyword_count`。
|
||||
- `researcher_node` 用它决定让 LLM 生成多少个检索词、并发检索多少个方向
|
||||
(`EXTRACT_KEYWORDS_USER` prompt 按该数量格式化,`search_keywords = keywords[:keyword_count]`)。
|
||||
- 非法值(缺省 / 越界 / 非数字)由前后端双重兜底:后端 `_resolve_keyword_count()`
|
||||
规整到 `[1, 10]`,越界回退默认 `6`。
|
||||
- 补充搜索(`re_search`)时用户额外添加的关键词不会被该上限截断。
|
||||
|
||||
---
|
||||
|
||||
## 1.6 素材总量上限
|
||||
|
||||
素材收集专家最终保留的素材**总条数**由后端配置文件统一控制,并按检索词数**均分**到每个词。
|
||||
|
||||
配置位置:`offline-backend-20260512/backend/config.yaml` 的 `ai_writing` 段:
|
||||
|
||||
```yaml
|
||||
ai_writing:
|
||||
# 素材总量上限,范围 1~100,非法值回退 20
|
||||
max_materials: 20
|
||||
```
|
||||
|
||||
均分规则 —— `每个检索词的检索条数 = ⌈ max_materials / 实际检索词数 ⌉`(向上取整):
|
||||
|
||||
| `max_materials` | 检索词数 | 每词检索条数 |
|
||||
|---|---|---|
|
||||
| 20 | 1 | 20 |
|
||||
| 20 | 2 | 10 |
|
||||
| 20 | 3 | 7(共 21,合并后截断到 20) |
|
||||
| 20 | 5 | 4 |
|
||||
|
||||
- 各检索词按上表条数检索 → 合并去重 → 最终统一截断到 `max_materials`。
|
||||
- 向上取整是为了保证去重后素材数仍能接近上限;去重后实际条数可能略少于上限(不同词命中同一篇),属正常。
|
||||
- 与 [1.5](#15-检索关键词数量) 的关系:`keyword_count` 决定**搜几个词**,`max_materials` 决定**总共要多少条**,两者相除得到每词条数。
|
||||
- 涉及代码:`researcher.py` 的 `_resolve_max_materials()`(读配置 + 规整)与 `_per_keyword_limit()`(均分计算)。
|
||||
|
||||
> `config.yaml` 支持热加载,改完一般无需重启后端。
|
||||
|
||||
---
|
||||
|
||||
## 2. 内网接口规格
|
||||
|
||||
- **地址**:`http://10.106.57.180:7863/knowledge_base/search_docs_es`
|
||||
- **方法**:`POST`
|
||||
- **请求体**:
|
||||
|
||||
```json
|
||||
{
|
||||
"query": "检索关键词",
|
||||
"knowledge_base_name": "samples",
|
||||
"ts": "yes"
|
||||
}
|
||||
```
|
||||
|
||||
- **响应体**:一个数组,每个元素结构如下:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"page_content": "正文片段",
|
||||
"metadata": {
|
||||
"m_title": "标题",
|
||||
"content": "内容",
|
||||
"recUuid": "原文id",
|
||||
"m_publish":"发布时间",
|
||||
"source1": "来源"
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
我们需要的字段都在 `metadata` 里。
|
||||
|
||||
### 字段映射
|
||||
|
||||
后端把内网响应映射为统一的 `SearchResult` 结构:
|
||||
|
||||
| 内网响应字段 | SearchResult 字段 | 说明 |
|
||||
|--------------------------|--------------------|------|
|
||||
| `metadata.m_title` | `title` | 标题,缺省为「(无标题)」 |
|
||||
| `metadata.content` | `content` | 内容,截断到 500 字 |
|
||||
| `metadata.source1` | `source` | 来源,缺省为「内网知识库」 |
|
||||
| `metadata.m_publish` | `published_at` | 发布时间 |
|
||||
| `metadata.recUuid` | `recUuid` | 原文 id,用于前端拼接「查看原文」链接 |
|
||||
| — | `url` | 固定为空,原文地址由前端依据 `recUuid` 拼接 |
|
||||
| — | `relevance_score` | 接口不返回分值,按返回顺序赋递减分以保留 ES 排序 |
|
||||
| — | `source_type` | 固定为 `"intranet"` |
|
||||
|
||||
---
|
||||
|
||||
## 3. 对接逻辑与涉及文件
|
||||
|
||||
### 后端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` | **内网检索适配器** `IntranetSearchProvider`。发起 POST 请求、解析响应、映射为 `SearchResult` |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/__init__.py` | `get_search_provider()` 工厂函数,依据 `search_provider` 配置返回对应 provider |
|
||||
| `packages/harness/deerflow/agents/ai_writing/search/base.py` | `SearchResult` 数据结构 + `BaseSearchProvider` 抽象基类 |
|
||||
| `packages/harness/deerflow/agents/ai_writing/nodes/researcher.py` | 素材收集专家节点,调用 provider 检索并把结果合并、排序为素材包 |
|
||||
| `app/gateway/routers/ai_writing.py` | `_ai_writing_config()` 读取 `config.yaml` 的 `ai_writing` 段,经 `_make_config()` 注入运行时 |
|
||||
| `config.yaml` | `ai_writing` 段,检索来源开关与内网接口地址 |
|
||||
|
||||
调用链:
|
||||
|
||||
```
|
||||
ai_writing.py(_make_config 注入 ai_writing_config)
|
||||
→ researcher_node
|
||||
→ get_search_provider(ai_writing_config) # 依据 search_provider 选择
|
||||
→ IntranetSearchProvider.search() # POST search_docs_es
|
||||
→ SearchResult[] # 字段映射
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
| 文件 | 作用 |
|
||||
|------|------|
|
||||
| `src/core/config/index.ts` | `INTRANET_SOURCE_URL_BASE` 原文详情页地址前缀 + `buildIntranetSourceUrl()` 构造函数 |
|
||||
| `src/open-canvas/components/ai-writing/MaterialItem.tsx` | 素材卡片,`recUuid` 非空时显示「查看原文」按钮 |
|
||||
| `src/open-canvas/components/ai-writing/CompletedInterventionCard.tsx` | 历史回显的素材卡,「原文」链接同样基于 `recUuid` |
|
||||
| `src/open-canvas/api/ai-writing.ts` | `normalizeMaterial()` 把后端 snake_case 字段转为前端 camelCase(含 `recUuid`) |
|
||||
|
||||
「查看原文」链接拼接规则:
|
||||
|
||||
```
|
||||
INTRANET_SOURCE_URL_BASE + recUuid
|
||||
= https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid={recUuid}
|
||||
```
|
||||
|
||||
- 仅当素材的 `recUuid` **非空**时才显示「查看原文」按钮。
|
||||
- 点击直接 `window.open(...)` 新标签页打开,不做站内跳转。
|
||||
|
||||
---
|
||||
|
||||
## 4. 错误处理与排查
|
||||
|
||||
### 4.1 内网检索返回空 / 调不通
|
||||
|
||||
**现象**:切到 `intranet` 后,素材专家时间线出现琥珀色告警「ES 波动导致检索异常」,
|
||||
下方显示真实报错原因;若完全无结果,再提示「未检索到任何素材」。
|
||||
|
||||
`IntranetSearchProvider.search()` 对所有异常(连接失败、超时、非 2xx、JSON 解析失败)
|
||||
都做了兜底:**捕获后记录 warning 日志、把原因收集到 `provider.errors`,并返回空列表**,
|
||||
不会让整个写作流程崩溃。`researcher_node` 在检索结束后取出 `provider.errors`,
|
||||
以非致命的 `notice` 事件推送到前端时间线(标题「ES 波动导致检索异常」+ 真实原因),
|
||||
**接口报错不影响写作流程继续**。
|
||||
|
||||
> 容错相关代码:
|
||||
> - 检索异常收集:`intranet_stub.py` / `duckduckgo_search.py` 的 `errors` 列表
|
||||
> - 告警事件发出:`researcher.py` 的 `_notice()` 与 `progress_events`
|
||||
> - 模型调用异常:`researcher.py`(检索词/排序)、`writer_outline.py` / `writer_draft.py` /
|
||||
> `editor.py` 均会发出「模型错误」事件(致命错误用 `error`,可兜底的用 `notice`)
|
||||
> - 前端展示:`TimelineStep.tsx` 渲染 `notice`(琥珀)/ `error`(红色)的标题 + 真实原因
|
||||
|
||||
排查步骤:
|
||||
|
||||
1. **确认网络可达**:在后端所在机器执行
|
||||
`curl -X POST http://10.106.57.180:7863/knowledge_base/search_docs_es -H "Content-Type: application/json" -d "{\"query\":\"测试\",\"knowledge_base_name\":\"samples\",\"ts\":\"yes\"}"`
|
||||
2. **查看后端日志**:搜索关键字 `intranet search failed`,会打印具体异常(连接超时 / DNS / 401 等)。
|
||||
3. **确认配置**:`config.yaml` 的 `intranet_search_url`、`intranet_knowledge_base` 是否正确。
|
||||
|
||||
空结果时前端已有兜底:素材确认卡会弹出「未检索到任何素材,是否在无素材的情况下继续写作」,
|
||||
用户可以选择「重新搜索」或「无数据继续写作」。
|
||||
|
||||
### 4.2 字段缺失 / 响应结构变化
|
||||
|
||||
`search()` 对每个字段都用 `or` 兜底(如 `m_title` 缺失时回退为「(无标题)」),
|
||||
单条数据字段缺失不会导致整批失败。
|
||||
|
||||
若内网接口改为「数组外包一层 `data`/`results`」的结构,适配器已兼容;
|
||||
若改成其它结构,需修改 `intranet_stub.py` 中解析 `items` 的逻辑。
|
||||
|
||||
### 4.3 「查看原文」按钮不显示
|
||||
|
||||
**原因**:该素材的 `recUuid` 为空。这是预期行为 —— 只有内网知识库素材才带 `recUuid`,
|
||||
互联网检索(`internet`/`tavily`/`demo`)的素材没有 `recUuid`,不显示「查看原文」。
|
||||
|
||||
如需让互联网素材也能跳转,需另行调整 `MaterialItem.tsx` 的显示条件。
|
||||
|
||||
### 4.4 原文链接打不开 / 地址不对
|
||||
|
||||
「查看原文」地址前缀写在 `src/core/config/index.ts` 的 `INTRANET_SOURCE_URL_BASE`。
|
||||
若内网原文详情页地址有变,只改这一个常量即可。
|
||||
|
||||
### 4.5 检索到的素材全是无关内容
|
||||
|
||||
**现象**:素材卡片内容与写作意图完全不沾边。
|
||||
|
||||
**排查**:检查后端日志里素材专家实际用的检索词。常见原因是模型把关键词提取
|
||||
提示词模板里的占位符(如「关键词1」「...」)当成真关键词去检索了。
|
||||
|
||||
`researcher.py` 的 `_clean_keywords()` 已对此做防御:剔除 `关键词\d*` / `keyword\d*` /
|
||||
`...` 等占位符,全部被滤掉时回退为写作意图本身。若仍出现,检查
|
||||
`researcher_prompts.py` 的 `EXTRACT_KEYWORDS` 提示词是否又引入了易被误抄的示例占位符。
|
||||
|
||||
---
|
||||
|
||||
## 5. 联调上线步骤
|
||||
|
||||
1. 确认后端机器能访问 `10.106.57.180:7863`。
|
||||
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
|
||||
3. 后端配置热加载生效(或重启后端)。
|
||||
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
|
||||
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
|
||||
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
|
||||
Generated
+689
-1
@@ -35,6 +35,13 @@
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@tiptap/core": "^3.23.4",
|
||||
"@tiptap/extension-bubble-menu": "^3.23.4",
|
||||
"@tiptap/extension-placeholder": "^3.23.4",
|
||||
"@tiptap/extension-underline": "^3.23.4",
|
||||
"@tiptap/pm": "^3.23.4",
|
||||
"@tiptap/react": "^3.23.4",
|
||||
"@tiptap/starter-kit": "^3.23.4",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
@@ -74,6 +81,7 @@
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
@@ -4262,6 +4270,417 @@
|
||||
"react": "^18 || ^19"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.23.4.tgz",
|
||||
"integrity": "sha512-ni2LWE52bVeSt3L2HVBSmbBw+elc32ATej9C68EyKzN/8vR5ILxFn6RCdDTKm4asmwZyq2jys12dKmBdWMr9QA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.23.4.tgz",
|
||||
"integrity": "sha512-7YjSibNlPcy9eGK+tHt5G/Njr7nPxl+rZ3rCC6TwtLIRLSHPnoGDsfFOgTPkXxaQcE1a/VQwemnYfWc3kdIjDQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.23.4.tgz",
|
||||
"integrity": "sha512-3L9tnZ12i+98u5df2nV2zGu/sc3rhI87E3ocn1YYAO8PJUAgZnMwdet8JawCrS1uut5sRKlxo3SXEmdNfRVm/w==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bubble-menu": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.23.4.tgz",
|
||||
"integrity": "sha512-EPTpL/IFp/aTGZErBq/Mc3dKznj6G/qNEkVYWjueOn1oKApyT0P6WVHGvu/vpMdErhzmoGDuFPPGVS6T8Upx2Q==",
|
||||
"dependencies": {
|
||||
"@floating-ui/dom": "^1.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.23.4.tgz",
|
||||
"integrity": "sha512-mXB2KZOz1R+E6VNTZ3vzdAk7ZDGKjPmsJEZIQg1B5qRycTKg49/rCCkLA2QnqAwX6BzS3mLLH1RWE2W0oXD7vg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.23.4.tgz",
|
||||
"integrity": "sha512-C0TeRipMycUEBnV+Mzx6eLp/yZb6Vi/waP3Tkb0lO5/ikg7LWLB7AlmMunjIXEUcR/pJHID/aEh5PfJFpysUDg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.23.4.tgz",
|
||||
"integrity": "sha512-UEU1w/85CSNKktbhESnIRmtjKcH7DeschReZA8err1wAnYLTKzid5ucnJSJ25iRg2V5Fnuws5gnPT5CVgdfXCQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.23.4.tgz",
|
||||
"integrity": "sha512-YC4G6VkxT629rlqUTwD6XvOpxjvghn7fxrK4RbyKVJY2C6E1vgmX0won1Ast6v+qTE6iONOMS6f6VyPxSGjg4w==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.23.4.tgz",
|
||||
"integrity": "sha512-ujJQUIENk0RwVFCh5g/TOSEv1a7Pnam/cjHmSUqHWUNZkYS9aOqjm+JfURJPCinRS2oHvo3AARul5mkKgDJYcA==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-floating-menu": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-floating-menu/-/extension-floating-menu-3.23.4.tgz",
|
||||
"integrity": "sha512-eAc72bKM26yIPx0jsU8qdjE71vFNVu5R9jGbdItBMFc0SPLS4qY8g+8RJ+iWoLwbcSEpgooLS9D9sLfdAU+Tvw==",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@floating-ui/dom": "^1.0.0",
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.23.4.tgz",
|
||||
"integrity": "sha512-RuyvOlIGP6UpVOc0Lw0L63jKLtYM49CNhPV2OMSfwwwbBZ3pJGos2/SqpYg71d3sn+qpsAopS4Pfr8iPZog73A==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.23.4.tgz",
|
||||
"integrity": "sha512-ODlpZCi7n136BH9luM09EFL8Pg+bbRCd0tzCQM5BKMXRkLitYZA8Gl/f5DLmGJ50wzFsDPXK2Br2g9UvZK7COg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.23.4.tgz",
|
||||
"integrity": "sha512-8W9Hqi0J69Xbqg08nPf4xRMJXMccaKFAgUE1tvy5PAWJSQxOMwkKQXgZXxwe+80sOMUnV8qveBqUy/ODMPgAxQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.23.4.tgz",
|
||||
"integrity": "sha512-EA4kK8ywZ4dQNOdxeZbplmDDs5T5LjMgHpqxRwukj9wwKiILOK5E3fcKm1fCKh9Q02w96jax6YVccHwmgJP3sQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.23.4.tgz",
|
||||
"integrity": "sha512-jUAHi+HZlg47BzgVIy6y/UH5vev7vPQ95jddhB5K3hC122kvWFMXlken7UOnqzbxNcHs2+4Oi/ZJirYMpT4P5w==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.23.4.tgz",
|
||||
"integrity": "sha512-XjxltY7MomwfTs6jmN6Bw5bb/upb34lpyqv2RiXppFTK25Br7ipksRjUpWpB4/csZeg30qwrLGVKxCol38ffrw==",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.23.4.tgz",
|
||||
"integrity": "sha512-yuauDm6qW/7q+ZO0YJBKQEGdnUm6DDTJM8AMp9bMZrT4jRf/zyUtNcZ91QEfFvBcyVuI+10PIOXtNPevhQ741Q==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.23.4.tgz",
|
||||
"integrity": "sha512-Q/JXosShD5oyDwukE6igdrZD2lb0ZgyoQTHYchk0pzU4frClFbn3RI1wKP+XeqKLhdO6KH2WZ9rERGH7PtDi7Q==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.23.4.tgz",
|
||||
"integrity": "sha512-9FezifCfuoc0o+5K6l4QNOOfelqxnDGg/f9oL1D/LFZPC54bPxpWWft9QCWOqyqZgyLCLjbCjciAlbgkrFUmmw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.23.4.tgz",
|
||||
"integrity": "sha512-+3ofyssYnOTa1+nFWEmCAY1ngn8nAV1xo25JnNNC87NMU9WkSgr93jB7/uUJP0uui1C2dBLlaup3XXm108yarw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.23.4.tgz",
|
||||
"integrity": "sha512-KbhXjCFzWphvFn5VU7E4dtmYDm+bssI1i0+CnXPWCXkjdaaX88ck68Xp1fKz8/bbI/CqlgiNDO/3TvqgtZ6woQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-placeholder": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-placeholder/-/extension-placeholder-3.23.4.tgz",
|
||||
"integrity": "sha512-yHtAZkFR9M2AQmCi555w4ns1BBCqwRyYDYMtd10DBvqPX7T3TmGerPdUfI6sLr74GxnZ5zHOnOYdwAbeG5JzNw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.23.4.tgz",
|
||||
"integrity": "sha512-Vnq5vW801zPbu1LtKeA5k4R241jY+hRjXeijYwIPxy15KzIiipY12518HiCf6/8kkRbMxgOfdYg9X4BRV3HV3g==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.23.4.tgz",
|
||||
"integrity": "sha512-q9kxver/MR18p66aWZHSPycnr9hcBFyVGeGj8gf+BQCzn5hpvtSYTfLvk1nq8GFhygdQ9/e3f7B5ovrm/jnpvw==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.23.4.tgz",
|
||||
"integrity": "sha512-F1ocPT10LV+seky25R1TMCRdc/Iof99jLcDSYDGr6mNEDY4ct2RvOeSM8aDdYq6CkH+vXt3i3JDeRwV23KzswQ==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.23.4.tgz",
|
||||
"integrity": "sha512-SlGPXauW8iKWG7wwuwC/0y/smLImp0h6GBIGgNnTBgIP/ThXQnjLMSZH0mW/REO87dQxkku01V3ARRywi+juhg==",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.23.4.tgz",
|
||||
"integrity": "sha512-+C5ngcoza47n3MjtjVBqBEBICPC0McdbwzJ+X6SSCviCLoqnSYanv5mIX9HWG0Q4fJ4BkdNM3VibZUxQaTbKyQ==",
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.3.0",
|
||||
"prosemirror-commands": "^1.6.2",
|
||||
"prosemirror-dropcursor": "^1.8.1",
|
||||
"prosemirror-gapcursor": "^1.3.2",
|
||||
"prosemirror-history": "^1.4.1",
|
||||
"prosemirror-keymap": "^1.2.2",
|
||||
"prosemirror-model": "^1.24.1",
|
||||
"prosemirror-schema-list": "^1.5.0",
|
||||
"prosemirror-state": "^1.4.3",
|
||||
"prosemirror-tables": "^1.6.4",
|
||||
"prosemirror-transform": "^1.10.2",
|
||||
"prosemirror-view": "^1.38.1"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/react": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/react/-/react-3.23.4.tgz",
|
||||
"integrity": "sha512-mb5aIY9PuLreOVLExqs+8BAI20I/8+jCUBfEIqheuFY2GRRuBiwczejSlYuADfVDBbPVN5uPw4UMADCaH5wueQ==",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"fast-equals": "^5.3.3",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@tiptap/extension-bubble-menu": "^3.23.4",
|
||||
"@tiptap/extension-floating-menu": "^3.23.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.23.4",
|
||||
"@tiptap/pm": "3.23.4",
|
||||
"@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"@types/react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react": "^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.23.4.tgz",
|
||||
"integrity": "sha512-3VhU+NO6/ec9DMj/5Ej0nzARSq42cXnqW+QHCmTL3FNXkXQz+tw1KlfruT5GGJ3M0RssjWjRC0a39N/4S3qxeA==",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "^3.23.4",
|
||||
"@tiptap/extension-blockquote": "^3.23.4",
|
||||
"@tiptap/extension-bold": "^3.23.4",
|
||||
"@tiptap/extension-bullet-list": "^3.23.4",
|
||||
"@tiptap/extension-code": "^3.23.4",
|
||||
"@tiptap/extension-code-block": "^3.23.4",
|
||||
"@tiptap/extension-document": "^3.23.4",
|
||||
"@tiptap/extension-dropcursor": "^3.23.4",
|
||||
"@tiptap/extension-gapcursor": "^3.23.4",
|
||||
"@tiptap/extension-hard-break": "^3.23.4",
|
||||
"@tiptap/extension-heading": "^3.23.4",
|
||||
"@tiptap/extension-horizontal-rule": "^3.23.4",
|
||||
"@tiptap/extension-italic": "^3.23.4",
|
||||
"@tiptap/extension-link": "^3.23.4",
|
||||
"@tiptap/extension-list": "^3.23.4",
|
||||
"@tiptap/extension-list-item": "^3.23.4",
|
||||
"@tiptap/extension-list-keymap": "^3.23.4",
|
||||
"@tiptap/extension-ordered-list": "^3.23.4",
|
||||
"@tiptap/extension-paragraph": "^3.23.4",
|
||||
"@tiptap/extension-strike": "^3.23.4",
|
||||
"@tiptap/extension-text": "^3.23.4",
|
||||
"@tiptap/extension-underline": "^3.23.4",
|
||||
"@tiptap/extensions": "^3.23.4",
|
||||
"@tiptap/pm": "^3.23.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenlens/core": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@tokenlens/core/-/core-1.3.0.tgz",
|
||||
@@ -4606,6 +5025,20 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz",
|
||||
"integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg=="
|
||||
},
|
||||
"node_modules/@types/linkify-it": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-3.0.5.tgz",
|
||||
"integrity": "sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw=="
|
||||
},
|
||||
"node_modules/@types/markdown-it": {
|
||||
"version": "13.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-13.0.9.tgz",
|
||||
"integrity": "sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^3",
|
||||
"@types/mdurl": "^1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdast": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
|
||||
@@ -4614,6 +5047,11 @@
|
||||
"@types/unist": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/mdurl": {
|
||||
"version": "1.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-1.0.5.tgz",
|
||||
"integrity": "sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA=="
|
||||
},
|
||||
"node_modules/@types/ms": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||
@@ -4641,7 +5079,6 @@
|
||||
},
|
||||
"node_modules/@types/react-dom": {
|
||||
"version": "19.2.3",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@types/react": "^19.2.0"
|
||||
@@ -4668,6 +5105,11 @@
|
||||
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
|
||||
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q=="
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
|
||||
},
|
||||
"node_modules/@types/validator": {
|
||||
"version": "13.15.10",
|
||||
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.10.tgz",
|
||||
@@ -4910,6 +5352,11 @@
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/argparse": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
|
||||
},
|
||||
"node_modules/aria-hidden": {
|
||||
"version": "1.2.6",
|
||||
"resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz",
|
||||
@@ -5986,6 +6433,14 @@
|
||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="
|
||||
},
|
||||
"node_modules/fast-equals": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz",
|
||||
"integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fault": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/fault/-/fault-1.0.4.tgz",
|
||||
@@ -6867,6 +7322,19 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==",
|
||||
"dependencies": {
|
||||
"uc.micro": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg=="
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.18.1",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz",
|
||||
@@ -6931,6 +7399,38 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it": {
|
||||
"version": "14.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.1.tgz",
|
||||
"integrity": "sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==",
|
||||
"dependencies": {
|
||||
"argparse": "^2.0.1",
|
||||
"entities": "^4.4.0",
|
||||
"linkify-it": "^5.0.0",
|
||||
"mdurl": "^2.0.0",
|
||||
"punycode.js": "^2.3.1",
|
||||
"uc.micro": "^2.1.0"
|
||||
},
|
||||
"bin": {
|
||||
"markdown-it": "bin/markdown-it.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-it-task-lists": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/markdown-it-task-lists/-/markdown-it-task-lists-2.1.1.tgz",
|
||||
"integrity": "sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA=="
|
||||
},
|
||||
"node_modules/markdown-it/node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/markdown-table": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
|
||||
@@ -7224,6 +7724,11 @@
|
||||
"url": "https://opencollective.com/unified"
|
||||
}
|
||||
},
|
||||
"node_modules/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w=="
|
||||
},
|
||||
"node_modules/mermaid": {
|
||||
"version": "11.14.0",
|
||||
"resolved": "https://registry.npmjs.org/mermaid/-/mermaid-11.14.0.tgz",
|
||||
@@ -7945,6 +8450,11 @@
|
||||
"regex-recursion": "^6.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="
|
||||
},
|
||||
"node_modules/p-finally": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
|
||||
@@ -8176,6 +8686,160 @@
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
|
||||
"integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
|
||||
"integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
|
||||
"integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
|
||||
"integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown": {
|
||||
"version": "1.13.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-markdown/-/prosemirror-markdown-1.13.4.tgz",
|
||||
"integrity": "sha512-D98dm4cQ3Hs6EmjK500TdAOew4Z03EV71ajEFiWra3Upr7diytJsjF4mPV2dW+eK5uNectiRj0xFxYI9NLXDbw==",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^14.0.0",
|
||||
"markdown-it": "^14.0.0",
|
||||
"prosemirror-model": "^1.25.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown/node_modules/@types/linkify-it": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
|
||||
"integrity": "sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q=="
|
||||
},
|
||||
"node_modules/prosemirror-markdown/node_modules/@types/markdown-it": {
|
||||
"version": "14.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/markdown-it/-/markdown-it-14.1.2.tgz",
|
||||
"integrity": "sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==",
|
||||
"dependencies": {
|
||||
"@types/linkify-it": "^5",
|
||||
"@types/mdurl": "^2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-markdown/node_modules/@types/mdurl": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/mdurl/-/mdurl-2.0.0.tgz",
|
||||
"integrity": "sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg=="
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.6",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.6.tgz",
|
||||
"integrity": "sha512-RIm+e9BiqAaJ1mRECv3vR3C+VG8ELoTTI+47tVudGi82yLnFOx3G/p/iSPK1HmHQdKhkkrJ68NJqxh7S+FBVmQ==",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.0",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
|
||||
"integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.41.8",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
|
||||
"integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.20.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/punycode.js": {
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz",
|
||||
"integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz",
|
||||
@@ -8608,6 +9272,11 @@
|
||||
"fsevents": "~2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ=="
|
||||
},
|
||||
"node_modules/roughjs": {
|
||||
"version": "4.6.6",
|
||||
"resolved": "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz",
|
||||
@@ -8907,6 +9576,20 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tiptap-markdown": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tiptap-markdown/-/tiptap-markdown-0.9.0.tgz",
|
||||
"integrity": "sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==",
|
||||
"dependencies": {
|
||||
"@types/markdown-it": "^13.0.7",
|
||||
"markdown-it": "^14.1.0",
|
||||
"markdown-it-task-lists": "^2.1.1",
|
||||
"prosemirror-markdown": "^1.11.1"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/tokenlens": {
|
||||
"version": "1.3.1",
|
||||
"license": "MIT",
|
||||
@@ -8968,6 +9651,11 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/uc.micro": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
|
||||
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A=="
|
||||
},
|
||||
"node_modules/ufo": {
|
||||
"version": "1.6.4",
|
||||
"resolved": "https://registry.npmjs.org/ufo/-/ufo-1.6.4.tgz",
|
||||
|
||||
+108
-100
@@ -1,100 +1,108 @@
|
||||
{
|
||||
"name": "deer-flow-frontend-vite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"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",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "3.15.0",
|
||||
"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",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
{
|
||||
"name": "deer-flow-frontend-vite",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@codemirror/lang-css": "^6.3.1",
|
||||
"@codemirror/lang-html": "^6.4.11",
|
||||
"@codemirror/lang-javascript": "^6.2.4",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/lang-markdown": "^6.5.0",
|
||||
"@codemirror/lang-python": "^6.2.1",
|
||||
"@codemirror/language-data": "^6.5.2",
|
||||
"@langchain/core": "^1.1.15",
|
||||
"@langchain/langgraph-sdk": "^1.5.3",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
"@radix-ui/react-collapsible": "^1.1.12",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-hover-card": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-progress": "^1.1.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toggle": "^1.1.10",
|
||||
"@radix-ui/react-toggle-group": "^1.1.11",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@radix-ui/react-use-controllable-state": "^1.2.2",
|
||||
"@tanstack/react-query": "^5.90.17",
|
||||
"@tiptap/core": "^3.23.4",
|
||||
"@tiptap/extension-bubble-menu": "^3.23.4",
|
||||
"@tiptap/extension-placeholder": "^3.23.4",
|
||||
"@tiptap/extension-underline": "^3.23.4",
|
||||
"@tiptap/pm": "^3.23.4",
|
||||
"@tiptap/react": "^3.23.4",
|
||||
"@tiptap/starter-kit": "^3.23.4",
|
||||
"@types/hast": "^3.0.4",
|
||||
"@uiw/codemirror-theme-basic": "^4.25.4",
|
||||
"@uiw/codemirror-theme-monokai": "^4.25.4",
|
||||
"@uiw/react-codemirror": "^4.25.4",
|
||||
"@xyflow/react": "^12.10.0",
|
||||
"ai": "^6.0.33",
|
||||
"best-effort-json-parser": "^1.2.1",
|
||||
"canvas-confetti": "^1.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"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",
|
||||
"hast": "^1.0.0",
|
||||
"katex": "^0.16.28",
|
||||
"lucide-react": "^0.562.0",
|
||||
"motion": "^12.26.2",
|
||||
"nanoid": "^5.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"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",
|
||||
"remark-math": "^6.0.0",
|
||||
"shiki": "3.15.0",
|
||||
"sonner": "^2.0.7",
|
||||
"streamdown": "1.4.0",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tdesign-react": "^1.12.3",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"tokenlens": "^1.3.1",
|
||||
"unist-util-visit": "^5.0.0",
|
||||
"use-stick-to-bottom": "^1.1.1",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^3.24.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4.0.15",
|
||||
"@types/gsap": "^3.0.0",
|
||||
"@types/node": "^20.14.10",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^4.0.15",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.8.2",
|
||||
"vite": "^6.0.0",
|
||||
"vite-plugin-source-identifier": "^1.1.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.26.2"
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import { HashRouter, useLocation } from "react-router-dom";
|
||||
import LandingPage from "./pages/LandingPage";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import PageRoutes from "./pages/PageRoutes";
|
||||
import PublicScheduledTaskPage from "./pages/PublicScheduledTaskPage";
|
||||
import PublicSharePage from "./pages/PublicSharePage";
|
||||
import { NotFoundPage } from "./pages/NotFoundPage";
|
||||
|
||||
function AppProviders({ children }: { children: React.ReactNode }) {
|
||||
@@ -47,6 +49,13 @@ export function App() {
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/:username" element={<LoginPage />} />
|
||||
<Route path="/landing" element={<LandingPage />} />
|
||||
{/* Public read-only conversation viewer — no authentication. */}
|
||||
<Route path="/share/:shareCode" element={<PublicSharePage />} />
|
||||
{/* Public read-only scheduled-task runs viewer — no authentication. */}
|
||||
<Route
|
||||
path="/public/scheduled-tasks/:taskId"
|
||||
element={<PublicScheduledTaskPage />}
|
||||
/>
|
||||
<Route path="/page/*" element={<PageRoutes />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,604 @@
|
||||
"use client";
|
||||
|
||||
import { Download, Loader2 } 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,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
downloadToolMetricsExcel,
|
||||
TOOL_ERROR_CATEGORIES,
|
||||
TOOL_ERROR_CATEGORY_LABELS,
|
||||
type ToolMetric,
|
||||
type ToolMetricsFilters,
|
||||
} from "@/core/tool-metrics/api";
|
||||
import { useToolMetrics, useToolMetricsSummary } from "@/core/tool-metrics/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const PAGE_SIZE = 50;
|
||||
|
||||
type StatusFilter = "all" | "success" | "error";
|
||||
type KindFilter = "skill" | "all";
|
||||
|
||||
function displayUser(row: { user_name?: string | null; user_id?: string | null }): string {
|
||||
if (row.user_name && row.user_name.trim()) return row.user_name.trim();
|
||||
const id = (row.user_id ?? "").trim();
|
||||
return id || "—";
|
||||
}
|
||||
|
||||
function formatDate(value?: string | null): string {
|
||||
if (!value) return "—";
|
||||
try {
|
||||
return new Date(value).toLocaleString();
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(value?: number | null): string {
|
||||
if (value === null || value === undefined || Number.isNaN(value)) return "—";
|
||||
if (value === 0) return "0 ms";
|
||||
if (value < 1000) return `${value} ms`;
|
||||
if (value < 60_000) return `${(value / 1000).toFixed(1)} s`;
|
||||
const minutes = Math.floor(value / 60_000);
|
||||
const seconds = Math.round((value % 60_000) / 1000);
|
||||
return `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
function categoryLabel(category?: string | null): string {
|
||||
if (!category) return "—";
|
||||
return TOOL_ERROR_CATEGORY_LABELS[category] ?? category;
|
||||
}
|
||||
|
||||
export function ToolMetricsPanel() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [tool, setTool] = useState("");
|
||||
const [user, setUser] = useState("");
|
||||
const [skill, setSkill] = useState("");
|
||||
// Default to skill-only — the table is otherwise flooded with low-level
|
||||
// bash / file-op calls. "全部" reveals every tool invocation.
|
||||
const [kind, setKind] = useState<KindFilter>("skill");
|
||||
const [status, setStatus] = useState<StatusFilter>("all");
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [since, setSince] = useState("");
|
||||
const [until, setUntil] = useState("");
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [detailRow, setDetailRow] = useState<ToolMetric | null>(null);
|
||||
|
||||
const filters = useMemo<ToolMetricsFilters>(() => {
|
||||
const out: ToolMetricsFilters = {};
|
||||
if (tool.trim()) out.tool_name = tool.trim();
|
||||
if (user.trim()) out.user_name = user.trim();
|
||||
if (skill.trim()) out.skill_name = skill.trim();
|
||||
if (kind !== "all") out.kind = kind;
|
||||
if (status !== "all") out.status = status;
|
||||
if (category !== "all") out.error_category = category;
|
||||
if (since) out.since = new Date(since).toISOString();
|
||||
if (until) out.until = new Date(until).toISOString();
|
||||
return out;
|
||||
}, [tool, user, skill, kind, status, category, since, until]);
|
||||
|
||||
const query = useMemo(
|
||||
() => ({ ...filters, limit: PAGE_SIZE, offset: page * PAGE_SIZE }),
|
||||
[filters, page],
|
||||
);
|
||||
|
||||
const { data, isLoading, error, isFetching } = useToolMetrics(query);
|
||||
// The summary ignores the status/category filter so the breakdown always
|
||||
// reflects the full failure picture for the chosen tool/user/time window.
|
||||
const summaryFilters = useMemo<ToolMetricsFilters>(() => {
|
||||
const out: ToolMetricsFilters = {};
|
||||
if (tool.trim()) out.tool_name = tool.trim();
|
||||
if (user.trim()) out.user_name = user.trim();
|
||||
if (skill.trim()) out.skill_name = skill.trim();
|
||||
if (kind !== "all") out.kind = kind;
|
||||
if (since) out.since = new Date(since).toISOString();
|
||||
if (until) out.until = new Date(until).toISOString();
|
||||
return out;
|
||||
}, [tool, user, skill, kind, since, until]);
|
||||
const { data: summary } = useToolMetricsSummary(summaryFilters);
|
||||
|
||||
const handleExport = async () => {
|
||||
setExporting(true);
|
||||
try {
|
||||
await downloadToolMetricsExcel(filters);
|
||||
toast.success("已导出为 Excel");
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : "导出失败");
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetFilters = () => {
|
||||
setTool("");
|
||||
setUser("");
|
||||
setSkill("");
|
||||
setKind("skill");
|
||||
setStatus("all");
|
||||
setCategory("all");
|
||||
setSince("");
|
||||
setUntil("");
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const total = data?.total ?? 0;
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const errorRate =
|
||||
summary && summary.total > 0
|
||||
? ((summary.error_total / summary.total) * 100).toFixed(1)
|
||||
: "0.0";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">工具调用统计</h2>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
记录每一次工具 / 技能 / MCP 调用,并对失败进行归类:检索为空、IP
|
||||
被封、限流、超时、鉴权失败等。后台无条件记录。
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={handleExport} disabled={exporting}>
|
||||
{exporting ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
) : (
|
||||
<Download className="mr-1 size-4" />
|
||||
)}
|
||||
导出 Excel
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Summary strip */}
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-xl border bg-white p-4">
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">总调用</span>
|
||||
<span className="text-lg font-semibold tabular-nums">
|
||||
{summary?.total ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">失败数</span>
|
||||
<span className="text-destructive text-lg font-semibold tabular-nums">
|
||||
{summary?.error_total ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mr-4 flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">失败率</span>
|
||||
<span className="text-lg font-semibold tabular-nums">{errorRate}%</span>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-wrap items-center gap-1.5">
|
||||
{summary && Object.keys(summary.by_category).length === 0 && (
|
||||
<span className="text-muted-foreground text-sm">暂无失败记录 🎉</span>
|
||||
)}
|
||||
{summary &&
|
||||
Object.entries(summary.by_category)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([cat, count]) => (
|
||||
<button
|
||||
key={cat}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCategory(cat);
|
||||
setStatus("error");
|
||||
setPage(0);
|
||||
}}
|
||||
className="hover:bg-muted inline-flex items-center gap-1 rounded-full border px-2.5 py-1 text-xs"
|
||||
>
|
||||
<span>{categoryLabel(cat)}</span>
|
||||
<span className="text-destructive font-semibold tabular-nums">
|
||||
{count}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Per-skill breakdown — answers "was skill X invoked, and did it succeed". */}
|
||||
{summary && summary.by_skill.length > 0 && (
|
||||
<div className="rounded-xl border bg-white p-4">
|
||||
<h3 className="mb-2 text-sm font-medium">各技能调用情况</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="text-muted-foreground">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left text-xs font-medium">技能名称</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">调用次数</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">成功</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">失败</th>
|
||||
<th className="px-2 py-1 text-right text-xs font-medium">成功率</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.by_skill.map((s) => {
|
||||
const rate =
|
||||
s.total > 0 ? ((s.success / s.total) * 100).toFixed(0) : "0";
|
||||
return (
|
||||
<tr
|
||||
key={s.skill_name}
|
||||
className="hover:bg-muted cursor-pointer border-t"
|
||||
title="点击只看该技能"
|
||||
onClick={() => {
|
||||
setSkill(s.skill_name);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<td className="px-2 py-1.5 font-medium">{s.skill_name}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{s.total}</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums text-green-600">
|
||||
{s.success}
|
||||
</td>
|
||||
<td className="text-destructive px-2 py-1.5 text-right tabular-nums">
|
||||
{s.error}
|
||||
</td>
|
||||
<td className="px-2 py-1.5 text-right tabular-nums">{rate}%</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{summary && kind === "skill" && summary.by_skill.length === 0 && (
|
||||
<div className="text-muted-foreground rounded-xl border bg-white p-4 text-sm">
|
||||
当前筛选范围内没有检测到任何技能调用记录。技能只有在被 Agent 通过
|
||||
<code className="bg-muted mx-1 rounded px-1">skill_view</code>
|
||||
读取(即真正"调用")时才会被记录;仅在系统提示里被列出、但未被读取的技能不会计入。
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Filters */}
|
||||
<div className="grid gap-3 rounded-xl border bg-white p-4 md:grid-cols-3 lg:grid-cols-6">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">调用类型</label>
|
||||
<Select
|
||||
value={kind}
|
||||
onValueChange={(value) => {
|
||||
setKind(value as KindFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="skill">技能(Agent Skill)</SelectItem>
|
||||
<SelectItem value="all">全部工具调用</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">技能名称</label>
|
||||
<Input
|
||||
value={skill}
|
||||
placeholder="按技能名筛选"
|
||||
onChange={(e) => {
|
||||
setSkill(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">工具名</label>
|
||||
<Input
|
||||
value={tool}
|
||||
placeholder="按工具名筛选"
|
||||
onChange={(e) => {
|
||||
setTool(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">用户名</label>
|
||||
<Input
|
||||
value={user}
|
||||
placeholder="@ 前部分"
|
||||
onChange={(e) => {
|
||||
setUser(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">状态</label>
|
||||
<Select
|
||||
value={status}
|
||||
onValueChange={(value) => {
|
||||
setStatus(value as StatusFilter);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
<SelectItem value="success">成功</SelectItem>
|
||||
<SelectItem value="error">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">错误类型</label>
|
||||
<Select
|
||||
value={category}
|
||||
onValueChange={(value) => {
|
||||
setCategory(value);
|
||||
setPage(0);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部</SelectItem>
|
||||
{TOOL_ERROR_CATEGORIES.map((cat) => (
|
||||
<SelectItem key={cat} value={cat}>
|
||||
{categoryLabel(cat)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">开始时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={since}
|
||||
onChange={(e) => {
|
||||
setSince(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-muted-foreground text-xs">结束时间</label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={until}
|
||||
onChange={(e) => {
|
||||
setUntil(e.target.value);
|
||||
setPage(0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end lg:col-span-6">
|
||||
<Button variant="outline" size="sm" onClick={resetFilters}>
|
||||
清空筛选
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-xl border bg-white">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full min-w-[960px] text-sm">
|
||||
<thead className="bg-muted/60 text-muted-foreground">
|
||||
<tr>
|
||||
<Th>时间</Th>
|
||||
<Th>用户</Th>
|
||||
<Th>Agent</Th>
|
||||
<Th>技能</Th>
|
||||
<Th>工具</Th>
|
||||
<Th className="text-right">耗时</Th>
|
||||
<Th>状态</Th>
|
||||
<Th>错误类型</Th>
|
||||
<Th>错误详情</Th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{isLoading && (
|
||||
<tr>
|
||||
<td colSpan={9} className="py-10 text-center">
|
||||
<Loader2 className="text-muted-foreground inline size-5 animate-spin" />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && error && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-destructive py-10 text-center text-sm">
|
||||
加载失败:{error.message}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{!isLoading && !error && items.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={9} className="text-muted-foreground py-10 text-center text-sm">
|
||||
暂无符合条件的调用记录。
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{items.map((row) => (
|
||||
<tr key={row.id} className="border-t">
|
||||
<Td>{formatDate(row.created_at)}</Td>
|
||||
<Td title={row.user_id ?? undefined}>{displayUser(row)}</Td>
|
||||
<Td>{row.agent_name ?? "—"}</Td>
|
||||
<Td className="font-medium">
|
||||
{row.skill_name ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setSkill(row.skill_name as string);
|
||||
setPage(0);
|
||||
}}
|
||||
className="hover:text-foreground underline-offset-2 hover:underline"
|
||||
title="点击只看该技能"
|
||||
>
|
||||
{row.skill_name}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</Td>
|
||||
<Td className="font-mono text-xs">{row.tool_name ?? "—"}</Td>
|
||||
<Td className="text-right tabular-nums" title={`${row.duration_ms} ms`}>
|
||||
{formatDuration(row.duration_ms)}
|
||||
</Td>
|
||||
<Td>
|
||||
<Badge variant={row.status === "success" ? "default" : "destructive"}>
|
||||
{row.status === "success" ? "成功" : "失败"}
|
||||
</Badge>
|
||||
</Td>
|
||||
<Td>
|
||||
{row.error_category ? (
|
||||
<Badge variant="outline">{categoryLabel(row.error_category)}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)}
|
||||
</Td>
|
||||
<Td className="max-w-[300px]">
|
||||
{row.error_message ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDetailRow(row)}
|
||||
className="text-muted-foreground hover:text-foreground block max-w-full truncate text-left underline-offset-2 hover:underline"
|
||||
title="点击查看完整报错"
|
||||
>
|
||||
{row.error_message}
|
||||
</button>
|
||||
) : (
|
||||
<span className="text-muted-foreground">—</span>
|
||||
)}
|
||||
</Td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="flex items-center justify-between border-t px-4 py-2 text-xs">
|
||||
<span className="text-muted-foreground">
|
||||
{isFetching && !isLoading ? "更新中..." : `共 ${total} 条`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page <= 0 || isLoading}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-muted-foreground">
|
||||
第 {page + 1} / {totalPages} 页
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={page + 1 >= totalPages || isLoading}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Full error detail popup */}
|
||||
<Dialog
|
||||
open={detailRow !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setDetailRow(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>工具调用报错详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailRow && (
|
||||
<div className="flex flex-col gap-3 text-sm">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
<DetailField label="时间" value={formatDate(detailRow.created_at)} />
|
||||
<DetailField label="用户" value={displayUser(detailRow)} />
|
||||
<DetailField label="工具" value={detailRow.tool_name ?? "—"} mono />
|
||||
<DetailField label="技能" value={detailRow.skill_name ?? "—"} />
|
||||
<DetailField label="Agent" value={detailRow.agent_name ?? "—"} />
|
||||
<DetailField
|
||||
label="状态"
|
||||
value={detailRow.status === "success" ? "成功" : "失败"}
|
||||
/>
|
||||
<DetailField
|
||||
label="错误类型"
|
||||
value={categoryLabel(detailRow.error_category)}
|
||||
/>
|
||||
<DetailField label="耗时" value={formatDuration(detailRow.duration_ms)} />
|
||||
<DetailField label="Thread" value={detailRow.thread_id ?? "—"} mono />
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground text-xs">完整报错信息</span>
|
||||
<pre className="bg-muted mt-1 max-h-72 overflow-auto whitespace-pre-wrap break-all rounded-md p-3 text-xs">
|
||||
{detailRow.error_message ?? "(无)"}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
mono,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
mono?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-muted-foreground text-xs">{label}</span>
|
||||
<span className={cn("break-all", mono && "font-mono text-xs")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Th({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<th className={`px-3 py-2 text-left text-xs font-medium ${className ?? ""}`}>
|
||||
{children}
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
function Td({
|
||||
children,
|
||||
className,
|
||||
title,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<td className={cn("px-3 py-2 align-top text-sm", className)} title={title}>
|
||||
{children}
|
||||
</td>
|
||||
);
|
||||
}
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import {
|
||||
BotIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
GlobeIcon,
|
||||
InfoIcon,
|
||||
LockIcon,
|
||||
MessageSquareIcon,
|
||||
SquarePenIcon,
|
||||
TagsIcon,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
import { TagChipList } from "@/components/workspace/tags/tag-chip";
|
||||
import { TagPickerDialog } from "@/components/workspace/tags/tag-picker-dialog";
|
||||
import { useDeleteAgent, useUpdateAgent } from "@/core/agents";
|
||||
@@ -62,6 +64,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
const [selectedSkills, setSelectedSkills] = useState<string[]>(
|
||||
agent.skills ?? [],
|
||||
);
|
||||
const [detailSkillName, setDetailSkillName] = useState<string | null>(null);
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -204,6 +207,7 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title="该智能体已对所有用户可见"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
已发布
|
||||
</Badge>
|
||||
)}
|
||||
@@ -238,7 +242,12 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
<Badge
|
||||
key={`sk:${skill}`}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
className="hover:bg-secondary/70 cursor-pointer text-xs"
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDetailSkillName(skill);
|
||||
}}
|
||||
>
|
||||
{skill}
|
||||
</Badge>
|
||||
@@ -275,9 +284,9 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
title={agent.published ? "取消发布" : "发布"}
|
||||
>
|
||||
{agent.published ? (
|
||||
<EyeOffIcon className="h-3.5 w-3.5" />
|
||||
<LockIcon className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="h-3.5 w-3.5" />
|
||||
<GlobeIcon className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -353,17 +362,31 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
{enabledSkills.map((skill) => {
|
||||
const checked = selectedSkills.includes(skill.name);
|
||||
return (
|
||||
<Button
|
||||
<div
|
||||
key={skill.name}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={skill.description}
|
||||
className="h-8"
|
||||
className="inline-flex max-w-full items-center"
|
||||
>
|
||||
{skill.name}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => toggleSkill(skill.name)}
|
||||
title={skill.description}
|
||||
className="h-8 max-w-[200px] rounded-r-none"
|
||||
>
|
||||
<span className="truncate">{skill.name}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant={checked ? "default" : "outline"}
|
||||
onClick={() => setDetailSkillName(skill.name)}
|
||||
title={t.settings.skills.viewDetailAction}
|
||||
className="h-8 w-7 rounded-l-none border-l-0"
|
||||
>
|
||||
<InfoIcon className="size-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
@@ -445,6 +468,14 @@ export function AgentCard({ agent }: AgentCardProps) {
|
||||
currentTags={agent.tags ?? []}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SkillDetailDialog
|
||||
skillName={detailSkillName}
|
||||
open={detailSkillName !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDetailSkillName(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -18,9 +18,9 @@ export function AgentGallery() {
|
||||
const router = useRouter();
|
||||
const [activeTab, setActiveTab] = useState<"all" | "mine">("mine");
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/agents query.
|
||||
const { agents, isLoading } = useAgents({ search, tagId });
|
||||
const { agents, isLoading } = useAgents({ search, tagIds });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -87,7 +87,7 @@ export function AgentGallery() {
|
||||
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">
|
||||
<div className="mx-6 mt-4 flex flex-col gap-3">
|
||||
<TabsList className="w-auto self-start">
|
||||
<TabsTrigger value="mine">我的</TabsTrigger>
|
||||
<TabsTrigger value="all">广场</TabsTrigger>
|
||||
@@ -95,9 +95,8 @@ export function AgentGallery() {
|
||||
<TagSearchBar
|
||||
targetType="agent"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="w-[420px] max-w-[50%]"
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,16 +2,17 @@
|
||||
|
||||
import {
|
||||
ArrowDownIcon,
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
Code2Icon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
EyeIcon,
|
||||
FileType2Icon,
|
||||
LoaderIcon,
|
||||
PackageIcon,
|
||||
SaveIcon,
|
||||
SquareArrowOutUpRightIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Streamdown } from "streamdown";
|
||||
@@ -34,16 +35,17 @@ 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,
|
||||
useUpdateArtifactContent,
|
||||
} 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 { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
import { installSkill } from "@/core/skills/api";
|
||||
import { streamdownPlugins } from "@/core/streamdown";
|
||||
import { checkCodeFile, getFileName } from "@/core/utils/files";
|
||||
@@ -89,7 +91,7 @@ export function ArtifactFileDetail({
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { artifacts, setOpen, select } = useArtifacts();
|
||||
const { isMock, thread } = useThread();
|
||||
const { isMock, thread, publicShareCode } = useThread();
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepathFromProps.startsWith("write-file:");
|
||||
}, [filepathFromProps]);
|
||||
@@ -117,18 +119,18 @@ export function ArtifactFileDetail({
|
||||
const isSupportPreview = useMemo(() => {
|
||||
return language === "html" || language === "markdown";
|
||||
}, [language]);
|
||||
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 { 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(() => {
|
||||
@@ -148,20 +150,20 @@ export function ArtifactFileDetail({
|
||||
const [binaryPreviewUrl, setBinaryPreviewUrl] = useState<string | 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]);
|
||||
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);
|
||||
@@ -279,7 +281,7 @@ export function ArtifactFileDetail({
|
||||
(async () => {
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
urlOfArtifact({ filepath, threadId, isMock }),
|
||||
urlOfArtifact({ filepath, threadId, isMock, publicShareCode }),
|
||||
);
|
||||
if (cancelled || !res.ok) return;
|
||||
const blob = await res.blob();
|
||||
@@ -304,7 +306,7 @@ export function ArtifactFileDetail({
|
||||
return null;
|
||||
});
|
||||
};
|
||||
}, [filepath, threadId, isMock, isCodeFile]);
|
||||
}, [filepath, threadId, isMock, isCodeFile, publicShareCode]);
|
||||
|
||||
const handlePauseAutoScroll = useCallback(() => {
|
||||
if (!thread.isLoading || !autoScrollEnabled) {
|
||||
@@ -329,7 +331,7 @@ export function ArtifactFileDetail({
|
||||
}, 1600);
|
||||
}, [scrollArtifactToBottom]);
|
||||
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
const handleInstallSkill = useCallback(async () => {
|
||||
if (isInstalling) return;
|
||||
|
||||
setIsInstalling(true);
|
||||
@@ -349,33 +351,53 @@ export function ArtifactFileDetail({
|
||||
} finally {
|
||||
setIsInstalling(false);
|
||||
}
|
||||
}, [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,
|
||||
]);
|
||||
}, [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,
|
||||
]);
|
||||
|
||||
const [isExportingWord, setIsExportingWord] = useState(false);
|
||||
const handleDownloadAsWord = useCallback(async () => {
|
||||
const source = (displayContent ?? "").trim();
|
||||
if (!source) {
|
||||
toast.error("Markdown 内容为空,无法另存为 Word");
|
||||
return;
|
||||
}
|
||||
setIsExportingWord(true);
|
||||
try {
|
||||
const baseName = getFileName(filepath).replace(/\.(md|markdown)$/i, "");
|
||||
await downloadMarkdownAsDocx(source, baseName || "document", baseName);
|
||||
toast.success("已另存为 Word");
|
||||
} catch (error) {
|
||||
console.error("Failed to export markdown as docx:", error);
|
||||
toast.error(error instanceof Error ? error.message : "另存为 Word 失败");
|
||||
} finally {
|
||||
setIsExportingWord(false);
|
||||
}
|
||||
}, [displayContent, filepath]);
|
||||
|
||||
return (
|
||||
<Artifact className={cn(className)}>
|
||||
@@ -452,19 +474,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">
|
||||
{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}>
|
||||
<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}
|
||||
@@ -488,6 +510,7 @@ export function ArtifactFileDetail({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -517,6 +540,15 @@ export function ArtifactFileDetail({
|
||||
tooltip={t.clipboard.copyToClipboard}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && language === "markdown" && (
|
||||
<ArtifactAction
|
||||
icon={isExportingWord ? LoaderIcon : FileType2Icon}
|
||||
label="另存为 Word"
|
||||
tooltip="另存为 Word (.docx)"
|
||||
disabled={isExportingWord || !displayContent}
|
||||
onClick={handleDownloadAsWord}
|
||||
/>
|
||||
)}
|
||||
{!isWriteFile && (
|
||||
<ArtifactAction
|
||||
icon={DownloadIcon}
|
||||
@@ -528,6 +560,7 @@ export function ArtifactFileDetail({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
@@ -550,19 +583,19 @@ export function ArtifactFileDetail({
|
||||
</ArtifactActions>
|
||||
</div>
|
||||
</ArtifactHeader>
|
||||
<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
|
||||
<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"
|
||||
@@ -574,31 +607,31 @@ export function ArtifactFileDetail({
|
||||
{isSupportPreview &&
|
||||
viewMode === "preview" &&
|
||||
(language === "markdown" || language === "html") && (
|
||||
<ArtifactFilePreview
|
||||
content={displayContent}
|
||||
language={language ?? "text"}
|
||||
animated={showPreviewSwitchPulse}
|
||||
activeCursor={isApplyingEdit}
|
||||
/>
|
||||
)}
|
||||
<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={!canEditArtifact}
|
||||
onChange={
|
||||
canEditArtifact
|
||||
? (value) => {
|
||||
setDraftContent(value);
|
||||
setHasLocalEdits(true);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
streamingReadonlyFallback={!canEditArtifact}
|
||||
/>
|
||||
</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} />
|
||||
@@ -613,17 +646,17 @@ export function ArtifactFileDetail({
|
||||
);
|
||||
}
|
||||
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
export function ArtifactFilePreview({
|
||||
content,
|
||||
language,
|
||||
animated = false,
|
||||
activeCursor = false,
|
||||
}: {
|
||||
content: string;
|
||||
language: string;
|
||||
animated?: boolean;
|
||||
activeCursor?: boolean;
|
||||
}) {
|
||||
const [htmlPreviewUrl, setHtmlPreviewUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -654,15 +687,15 @@ export function ArtifactFilePreview({
|
||||
className="size-full"
|
||||
{...streamdownPlugins}
|
||||
components={{ a: ArtifactLink }}
|
||||
>
|
||||
{content ?? ""}
|
||||
</Streamdown>
|
||||
{activeCursor && (
|
||||
<span className="mt-2 inline-block h-5 w-0.5 animate-pulse rounded-full bg-[#9b7a2f]" />
|
||||
)}
|
||||
</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 (
|
||||
|
||||
@@ -33,7 +33,7 @@ export function ArtifactFileList({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { isMock } = useThread();
|
||||
const { isMock, publicShareCode } = useThread();
|
||||
const { select: selectArtifact, setOpen } = useArtifacts();
|
||||
const [installingFile, setInstallingFile] = useState<string | null>(null);
|
||||
const [downloadingFile, setDownloadingFile] = useState<string | null>(null);
|
||||
@@ -81,7 +81,7 @@ export function ArtifactFileList({
|
||||
if (downloadingFile) return;
|
||||
setDownloadingFile(filepath);
|
||||
try {
|
||||
await downloadArtifactFile({ filepath, threadId, isMock });
|
||||
await downloadArtifactFile({ filepath, threadId, isMock, publicShareCode });
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
toast.error(
|
||||
@@ -91,7 +91,7 @@ export function ArtifactFileList({
|
||||
setDownloadingFile(null);
|
||||
}
|
||||
},
|
||||
[threadId, isMock, downloadingFile],
|
||||
[threadId, isMock, publicShareCode, downloadingFile],
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -94,3 +94,7 @@ export function useArtifacts() {
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
export function useArtifactsOptional(): ArtifactsContextType | null {
|
||||
return useContext(ArtifactsContext) ?? null;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { Download, FileJson, FileText } from "lucide-react";
|
||||
import { Download, FileJson, FileText, FileType2 } from "lucide-react";
|
||||
import { useCallback } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
exportThreadAsJSON,
|
||||
exportThreadAsMarkdown,
|
||||
exportThreadAsWord,
|
||||
} from "@/core/threads/export";
|
||||
import type { AgentThread } from "@/core/threads/types";
|
||||
|
||||
@@ -28,7 +29,7 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
const messages = thread.messages;
|
||||
|
||||
const handleExport = useCallback(
|
||||
(format: "markdown" | "json") => {
|
||||
async (format: "markdown" | "json" | "word") => {
|
||||
if (messages.length === 0) {
|
||||
toast.error(t.conversation.noMessages);
|
||||
return;
|
||||
@@ -39,12 +40,18 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
values: thread.values,
|
||||
} as AgentThread;
|
||||
|
||||
if (format === "markdown") {
|
||||
exportThreadAsMarkdown(agentThread, messages);
|
||||
} else {
|
||||
exportThreadAsJSON(agentThread, messages);
|
||||
try {
|
||||
if (format === "markdown") {
|
||||
exportThreadAsMarkdown(agentThread, messages);
|
||||
} else if (format === "json") {
|
||||
exportThreadAsJSON(agentThread, messages);
|
||||
} else {
|
||||
await exportThreadAsWord(agentThread, messages);
|
||||
}
|
||||
toast.success(t.common.exportSuccess);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "导出失败");
|
||||
}
|
||||
toast.success(t.common.exportSuccess);
|
||||
},
|
||||
[messages, thread.values, threadId, t],
|
||||
);
|
||||
@@ -67,11 +74,15 @@ export function ExportTrigger({ threadId }: { threadId: string }) {
|
||||
</DropdownMenuTrigger>
|
||||
</Tooltip>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onSelect={() => handleExport("markdown")}>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("markdown")}>
|
||||
<FileText className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsMarkdown}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => handleExport("json")}>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("word")}>
|
||||
<FileType2 className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsWord}</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={() => void handleExport("json")}>
|
||||
<FileJson className="text-muted-foreground" />
|
||||
<span>{t.common.exportAsJSON}</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -6,6 +6,13 @@ import type { AgentThreadState } from "@/core/threads";
|
||||
export interface ThreadContextType {
|
||||
thread: BaseStream<AgentThreadState>;
|
||||
isMock?: boolean;
|
||||
/**
|
||||
* When set, the conversation is rendered from a public read-only share.
|
||||
* Artifact/image URLs then resolve to the unauthenticated
|
||||
* `/api/public/shares/{code}/files/...` endpoint instead of the
|
||||
* auth-gated thread artifact route.
|
||||
*/
|
||||
publicShareCode?: string;
|
||||
}
|
||||
|
||||
export const ThreadContext = createContext<ThreadContextType | undefined>(
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { FileText, Loader2 } from "lucide-react";
|
||||
import { useState, type ComponentProps } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
export function DownloadWordButton({
|
||||
markdown,
|
||||
fileName,
|
||||
title,
|
||||
...props
|
||||
}: ComponentProps<typeof Button> & {
|
||||
markdown: string;
|
||||
fileName?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const handleDownload = async () => {
|
||||
const source = markdown?.trim();
|
||||
if (!source) {
|
||||
toast.error("无可下载的 Markdown 内容");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
await downloadMarkdownAsDocx(source, fileName || "message", title);
|
||||
toast.success("Word 文件已生成");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "生成 Word 失败");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<Tooltip content="下载为 Word">
|
||||
<Button
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={handleDownload}
|
||||
{...props}
|
||||
>
|
||||
{busy ? (
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
) : (
|
||||
<FileText size={12} />
|
||||
)}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -33,7 +33,7 @@ import { extractTitleFromMarkdown } from "@/core/utils/markdown";
|
||||
import { env } from "@/env";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
import { useArtifacts } from "../artifacts";
|
||||
import { useArtifactsOptional } from "../artifacts";
|
||||
import { FlipDisplay } from "../flip-display";
|
||||
import { Tooltip } from "../tooltip";
|
||||
|
||||
@@ -201,8 +201,7 @@ function ToolCall({
|
||||
isLoading?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { setOpen, autoOpen, autoSelect, selectedArtifact, select } =
|
||||
useArtifacts();
|
||||
const artifacts = useArtifactsOptional();
|
||||
|
||||
if (name === "web_search") {
|
||||
let label: React.ReactNode = t.toolCalls.searchForRelatedInfo;
|
||||
@@ -329,43 +328,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;
|
||||
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);
|
||||
}
|
||||
} 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 (artifacts && isLoading && isLast && artifacts.autoOpen && artifacts.autoSelect && path && !result) {
|
||||
setTimeout(() => {
|
||||
if (!artifactPath || artifacts.selectedArtifact === artifactPath) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath, true);
|
||||
artifacts.setOpen(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
return (
|
||||
<ChainOfThoughtStep
|
||||
key={id}
|
||||
className="cursor-pointer"
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath) {
|
||||
return;
|
||||
}
|
||||
select(artifactPath);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
label={description}
|
||||
icon={NotebookPenIcon}
|
||||
onClick={() => {
|
||||
if (!artifactPath || !artifacts) {
|
||||
return;
|
||||
}
|
||||
artifacts.select(artifactPath);
|
||||
artifacts.setOpen(true);
|
||||
}}
|
||||
>
|
||||
{path && (
|
||||
<ChainOfThoughtSearchResult className="cursor-pointer">
|
||||
{path}
|
||||
@@ -412,6 +411,17 @@ function ToolCall({
|
||||
icon={ListTodoIcon}
|
||||
></ChainOfThoughtStep>
|
||||
);
|
||||
} else if (name === "update_artifact") {
|
||||
const title = typeof args.title === "string" ? args.title : undefined;
|
||||
const type = args.type as string | undefined;
|
||||
const label = title ?? (type === "code" ? "生成代码" : "生成文档");
|
||||
return (
|
||||
<ChainOfThoughtStep key={id} label={label} icon={NotebookPenIcon}>
|
||||
<ChainOfThoughtSearchResult>
|
||||
{type === "code" ? "代码文件" : "Markdown 文档"}
|
||||
</ChainOfThoughtSearchResult>
|
||||
</ChainOfThoughtStep>
|
||||
);
|
||||
} else {
|
||||
const description: string | undefined = (args as { description: string })
|
||||
?.description;
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { Message } from "@langchain/langgraph-sdk";
|
||||
import { FileIcon, Loader2Icon } from "lucide-react";
|
||||
import {
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useMemo,
|
||||
type AnchorHTMLAttributes,
|
||||
type ImgHTMLAttributes,
|
||||
@@ -23,11 +25,13 @@ import {
|
||||
import { Task, TaskTrigger } from "@/components/ai-elements/task";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { resolveArtifactURL } from "@/core/artifacts/utils";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
parseUploadedFiles,
|
||||
stripArtifactTag,
|
||||
stripUploadedFilesTag,
|
||||
type FileInMessage,
|
||||
} from "@/core/messages/utils";
|
||||
@@ -37,9 +41,34 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
import { CopyButton } from "../copy-button";
|
||||
|
||||
import { ThreadContext } from "./context";
|
||||
import { DownloadWordButton } from "./download-word-button";
|
||||
import { MarkdownContent } from "./markdown-content";
|
||||
import { MessageTokenUsage } from "./message-token-usage";
|
||||
|
||||
/**
|
||||
* Resolve an in-conversation file path to a fetchable URL.
|
||||
*
|
||||
* On a public read-only share (`ThreadContext.publicShareCode` set) artifact
|
||||
* URLs point at the unauthenticated `/api/public/shares/{code}/files/...`
|
||||
* endpoint; otherwise they use the normal auth-gated thread artifact route.
|
||||
*/
|
||||
function useArtifactResolver(threadId: string): (path: string) => string {
|
||||
const publicShareCode = useContext(ThreadContext)?.publicShareCode;
|
||||
return useCallback(
|
||||
(path: string) => {
|
||||
if (publicShareCode) {
|
||||
const normalized = path.startsWith("/") ? path : `/${path}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
return resolveArtifactURL(path, threadId);
|
||||
},
|
||||
[publicShareCode, threadId],
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageListItem({
|
||||
className,
|
||||
message,
|
||||
@@ -54,6 +83,10 @@ export function MessageListItem({
|
||||
tokenUsageEnabled?: boolean;
|
||||
}) {
|
||||
const isHuman = message.type === "human";
|
||||
const messageMarkdown =
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
"";
|
||||
return (
|
||||
<AIElementMessage
|
||||
className={cn("group/conversation-message relative w-full", className)}
|
||||
@@ -74,13 +107,13 @@ export function MessageListItem({
|
||||
)}
|
||||
>
|
||||
<div className="flex gap-1">
|
||||
<CopyButton
|
||||
clipboardData={
|
||||
extractContentFromMessage(message) ??
|
||||
extractReasoningContentFromMessage(message) ??
|
||||
""
|
||||
}
|
||||
/>
|
||||
<CopyButton clipboardData={messageMarkdown} />
|
||||
{!isHuman && messageMarkdown && (
|
||||
<DownloadWordButton
|
||||
markdown={messageMarkdown}
|
||||
fileName="message"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</MessageToolbar>
|
||||
)}
|
||||
@@ -101,6 +134,7 @@ function MessageImage({
|
||||
threadId: string;
|
||||
maxWidth?: string;
|
||||
}) {
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
if (!src) return null;
|
||||
|
||||
const imgClassName = cn("overflow-hidden rounded-lg", `max-w-[${maxWidth}]`);
|
||||
@@ -109,7 +143,7 @@ function MessageImage({
|
||||
return <img className={imgClassName} src={src} alt={alt} {...props} />;
|
||||
}
|
||||
|
||||
const url = src.startsWith("/mnt/") ? resolveArtifactURL(src, threadId) : src;
|
||||
const url = src.startsWith("/mnt/") ? resolveArtifact(src) : src;
|
||||
|
||||
return (
|
||||
<a href={url} target="_blank" rel="noopener noreferrer">
|
||||
@@ -133,6 +167,7 @@ function MessageContent_({
|
||||
}) {
|
||||
const rehypePlugins = useRehypeSplitWordsIntoSpans(isLoading);
|
||||
const isHuman = message.type === "human";
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const components = useMemo(
|
||||
() => ({
|
||||
img: (props: ImgHTMLAttributes<HTMLImageElement>) => (
|
||||
@@ -140,7 +175,7 @@ function MessageContent_({
|
||||
),
|
||||
a: ({ href, ...props }: AnchorHTMLAttributes<HTMLAnchorElement>) => {
|
||||
if (href?.startsWith("/mnt/")) {
|
||||
const url = resolveArtifactURL(href, threadId);
|
||||
const url = resolveArtifact(href);
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
@@ -153,7 +188,7 @@ function MessageContent_({
|
||||
return <a {...props} href={href} />;
|
||||
},
|
||||
}),
|
||||
[threadId],
|
||||
[threadId, resolveArtifact],
|
||||
);
|
||||
|
||||
const rawContent = extractContentFromMessage(message);
|
||||
@@ -173,7 +208,7 @@ function MessageContent_({
|
||||
|
||||
const contentToDisplay = useMemo(() => {
|
||||
if (isHuman) {
|
||||
return rawContent ? stripUploadedFilesTag(rawContent) : "";
|
||||
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
|
||||
}
|
||||
return rawContent ?? "";
|
||||
}, [rawContent, isHuman]);
|
||||
@@ -346,6 +381,7 @@ function RichFileCard({
|
||||
threadId: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const resolveArtifact = useArtifactResolver(threadId);
|
||||
const isUploading = file.status === "uploading";
|
||||
const isImage = isImageFile(file.filename);
|
||||
|
||||
@@ -378,7 +414,7 @@ function RichFileCard({
|
||||
|
||||
if (!file.path) return null;
|
||||
|
||||
const fileUrl = resolveArtifactURL(file.path, threadId);
|
||||
const fileUrl = resolveArtifact(file.path);
|
||||
|
||||
if (isImage) {
|
||||
return (
|
||||
|
||||
@@ -88,7 +88,13 @@ export function MessageTokenUsage({
|
||||
// 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;
|
||||
// The speed pill is shown once per turn — only on the turn-final AI message
|
||||
// — so a turn with several LLM calls no longer renders a pill per call.
|
||||
const showSpeed =
|
||||
speedEnabled &&
|
||||
resolvedMetric !== undefined &&
|
||||
resolvedMetric.durationMs > 0 &&
|
||||
resolvedMetric.isTurnFinal;
|
||||
|
||||
if (!showUsage && !showSpeed) {
|
||||
return null;
|
||||
|
||||
@@ -36,6 +36,13 @@ export interface MessageMetric {
|
||||
* only to add a "~" prefix when the value is approximate.
|
||||
*/
|
||||
fromUsage: boolean;
|
||||
/**
|
||||
* True iff this is the turn-final AI message — the last AI message before
|
||||
* the next human message. An agentic turn makes several LLM calls, each its
|
||||
* own AI message; the speed pill is shown only on the turn-final one so a
|
||||
* turn renders a single pill instead of one per call.
|
||||
*/
|
||||
isTurnFinal: boolean;
|
||||
}
|
||||
|
||||
type Sample = { at: number; chars: number };
|
||||
@@ -161,6 +168,25 @@ export function useMessageMetrics(
|
||||
return () => window.clearInterval(interval);
|
||||
}, [isLoading]);
|
||||
|
||||
// Identify the turn-final AI message of each turn — the last AI message
|
||||
// (with a recorded metric) before the next human message. Only these show
|
||||
// the speed pill, collapsing a multi-LLM-call turn to a single pill.
|
||||
const turnFinalIds = new Set<string>();
|
||||
let pendingTurnFinal: string | null = null;
|
||||
for (let i = 0; i < messages.length; i++) {
|
||||
const message = messages[i];
|
||||
if (!message) continue;
|
||||
if (message.type === "human") {
|
||||
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
|
||||
pendingTurnFinal = null;
|
||||
continue;
|
||||
}
|
||||
if (isAiMessage(message) && timingRef.current.has(getMessageId(message, i))) {
|
||||
pendingTurnFinal = getMessageId(message, i);
|
||||
}
|
||||
}
|
||||
if (pendingTurnFinal) turnFinalIds.add(pendingTurnFinal);
|
||||
|
||||
// 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>();
|
||||
@@ -212,6 +238,7 @@ export function useMessageMetrics(
|
||||
durationMs,
|
||||
isActive,
|
||||
fromUsage,
|
||||
isTurnFinal: turnFinalIds.has(id),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ import {
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
} from "@/components/ui/sidebar";
|
||||
import { ShareConversationDialog } from "@/components/workspace/thread-share-dialogs";
|
||||
import { getAPIClient } from "@/core/api";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
@@ -76,6 +77,9 @@ export function RecentChatList() {
|
||||
const [renameThreadId, setRenameThreadId] = useState<string | null>(null);
|
||||
const [renameValue, setRenameValue] = useState("");
|
||||
|
||||
// Share dialog state — the conversation whose share code is being shown.
|
||||
const [shareThreadId, setShareThreadId] = useState<string | null>(null);
|
||||
|
||||
const handleDelete = useCallback(
|
||||
(threadId: string) => {
|
||||
deleteThread({ threadId });
|
||||
@@ -115,18 +119,10 @@ export function RecentChatList() {
|
||||
}
|
||||
}, [renameThread, renameThreadId, renameValue]);
|
||||
|
||||
const handleShare = useCallback(
|
||||
async (thread: AgentThread) => {
|
||||
const shareUrl = `${window.location.origin}${pathOfThread(thread)}`;
|
||||
try {
|
||||
await navigator.clipboard.writeText(shareUrl);
|
||||
toast.success(t.clipboard.linkCopied);
|
||||
} catch {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
const handleShare = useCallback((thread: AgentThread) => {
|
||||
// Opens the share-code dialog; the recipient imports a copy via that code.
|
||||
setShareThreadId(thread.thread_id);
|
||||
}, []);
|
||||
|
||||
const handleExport = useCallback(
|
||||
async (thread: AgentThread, format: "markdown" | "json") => {
|
||||
@@ -173,89 +169,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>
|
||||
<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>
|
||||
);
|
||||
})}
|
||||
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>
|
||||
@@ -291,6 +287,15 @@ export function RecentChatList() {
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Share Dialog */}
|
||||
<ShareConversationDialog
|
||||
threadId={shareThreadId}
|
||||
open={shareThreadId !== null}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setShareThreadId(null);
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
// ---- 时间配置 ----
|
||||
export type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
|
||||
|
||||
export interface ScheduleConfig {
|
||||
freq: ScheduleFreq;
|
||||
minute: number;
|
||||
hour: number;
|
||||
weekday: number;
|
||||
date: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_SCHEDULE_CONFIG: ScheduleConfig = {
|
||||
freq: "daily",
|
||||
minute: 0,
|
||||
hour: 9,
|
||||
weekday: 1,
|
||||
date: 1,
|
||||
};
|
||||
|
||||
const WEEKDAYS = ["周日", "周一", "周二", "周三", "周四", "周五", "周六"];
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => i);
|
||||
const MINUTES = [0, 15, 30, 45];
|
||||
const DATES = Array.from({ length: 31 }, (_, i) => i + 1);
|
||||
|
||||
export function buildCron(cfg: ScheduleConfig): string {
|
||||
// Keep the minute slot when the user picked one for daily/weekly/monthly too,
|
||||
// so "每天 9:30" doesn't silently truncate to 9:00 on save → reload round trips.
|
||||
const m = Number.isFinite(cfg.minute) ? cfg.minute : 0;
|
||||
switch (cfg.freq) {
|
||||
case "hourly":
|
||||
return `${m} * * * *`;
|
||||
case "daily":
|
||||
return `${m} ${cfg.hour} * * *`;
|
||||
case "weekly":
|
||||
return `${m} ${cfg.hour} * * ${cfg.weekday}`;
|
||||
case "monthly":
|
||||
return `${m} ${cfg.hour} ${cfg.date} * *`;
|
||||
}
|
||||
}
|
||||
|
||||
// Parse a 5-field cron expression into the form picker state.
|
||||
//
|
||||
// Bug we keep getting bitten by: the edit dialog used to call
|
||||
// `parseCron(task.schedule_text)` and `schedule_text` may be the raw user
|
||||
// input (free-text like "每天 9 点") rather than a real cron string. The
|
||||
// fallback `DEFAULT_SCHEDULE_CONFIG` then made the picker silently
|
||||
// reinterpret the task. Callers should prefer `task.cron_expr` (the canonical
|
||||
// schedule produced by `_schedule_to_cron`); this function stays the single
|
||||
// source of truth for cron → picker, and also handles the looser step-syntax
|
||||
// variants (e.g. "0 */3 * * *") we sometimes generate.
|
||||
export function parseCron(cron: string | null | undefined): ScheduleConfig | null {
|
||||
if (!cron) return null;
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [min, hr, date, , wd] = parts;
|
||||
const minute = parseInt(min ?? "", 10);
|
||||
const hour = parseInt(hr ?? "", 10);
|
||||
|
||||
// Hourly: ``M * * * *`` — match before the daily branch so a minute-only
|
||||
// cron isn't misread as daily.
|
||||
if (hr === "*" && date === "*" && wd === "*") {
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
// ``0 */N * * *`` (every N hours): map to hourly with that minute; the
|
||||
// picker doesn't model "every N hours" yet, so this is the safest fallback
|
||||
// that at least doesn't silently round to 9:00.
|
||||
if ((hr ?? "").startsWith("*/") && date === "*" && wd === "*") {
|
||||
if (!isNaN(minute)) {
|
||||
return { ...DEFAULT_SCHEDULE_CONFIG, freq: "hourly", minute };
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd === "*") {
|
||||
if (!isNaN(hour)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "daily",
|
||||
hour,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (date === "*" && wd !== "*") {
|
||||
const weekday = parseInt(wd ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(weekday)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "weekly",
|
||||
hour,
|
||||
weekday,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
if (wd === "*" && date !== "*") {
|
||||
const d = parseInt(date ?? "", 10);
|
||||
if (!isNaN(hour) && !isNaN(d)) {
|
||||
return {
|
||||
...DEFAULT_SCHEDULE_CONFIG,
|
||||
freq: "monthly",
|
||||
hour,
|
||||
date: d,
|
||||
minute: isNaN(minute) ? 0 : minute,
|
||||
};
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function ScheduleBuilder({
|
||||
value,
|
||||
onChange,
|
||||
}: {
|
||||
value: ScheduleConfig;
|
||||
onChange: (cfg: ScheduleConfig) => void;
|
||||
}) {
|
||||
const set = (patch: Partial<ScheduleConfig>) => onChange({ ...value, ...patch });
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Select value={value.freq} onValueChange={(v) => set({ freq: v as ScheduleFreq })}>
|
||||
<SelectTrigger className="w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hourly">每小时</SelectItem>
|
||||
<SelectItem value="daily">每天</SelectItem>
|
||||
<SelectItem value="weekly">每周</SelectItem>
|
||||
<SelectItem value="monthly">每月</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{value.freq === "hourly" && (
|
||||
<Select value={String(value.minute)} onValueChange={(v) => set({ minute: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={String(m)}>{m} 分</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq === "weekly" && (
|
||||
<Select value={String(value.weekday)} onValueChange={(v) => set({ weekday: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{WEEKDAYS.map((d, i) => (
|
||||
<SelectItem key={i} value={String(i)}>{d}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq === "monthly" && (
|
||||
<Select value={String(value.date)} onValueChange={(v) => set({ date: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{DATES.map((d) => (
|
||||
<SelectItem key={d} value={String(d)}>第 {d} 日</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{value.freq !== "hourly" && (
|
||||
<Select value={String(value.hour)} onValueChange={(v) => set({ hour: parseInt(v) })}>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={String(h)}>{h} 时</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function statusLabel(status?: string | null) {
|
||||
switch (status) {
|
||||
case "succeeded":
|
||||
return "成功";
|
||||
case "failed":
|
||||
return "失败";
|
||||
case "running":
|
||||
return "执行中";
|
||||
case "pending":
|
||||
return "等待中";
|
||||
default:
|
||||
return status || "未知";
|
||||
}
|
||||
}
|
||||
|
||||
export function statusVariant(
|
||||
status?: string | null,
|
||||
): "default" | "secondary" | "destructive" | "outline" {
|
||||
if (status === "succeeded") return "default";
|
||||
if (status === "failed") return "destructive";
|
||||
if (status === "running") return "secondary";
|
||||
return "outline";
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { BrainIcon, PaletteIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { BellIcon, PaletteIcon, SparklesIcon, UserIcon, WrenchIcon } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import {
|
||||
@@ -50,11 +50,19 @@ export function SettingsDialog(props: SettingsDialogProps) {
|
||||
label: t.settings.sections.appearance,
|
||||
icon: PaletteIcon,
|
||||
},
|
||||
{
|
||||
id: "memory",
|
||||
label: t.settings.sections.memory,
|
||||
icon: BrainIcon,
|
||||
},
|
||||
// {
|
||||
// id: "notification",
|
||||
// label: t.settings.sections.notification,
|
||||
// icon: BellIcon,
|
||||
// },
|
||||
// { id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
// { id: "skills", label: t.settings.sections.skills, icon: SparklesIcon },
|
||||
// 记忆已移至侧边栏入口 (/page/workspace/memory)
|
||||
// {
|
||||
// id: "memory",
|
||||
// label: t.settings.sections.memory,
|
||||
// icon: BrainIcon,
|
||||
// },
|
||||
{ id: "tools", label: t.settings.sections.tools, icon: WrenchIcon },
|
||||
{
|
||||
id: "account",
|
||||
|
||||
@@ -5,10 +5,10 @@ import {
|
||||
ArchiveIcon,
|
||||
CheckCircle2,
|
||||
ClockIcon,
|
||||
EyeIcon,
|
||||
EyeOffIcon,
|
||||
FileArchive,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
LockIcon,
|
||||
PinIcon,
|
||||
PinOffIcon,
|
||||
SparklesIcon,
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
ItemDescription,
|
||||
} from "@/components/ui/item";
|
||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { SkillDetailDialog } from "@/components/workspace/skills/skill-detail-dialog";
|
||||
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";
|
||||
@@ -58,7 +59,6 @@ import {
|
||||
useInstallSkillUpload,
|
||||
useSetSkillPinned,
|
||||
useSetSkillPublished,
|
||||
useSkillContent,
|
||||
useSkills,
|
||||
useValidateSkillUpload,
|
||||
} from "@/core/skills/hooks";
|
||||
@@ -89,9 +89,9 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
const [staleSheetOpen, setStaleSheetOpen] = useState(false);
|
||||
const [archiveSheetOpen, setArchiveSheetOpen] = useState(false);
|
||||
const [search, setSearch] = useState("");
|
||||
const [tagId, setTagId] = useState<string | null>(null);
|
||||
const [tagIds, setTagIds] = useState<string[]>([]);
|
||||
// Name + tag filtering is resolved server-side via the /api/skills query.
|
||||
const { skills, isLoading, error } = useSkills({ search, tagId });
|
||||
const { skills, isLoading, error } = useSkills({ search, tagIds });
|
||||
|
||||
const storedAuth = useMemo(() => getStoredAuth(), []);
|
||||
const currentUserId = storedAuth?.user_id ?? null;
|
||||
@@ -208,9 +208,8 @@ function SkillSettingsList({ onClose }: { onClose?: () => void }) {
|
||||
<TagSearchBar
|
||||
targetType="skill"
|
||||
onSearchChange={setSearch}
|
||||
tagId={tagId}
|
||||
onTagIdChange={setTagId}
|
||||
className="max-w-xl"
|
||||
tagIds={tagIds}
|
||||
onTagIdsChange={setTagIds}
|
||||
/>
|
||||
{isLoading && (
|
||||
<div className="text-muted-foreground text-sm">{t.common.loading}</div>
|
||||
@@ -358,6 +357,7 @@ function SkillRow({
|
||||
className="border-sky-500/30 text-sky-700 dark:text-sky-300 text-xs"
|
||||
title={t.settings.skills.badgePublishedTitle}
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{t.settings.skills.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
@@ -453,9 +453,9 @@ function SkillRow({
|
||||
}
|
||||
>
|
||||
{skill.published ? (
|
||||
<EyeOffIcon className="size-3.5" />
|
||||
<LockIcon className="size-3.5" />
|
||||
) : (
|
||||
<EyeIcon className="size-3.5" />
|
||||
<GlobeIcon className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
@@ -508,7 +508,7 @@ function SkillRow({
|
||||
/>
|
||||
)}
|
||||
<SkillDetailDialog
|
||||
skill={skill}
|
||||
skillName={skill.name}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
/>
|
||||
@@ -516,46 +516,6 @@ function SkillRow({
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CopyIcon,
|
||||
EyeIcon,
|
||||
FileCode2Icon,
|
||||
FileTextIcon,
|
||||
GlobeIcon,
|
||||
Loader2,
|
||||
PencilIcon,
|
||||
SparklesIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
|
||||
import { getStoredAuth } from "@/core/auth";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import {
|
||||
useSetSkillDetail,
|
||||
useSkill,
|
||||
useSkillContent,
|
||||
} from "@/core/skills/hooks";
|
||||
import type { Skill } from "@/core/skills/type";
|
||||
import { reasoningPlugins } from "@/core/streamdown";
|
||||
|
||||
const markdownClassName =
|
||||
"!h-auto !w-full max-w-none min-w-0 text-sm leading-7 " +
|
||||
"[&_h1]:mt-0 [&_h1]:mb-3 [&_h1]:text-xl [&_h1]:font-semibold " +
|
||||
"[&_h2]:mt-6 [&_h2]:mb-2 [&_h2]:border-b [&_h2]:pb-2 [&_h2]:text-lg [&_h2]:font-semibold " +
|
||||
"[&_h3]:mt-5 [&_h3]:mb-2 [&_h3]:text-base [&_h3]:font-semibold " +
|
||||
"[&_p]:my-3 [&_ul]:my-3 [&_ol]:my-3 [&_li]:my-1.5 " +
|
||||
"[&_ul]:list-disc [&_ol]:list-decimal [&_ul]:pl-5 [&_ol]:pl-5 " +
|
||||
"[&_blockquote]:my-4 [&_blockquote]:border-l-4 [&_blockquote]:pl-4 [&_blockquote]:text-muted-foreground " +
|
||||
"[&_code]:rounded [&_code]:border [&_code]:bg-muted [&_code]:px-1.5 [&_code]:py-0.5 [&_code]:font-mono [&_code]:text-xs " +
|
||||
"[&_pre]:my-4 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-lg [&_pre]:border [&_pre]:bg-muted [&_pre]:p-4 " +
|
||||
"[&_pre_code]:border-0 [&_pre_code]:bg-transparent [&_pre_code]:p-0 " +
|
||||
"[&_table]:my-4 [&_table]:w-full [&_table]:min-w-max [&_table]:border-collapse " +
|
||||
"[&_th]:border [&_th]:bg-muted [&_th]:px-3 [&_th]:py-2 [&_td]:border [&_td]:px-3 [&_td]:py-2";
|
||||
|
||||
function canManageSkill(skill: Skill | null | undefined): boolean {
|
||||
if (!skill) return false;
|
||||
const auth = getStoredAuth();
|
||||
if (!auth) return false;
|
||||
if (auth.system_role === "admin") return true;
|
||||
return (
|
||||
skill.category === "custom" &&
|
||||
!!skill.owner_user_id &&
|
||||
skill.owner_user_id === auth.user_id
|
||||
);
|
||||
}
|
||||
|
||||
function stripYamlFrontmatter(content: string): string {
|
||||
return content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, "").trimStart();
|
||||
}
|
||||
|
||||
export function SkillDetailDialog({
|
||||
skillName,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
skillName: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.settings.skills;
|
||||
const activeName = open ? skillName : null;
|
||||
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
|
||||
const setDetail = useSetSkillDetail();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
const [activeTab, setActiveTab] = useState<"detail" | "source" | null>(
|
||||
null,
|
||||
);
|
||||
const [showRawSource, setShowRawSource] = useState(false);
|
||||
|
||||
const canManage = useMemo(() => canManageSkill(skill), [skill]);
|
||||
const {
|
||||
data: contentData,
|
||||
isLoading: isContentLoading,
|
||||
error: contentError,
|
||||
} = useSkillContent(open && canManage ? skillName : null);
|
||||
|
||||
useEffect(() => {
|
||||
setEditing(false);
|
||||
setActiveTab(null);
|
||||
setShowRawSource(false);
|
||||
}, [open, skillName]);
|
||||
|
||||
const skillBody = useMemo(() => {
|
||||
return stripYamlFrontmatter(contentData?.content ?? "");
|
||||
}, [contentData]);
|
||||
|
||||
const defaultTab: "detail" | "source" =
|
||||
canManage && skill && !skill.detail ? "source" : "detail";
|
||||
const selectedTab = activeTab ?? defaultTab;
|
||||
|
||||
function startEditing() {
|
||||
setDraft(skill?.detail ?? "");
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!skill) return;
|
||||
try {
|
||||
await setDetail.mutateAsync({ skillName: skill.name, detail: draft });
|
||||
toast.success(tx.detailSaveSuccess);
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
toast.error(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function copySource() {
|
||||
await navigator.clipboard.writeText(contentData?.content ?? "");
|
||||
toast.success("Copied SKILL.md content");
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="flex w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl"
|
||||
style={{ height: "min(760px, calc(100dvh - 2rem))" }}
|
||||
>
|
||||
<DialogHeader className="shrink-0 border-b px-6 py-4 pr-12 text-left">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="bg-primary/10 text-primary flex size-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<SparklesIcon className="size-5" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<DialogTitle className="truncate text-base">
|
||||
{skill?.name ?? skillName ?? ""}
|
||||
</DialogTitle>
|
||||
{skill?.builtin && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{tx.badgeBuiltin}
|
||||
</Badge>
|
||||
)}
|
||||
{skill?.published && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="border-sky-500/30 text-xs text-sky-700 dark:text-sky-300"
|
||||
>
|
||||
<GlobeIcon className="size-3" />
|
||||
{tx.badgePublished}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<DialogDescription className="mt-1 line-clamp-2 text-sm leading-relaxed">
|
||||
{skill?.description || "-"}
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{isSkillLoading || !skill ? (
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6 py-10">
|
||||
<div className="text-muted-foreground flex items-center gap-2 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<Tabs
|
||||
value={selectedTab}
|
||||
onValueChange={(value) =>
|
||||
setActiveTab(value as Exclude<typeof activeTab, null>)
|
||||
}
|
||||
className="flex min-h-0 flex-1 flex-col gap-0"
|
||||
>
|
||||
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-6 py-2">
|
||||
<TabsList className="h-8">
|
||||
<TabsTrigger value="detail" className="text-xs">
|
||||
<FileTextIcon className="mr-1 size-3.5" />
|
||||
{tx.detailSectionTitle}
|
||||
</TabsTrigger>
|
||||
{canManage && (
|
||||
<TabsTrigger value="source" className="text-xs">
|
||||
<FileCode2Icon className="mr-1 size-3.5" />
|
||||
{tx.contentSectionTitle}
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{selectedTab === "detail" && canManage && !editing && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<PencilIcon className="size-3.5" />
|
||||
{tx.detailEditAction}
|
||||
</Button>
|
||||
)}
|
||||
{selectedTab === "source" &&
|
||||
canManage &&
|
||||
!isContentLoading &&
|
||||
!contentError && (
|
||||
<>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => setShowRawSource((value) => !value)}
|
||||
>
|
||||
{showRawSource ? (
|
||||
<>
|
||||
<EyeIcon className="size-3.5" />
|
||||
Rendered
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FileCode2Icon className="size-3.5" />
|
||||
Raw
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="text-muted-foreground hover:text-foreground h-7 px-2 text-xs"
|
||||
onClick={() => void copySource()}
|
||||
>
|
||||
<CopyIcon className="size-3.5" />
|
||||
Copy
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TabsContent
|
||||
value="detail"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{editing ? (
|
||||
<div className="space-y-2">
|
||||
<Textarea
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
placeholder={tx.detailPlaceholder}
|
||||
className="max-h-[420px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
disabled={setDetail.isPending}
|
||||
>
|
||||
{t.common.cancel}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void handleSave()}
|
||||
disabled={setDetail.isPending}
|
||||
>
|
||||
{setDetail.isPending && (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
)}
|
||||
{t.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : skill?.detail ? (
|
||||
<div className="bg-muted/30 min-w-0 rounded-lg border p-4">
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={skill.detail}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-muted-foreground rounded-lg border border-dashed px-4 py-8 text-center">
|
||||
<FileTextIcon className="mx-auto mb-2 size-6 opacity-40" />
|
||||
<p className="text-sm">{tx.detailEmpty}</p>
|
||||
{canManage ? (
|
||||
<p className="mt-1 text-xs">
|
||||
Open the SKILL.md tab to view the skill source.
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-1 text-xs">{tx.detailAdminOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
{canManage && (
|
||||
<TabsContent
|
||||
value="source"
|
||||
className="m-0 min-h-0 flex-1 overscroll-contain px-6 py-5 data-[state=inactive]:hidden"
|
||||
style={{ overflowY: "auto" }}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="text-muted-foreground flex items-center justify-center gap-2 py-12 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{t.common.loading}
|
||||
</div>
|
||||
) : contentError ? (
|
||||
<div className="text-destructive py-6 text-sm">
|
||||
{contentError instanceof Error
|
||||
? contentError.message
|
||||
: String(contentError)}
|
||||
</div>
|
||||
) : showRawSource ? (
|
||||
<pre className="text-foreground/80 max-w-full overflow-x-auto rounded-lg border bg-muted p-4 font-mono text-xs leading-relaxed whitespace-pre-wrap">
|
||||
{contentData?.content}
|
||||
</pre>
|
||||
) : (
|
||||
<MarkdownContent
|
||||
className={markdownClassName}
|
||||
content={skillBody || "(SKILL.md is empty)"}
|
||||
isLoading={false}
|
||||
remarkPlugins={reasoningPlugins.remarkPlugins}
|
||||
rehypePlugins={reasoningPlugins.rehypePlugins}
|
||||
/>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -177,6 +177,9 @@ function StatusCard() {
|
||||
<span>
|
||||
{tt.archived}: {status.agent_created_skills.archived}
|
||||
</span>
|
||||
<span>
|
||||
{tt.pinned}: {status.agent_created_skills.pinned}
|
||||
</span>
|
||||
</span>
|
||||
</StatRow>
|
||||
<StatRow label={tt.lastActions} className="sm:col-span-2">
|
||||
|
||||
@@ -1,46 +1,48 @@
|
||||
"use client";
|
||||
|
||||
import { SearchIcon } from "lucide-react";
|
||||
import { CheckIcon, ChevronDownIcon, ChevronUpIcon, 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__";
|
||||
import { resolveTagColor } from "./tag-colors";
|
||||
|
||||
// How many tag chips to show before the list is collapsed behind a toggle.
|
||||
const COLLAPSE_THRESHOLD = 16;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* request per keystroke.
|
||||
*
|
||||
* Tags are rendered inline as a multi-select chip row (instead of a dropdown):
|
||||
* "全部" is selected when no tag is picked, and any number of tags can be
|
||||
* toggled. Filtering is OR — a resource matches if it carries any picked tag.
|
||||
* When there are many tags the row collapses behind a 展开/收起 toggle.
|
||||
*/
|
||||
export function TagSearchBar({
|
||||
targetType,
|
||||
onSearchChange,
|
||||
tagId,
|
||||
onTagIdChange,
|
||||
tagIds,
|
||||
onTagIdsChange,
|
||||
className,
|
||||
}: {
|
||||
targetType: TagType;
|
||||
onSearchChange: (value: string) => void;
|
||||
tagId: string | null;
|
||||
onTagIdChange: (value: string | null) => void;
|
||||
tagIds: string[];
|
||||
onTagIdsChange: (value: string[]) => void;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const { data: tags } = useTags(targetType);
|
||||
|
||||
const [input, setInput] = useState("");
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const cbRef = useRef(onSearchChange);
|
||||
cbRef.current = onSearchChange;
|
||||
useEffect(() => {
|
||||
@@ -48,9 +50,24 @@ export function TagSearchBar({
|
||||
return () => clearTimeout(id);
|
||||
}, [input]);
|
||||
|
||||
const allTags = tags ?? [];
|
||||
const canCollapse = allTags.length > COLLAPSE_THRESHOLD;
|
||||
// When collapsed, always keep the selected tags visible so the active
|
||||
// filter never hides itself behind the toggle.
|
||||
const visibleTags =
|
||||
canCollapse && !expanded
|
||||
? allTags.filter((tag, i) => i < COLLAPSE_THRESHOLD || tagIds.includes(tag.id))
|
||||
: allTags;
|
||||
|
||||
const toggleTag = (id: string) => {
|
||||
onTagIdsChange(
|
||||
tagIds.includes(id) ? tagIds.filter((x) => x !== id) : [...tagIds, id],
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<div className="relative flex-1">
|
||||
<div className={cn("flex flex-col gap-2.5", className)}>
|
||||
<div className="relative">
|
||||
<SearchIcon className="text-muted-foreground absolute left-2.5 top-1/2 size-4 -translate-y-1/2" />
|
||||
<Input
|
||||
value={input}
|
||||
@@ -59,22 +76,61 @@ export function TagSearchBar({
|
||||
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>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onTagIdsChange([])}
|
||||
className={cn(
|
||||
"inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium transition-all",
|
||||
tagIds.length === 0
|
||||
? "bg-foreground text-background border-foreground"
|
||||
: "bg-muted text-muted-foreground border-transparent hover:opacity-80",
|
||||
)}
|
||||
>
|
||||
{t.adminTags.allTags}
|
||||
</button>
|
||||
{visibleTags.map((tag) => {
|
||||
const checked = tagIds.includes(tag.id);
|
||||
const color = resolveTagColor(tag.color);
|
||||
return (
|
||||
<button
|
||||
key={tag.id}
|
||||
type="button"
|
||||
onClick={() => toggleTag(tag.id)}
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1 rounded-full border px-3 py-1 text-xs 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" />}
|
||||
{tag.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{canCollapse && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="text-muted-foreground hover:text-foreground inline-flex items-center gap-0.5 px-1.5 py-1 text-xs"
|
||||
>
|
||||
{expanded ? (
|
||||
<>
|
||||
收起 <ChevronUpIcon className="size-3.5" />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
展开全部 <ChevronDownIcon className="size-3.5" />
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
CheckIcon,
|
||||
CopyIcon,
|
||||
DownloadIcon,
|
||||
ExternalLinkIcon,
|
||||
Loader2,
|
||||
Trash2Icon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { useI18n } from "@/core/i18n/hooks";
|
||||
import type {
|
||||
SharePreview,
|
||||
ThreadShare,
|
||||
ThreadShareMode,
|
||||
} from "@/core/thread-shares/api";
|
||||
import { buildPublicShareUrl, extractShareCode } from "@/core/thread-shares/api";
|
||||
import {
|
||||
useCreateShare,
|
||||
useImportShare,
|
||||
usePreviewShare,
|
||||
useRevokeShare,
|
||||
} from "@/core/thread-shares/hooks";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function errorMessage(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog that produces a share link for a single conversation. Two modes:
|
||||
*
|
||||
* - `import` — mints a short code; a recipient enters it in the import dialog
|
||||
* to copy the conversation into their own account.
|
||||
* - `view` — mints a public, read-only link that anyone can open without
|
||||
* logging in (a static page; viewers cannot send new messages).
|
||||
*/
|
||||
export function ShareConversationDialog({
|
||||
threadId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
threadId: string | null;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.threadShare;
|
||||
const createShare = useCreateShare();
|
||||
const revokeShare = useRevokeShare();
|
||||
const [mode, setMode] = useState<ThreadShareMode>("view");
|
||||
const [share, setShare] = useState<ThreadShare | null>(null);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Reset to the default mode (read-only link) every time the dialog opens.
|
||||
useEffect(() => {
|
||||
if (open) setMode("view");
|
||||
}, [open, threadId]);
|
||||
|
||||
// Mint / reuse the link whenever the dialog opens or the mode changes.
|
||||
useEffect(() => {
|
||||
if (!open || !threadId) return;
|
||||
setShare(null);
|
||||
setCopied(false);
|
||||
createShare
|
||||
.mutateAsync({ threadId, mode })
|
||||
.then(setShare)
|
||||
.catch((err) => toast.error(errorMessage(err)));
|
||||
// createShare is stable enough; re-running only on open/threadId/mode is intended.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, threadId, mode]);
|
||||
|
||||
const isView = mode === "view";
|
||||
const publicUrl =
|
||||
isView && share ? buildPublicShareUrl(share.share_code) : "";
|
||||
|
||||
async function handleCopy() {
|
||||
if (!share) return;
|
||||
const text = isView ? publicUrl : share.share_code;
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
setCopied(true);
|
||||
toast.success(isView ? "链接已复制" : tx.codeCopied);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
toast.error(t.clipboard.failedToCopyToClipboard);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!share || !window.confirm(tx.revokeConfirm)) return;
|
||||
try {
|
||||
await revokeShare.mutateAsync(share.share_code);
|
||||
toast.success(tx.revokeSuccess);
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tx.shareTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isView
|
||||
? "生成一个只读链接,任何人无需登录都能查看这个对话,但不能继续提问。"
|
||||
: tx.shareDescription}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* Mode selector */}
|
||||
<div className="bg-muted flex gap-1 rounded-lg p-1">
|
||||
{(
|
||||
[
|
||||
{ value: "view" as const, label: "只读链接" },
|
||||
{ value: "import" as const, label: "导入副本" },
|
||||
]
|
||||
).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
onClick={() => setMode(option.value)}
|
||||
className={cn(
|
||||
"flex-1 rounded-md px-3 py-1.5 text-sm transition-colors",
|
||||
mode === option.value
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!share ? (
|
||||
<div className="text-muted-foreground flex items-center gap-2 py-8 text-sm">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{tx.generating}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<p className="text-muted-foreground mb-1 text-xs">
|
||||
{isView ? "只读链接" : tx.shareCodeLabel}
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
{isView ? (
|
||||
<Input
|
||||
readOnly
|
||||
value={publicUrl}
|
||||
onFocus={(e) => e.currentTarget.select()}
|
||||
className="flex-1 font-mono text-xs"
|
||||
/>
|
||||
) : (
|
||||
<code className="bg-muted flex-1 rounded-md px-3 py-2 text-center font-mono text-lg tracking-[0.3em]">
|
||||
{share.share_code}
|
||||
</code>
|
||||
)}
|
||||
<Button variant="outline" onClick={() => void handleCopy()}>
|
||||
{copied ? (
|
||||
<CheckIcon className="size-4" />
|
||||
) : (
|
||||
<CopyIcon className="size-4" />
|
||||
)}
|
||||
{isView ? "复制链接" : tx.copyCode}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isView ? (
|
||||
<div className="space-y-3">
|
||||
<p className="text-muted-foreground text-xs">
|
||||
拿到链接的人都能查看这个对话的全部内容(只读),无法继续发送问题。撤销后链接立即失效。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => window.open(publicUrl, "_blank", "noopener")}
|
||||
>
|
||||
<ExternalLinkIcon className="size-3.5" />
|
||||
在新标签页打开
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-muted-foreground text-xs">{tx.shareHint}</p>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={revokeShare.isPending}
|
||||
onClick={() => void handleRevoke()}
|
||||
>
|
||||
<Trash2Icon className="size-3.5" />
|
||||
{tx.revoke}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
/** Dialog where a user pastes a share code to import someone's conversation. */
|
||||
export function ImportConversationsDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const tx = t.threadShare;
|
||||
const [input, setInput] = useState("");
|
||||
const [preview, setPreview] = useState<SharePreview | null>(null);
|
||||
const previewShare = usePreviewShare();
|
||||
const importShare = useImportShare();
|
||||
|
||||
// Reset state whenever the dialog closes.
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setInput("");
|
||||
setPreview(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
async function handlePreview() {
|
||||
const code = extractShareCode(input);
|
||||
if (!code) {
|
||||
toast.error(tx.emptyCode);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setPreview(await previewShare.mutateAsync(code));
|
||||
} catch (err) {
|
||||
setPreview(null);
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
async function handleImport() {
|
||||
const code = preview?.share_code ?? extractShareCode(input);
|
||||
if (!code) {
|
||||
toast.error(tx.emptyCode);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await importShare.mutateAsync(code);
|
||||
if (result.imported > 0) {
|
||||
toast.success(tx.importSuccess);
|
||||
} else {
|
||||
toast.info(tx.alreadyImported);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
toast.error(errorMessage(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{tx.importTitle}</DialogTitle>
|
||||
<DialogDescription>{tx.importDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={input}
|
||||
onChange={(e) => {
|
||||
setInput(e.target.value);
|
||||
setPreview(null);
|
||||
}}
|
||||
placeholder={tx.codeInputPlaceholder}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => void handlePreview()}
|
||||
disabled={previewShare.isPending || !input.trim()}
|
||||
>
|
||||
{previewShare.isPending && (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
)}
|
||||
{tx.preview}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{preview && (
|
||||
<div className="space-y-2 rounded-lg border p-3 text-sm">
|
||||
<p className="font-medium">
|
||||
{tx.previewConversation.replace("{title}", preview.title)}
|
||||
</p>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{tx.previewFrom.replace("{user}", preview.shared_by)}
|
||||
</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => void handleImport()}
|
||||
disabled={importShare.isPending}
|
||||
>
|
||||
{importShare.isPending ? (
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
) : (
|
||||
<DownloadIcon className="size-4" />
|
||||
)}
|
||||
{importShare.isPending ? tx.importing : tx.doImport}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,14 +11,13 @@ 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";
|
||||
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
|
||||
import { BotIcon, BrainIcon, CalendarClock, 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>
|
||||
@@ -63,6 +62,17 @@ export function WorkspaceNavChatList() {
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname.startsWith("/page/workspace/memory")}
|
||||
asChild
|
||||
>
|
||||
<Link className="text-sidebar-foreground" href="/page/workspace/memory">
|
||||
<BrainIcon />
|
||||
<span>记忆</span>
|
||||
</Link>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
isActive={pathname === "/page/workspace/scheduled/tasks"}
|
||||
@@ -74,19 +84,6 @@ 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>
|
||||
);
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
|
||||
import {
|
||||
BellIcon,
|
||||
CalendarClockIcon,
|
||||
ChevronsUpDown,
|
||||
GaugeIcon,
|
||||
HistoryIcon,
|
||||
MessageSquareCodeIcon,
|
||||
MessageSquarePlusIcon,
|
||||
Settings2Icon,
|
||||
SettingsIcon,
|
||||
TagsIcon,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -149,6 +152,34 @@ export function WorkspaceNavMenu() {
|
||||
大模型调用统计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/users")}
|
||||
>
|
||||
<UsersIcon />
|
||||
用户管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/scheduled-tasks")
|
||||
}
|
||||
>
|
||||
<CalendarClockIcon />
|
||||
定时任务管理
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
navigate("/page/workspace/admin/user-activity")
|
||||
}
|
||||
>
|
||||
<HistoryIcon />
|
||||
用户活动审计
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => navigate("/page/workspace/admin/tags")}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** A conversation thread owned by some user (admin view). */
|
||||
export interface AdminThread {
|
||||
thread_id: string;
|
||||
display_name: string;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* One conversation message, as serialized from the LangGraph checkpoint —
|
||||
* a LangChain message dump: ``type`` is "human" | "ai" | "tool" | "system",
|
||||
* ``content`` is a string or a content-block list.
|
||||
*/
|
||||
export interface AdminMessage {
|
||||
type?: string;
|
||||
content?: unknown;
|
||||
id?: string | null;
|
||||
name?: string | null;
|
||||
}
|
||||
|
||||
/** One memory bucket: the global file or a per-agent file. */
|
||||
export interface AdminMemoryBucket {
|
||||
name: string;
|
||||
data: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface AdminMemoryResponse {
|
||||
user_id: string;
|
||||
buckets: AdminMemoryBucket[];
|
||||
}
|
||||
|
||||
const base = () => `${getBackendBaseURL()}/api/admin/users`;
|
||||
|
||||
async function parse<T>(res: Response, what: string): Promise<T> {
|
||||
if (!res.ok) {
|
||||
const err = (await res.json().catch(() => ({}))) as { detail?: string };
|
||||
throw new Error(err.detail ?? `${what} (HTTP ${res.status})`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export async function listUserThreads(userId: string): Promise<AdminThread[]> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/threads?limit=200`),
|
||||
"Failed to load threads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function listUserThreadMessages(
|
||||
userId: string,
|
||||
threadId: string,
|
||||
): Promise<AdminMessage[]> {
|
||||
return parse(
|
||||
await apiFetch(
|
||||
`${base()}/${encodeURIComponent(userId)}/threads/${encodeURIComponent(threadId)}/messages`,
|
||||
),
|
||||
"Failed to load messages",
|
||||
);
|
||||
}
|
||||
|
||||
export async function getUserMemory(
|
||||
userId: string,
|
||||
): Promise<AdminMemoryResponse> {
|
||||
return parse(
|
||||
await apiFetch(`${base()}/${encodeURIComponent(userId)}/memory`),
|
||||
"Failed to load memory",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { getUserMemory, listUserThreadMessages, listUserThreads } from "./api";
|
||||
|
||||
/** Threads owned by a given user. Disabled until a user is picked. */
|
||||
export function useUserThreads(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "threads", userId],
|
||||
queryFn: () => listUserThreads(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
|
||||
/** Messages of a given thread. Disabled until a thread is picked. */
|
||||
export function useUserThreadMessages(
|
||||
userId: string | null,
|
||||
threadId: string | null,
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "messages", userId, threadId],
|
||||
queryFn: () => listUserThreadMessages(userId!, threadId!),
|
||||
enabled: !!userId && !!threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/** All memory buckets of a given user. Disabled until a user is picked. */
|
||||
export function useUserMemory(userId: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["admin-activity", "memory", userId],
|
||||
queryFn: () => getUserMemory(userId!),
|
||||
enabled: !!userId,
|
||||
});
|
||||
}
|
||||
@@ -17,13 +17,13 @@ export class AgentNameCheckError extends Error {
|
||||
|
||||
export interface ListAgentsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
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);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const res = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/agents${query ? `?${query}` : ""}`,
|
||||
|
||||
@@ -14,12 +14,12 @@ import {
|
||||
} from "./api";
|
||||
import type { CreateAgentRequest, UpdateAgentRequest } from "./types";
|
||||
|
||||
export function useAgents(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useAgents(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["agents", search, tagId],
|
||||
queryFn: () => listAgents({ search, tagId }),
|
||||
queryKey: ["agents", search, [...tagIds].sort()],
|
||||
queryFn: () => listAgents({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { agents: data ?? [], isLoading, error };
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { Client as LangGraphClient } from "@langchain/langgraph-sdk/client";
|
||||
|
||||
import { getAuthorizationHeaderValue } from "../auth";
|
||||
import { getCanvasLangGraphBaseURL } from "../config";
|
||||
|
||||
import { sanitizeRunStreamOptions } from "./stream-mode";
|
||||
|
||||
/**
|
||||
* Dedicated LangGraph client for the Canvas page.
|
||||
*
|
||||
* Keeping this separate from getAPIClient() means:
|
||||
* - Canvas API calls can be pointed at a different server via VITE_CANVAS_LANGGRAPH_BASE_URL
|
||||
* - Canvas-specific HTTP-level logic (headers, retries, middleware) lives here
|
||||
* - Changes here never touch the main page's API path
|
||||
*/
|
||||
function createCanvasClient(): LangGraphClient {
|
||||
const client = new LangGraphClient({
|
||||
apiUrl: getCanvasLangGraphBaseURL(),
|
||||
onRequest(_url, init) {
|
||||
const headers = new Headers(init.headers ?? {});
|
||||
const authHeader = getAuthorizationHeaderValue();
|
||||
if (authHeader && !headers.has("Authorization")) {
|
||||
headers.set("Authorization", authHeader);
|
||||
}
|
||||
return { ...init, headers };
|
||||
},
|
||||
});
|
||||
|
||||
const originalRunStream = client.runs.stream.bind(client.runs);
|
||||
client.runs.stream = ((threadId, assistantId, payload) =>
|
||||
originalRunStream(
|
||||
threadId,
|
||||
assistantId,
|
||||
sanitizeRunStreamOptions(payload),
|
||||
)) as typeof client.runs.stream;
|
||||
|
||||
const originalJoinStream = client.runs.joinStream.bind(client.runs);
|
||||
client.runs.joinStream = ((threadId, runId, options) =>
|
||||
originalJoinStream(
|
||||
threadId,
|
||||
runId,
|
||||
sanitizeRunStreamOptions(options),
|
||||
)) as typeof client.runs.joinStream;
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
let _canvasClient: LangGraphClient | null = null;
|
||||
|
||||
export function getCanvasAPIClient(): LangGraphClient {
|
||||
if (!_canvasClient) {
|
||||
_canvasClient = createCanvasClient();
|
||||
}
|
||||
return _canvasClient;
|
||||
}
|
||||
@@ -43,12 +43,14 @@ export async function downloadArtifactFile({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: true });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: true });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
@@ -73,12 +75,14 @@ export async function openArtifactInNewTab({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}): Promise<void> {
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, download: false });
|
||||
const url = urlOfArtifact({ filepath, threadId, isMock, publicShareCode, download: false });
|
||||
const res = await apiFetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(await errorMessageFromResponse(res));
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
|
||||
import { useThread } from "@/components/workspace/messages/context";
|
||||
|
||||
import { loadArtifactContent, loadArtifactContentFromToolCall } from "./loader";
|
||||
import { updateArtifactContent } from "./api";
|
||||
|
||||
@@ -60,32 +60,32 @@ function applyPendingTextEdit({
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
|
||||
export function useArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
enabled,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
const isWriteFile = useMemo(() => {
|
||||
return filepath.startsWith("write-file:");
|
||||
}, [filepath]);
|
||||
const { thread, isMock, publicShareCode } = useThread();
|
||||
const content = useMemo(() => {
|
||||
if (isWriteFile) {
|
||||
return loadArtifactContentFromToolCall({ url: filepath, thread });
|
||||
}
|
||||
return null;
|
||||
}, [filepath, isWriteFile, thread]);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["artifact", filepath, threadId, isMock],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock });
|
||||
},
|
||||
queryKey: ["artifact", filepath, threadId, isMock, publicShareCode],
|
||||
queryFn: () => {
|
||||
return loadArtifactContent({ filepath, threadId, isMock, publicShareCode });
|
||||
},
|
||||
enabled,
|
||||
refetchInterval: thread.isLoading ? 1000 : false,
|
||||
// Cache artifact content for 5 minutes to avoid repeated fetches (especially for .skill ZIP extraction)
|
||||
|
||||
@@ -9,16 +9,18 @@ export async function loadArtifactContent({
|
||||
filepath,
|
||||
threadId,
|
||||
isMock,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
let enhancedFilepath = filepath;
|
||||
if (filepath.endsWith(".skill")) {
|
||||
enhancedFilepath = filepath + "/SKILL.md";
|
||||
}
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock });
|
||||
const url = urlOfArtifact({ filepath: enhancedFilepath, threadId, isMock, publicShareCode });
|
||||
const response = await apiFetch(url);
|
||||
const text = await response.text();
|
||||
return { content: text, url };
|
||||
|
||||
@@ -6,12 +6,22 @@ export function urlOfArtifact({
|
||||
threadId,
|
||||
download = false,
|
||||
isMock = false,
|
||||
publicShareCode,
|
||||
}: {
|
||||
filepath: string;
|
||||
threadId: string;
|
||||
download?: boolean;
|
||||
isMock?: boolean;
|
||||
publicShareCode?: string;
|
||||
}) {
|
||||
// A public read-only share has no auth-gated thread route — its referenced
|
||||
// files are served by the unauthenticated public-share endpoint instead.
|
||||
if (publicShareCode) {
|
||||
const normalized = filepath.startsWith("/") ? filepath : `/${filepath}`;
|
||||
return `${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(
|
||||
publicShareCode,
|
||||
)}/files${normalized}`;
|
||||
}
|
||||
if (isMock) {
|
||||
return `${getBackendBaseURL()}/mock/api/threads/${threadId}/artifacts${filepath}${download ? "?download=true" : ""}`;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { env } from "@/env";
|
||||
|
||||
/**
|
||||
* 内网知识库「查看原文」详情页地址前缀。
|
||||
* 素材的 recUuid 直接拼接在其后即为完整原文链接。
|
||||
*/
|
||||
export const INTRANET_SOURCE_URL_BASE =
|
||||
"https://chlib.b.uat.4.cn/knowlibrary/#/knowledge/mid_detail?recUuid=";
|
||||
|
||||
/** 由 recUuid 构造内网原文详情页地址;recUuid 为空时返回空串。 */
|
||||
export function buildIntranetSourceUrl(recUuid?: string | null): string {
|
||||
if (!recUuid) return "";
|
||||
return `${INTRANET_SOURCE_URL_BASE}${encodeURIComponent(recUuid)}`;
|
||||
}
|
||||
|
||||
function getBaseOrigin() {
|
||||
if (typeof window !== "undefined") {
|
||||
return window.location.origin;
|
||||
@@ -18,6 +31,23 @@ export function getBackendBaseURL() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Canvas page has its own LangGraph base URL so canvas-specific modifications
|
||||
* (different server, different route prefix, etc.) can be made independently
|
||||
* without touching the main page's API path.
|
||||
* Falls back to the main LangGraph URL when VITE_CANVAS_LANGGRAPH_BASE_URL is unset.
|
||||
*/
|
||||
export function getCanvasLangGraphBaseURL(): string {
|
||||
if (env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
env.NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL,
|
||||
getBaseOrigin(),
|
||||
).toString();
|
||||
}
|
||||
// Fall back to the main LangGraph URL (same server, different assistantId)
|
||||
return getLangGraphBaseURL();
|
||||
}
|
||||
|
||||
export function getLangGraphBaseURL(isMock?: boolean) {
|
||||
if (env.NEXT_PUBLIC_LANGGRAPH_BASE_URL) {
|
||||
return new URL(
|
||||
|
||||
@@ -47,6 +47,8 @@ export interface CuratorStatus {
|
||||
active: number;
|
||||
stale: number;
|
||||
archived: number;
|
||||
/** 被 pin 的数量;与 active/stale/archived 正交。 */
|
||||
pinned: number;
|
||||
};
|
||||
/** 上次运行 LLM 自述的建议(它"说"要做什么)。 */
|
||||
last_consolidation_suggestions: CuratorSuggestions;
|
||||
|
||||
@@ -48,6 +48,7 @@ export const enUS: Translations = {
|
||||
export: "Export",
|
||||
exportAsMarkdown: "Export as Markdown",
|
||||
exportAsJSON: "Export as JSON",
|
||||
exportAsWord: "Export as Word",
|
||||
exportSuccess: "Conversation exported",
|
||||
},
|
||||
|
||||
@@ -250,6 +251,35 @@ export const enUS: Translations = {
|
||||
searchChats: "Search chats",
|
||||
},
|
||||
|
||||
// Conversation sharing
|
||||
threadShare: {
|
||||
importChats: "Import chat",
|
||||
shareTitle: "Share conversation",
|
||||
shareDescription:
|
||||
'Share this conversation with others. They enter the code under "Import chat" to copy it into their own account and keep chatting.',
|
||||
generating: "Generating code...",
|
||||
shareCodeLabel: "Share code",
|
||||
copyCode: "Copy",
|
||||
codeCopied: "Share code copied",
|
||||
shareHint:
|
||||
"Anyone who has the code can copy this conversation — share it carefully.",
|
||||
revoke: "Revoke share",
|
||||
revokeConfirm: "Revoking makes the code unusable immediately. Continue?",
|
||||
revokeSuccess: "Share revoked",
|
||||
importTitle: "Import a conversation",
|
||||
importDescription:
|
||||
"Enter a share code to copy the shared conversation into your account.",
|
||||
codeInputPlaceholder: "Paste a share code",
|
||||
preview: "Preview",
|
||||
previewConversation: "Conversation: {title}",
|
||||
previewFrom: "From user {user}",
|
||||
doImport: "Import",
|
||||
importing: "Importing...",
|
||||
importSuccess: "Conversation imported",
|
||||
alreadyImported: "You have already imported this conversation",
|
||||
emptyCode: "Enter a share code first",
|
||||
},
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "CM Assistant",
|
||||
@@ -336,81 +366,62 @@ export const enUS: Translations = {
|
||||
memory: {
|
||||
title: "Memory",
|
||||
description:
|
||||
"CM Assistant automatically learns from your conversations in the background. These memories help CM Assistant understand you better and deliver a more personalized experience.",
|
||||
empty: "No memory data to display.",
|
||||
rawJson: "Raw JSON",
|
||||
exportButton: "Export memory",
|
||||
exportSuccess: "Memory exported",
|
||||
importButton: "Import memory",
|
||||
importConfirmTitle: "Import memory?",
|
||||
importConfirmDescription:
|
||||
"This will overwrite your current memory with the selected JSON backup.",
|
||||
importFileLabel: "Selected file",
|
||||
importInvalidFile:
|
||||
"Failed to read the selected memory file. Please choose a valid JSON export.",
|
||||
importSuccess: "Memory imported",
|
||||
manualFactSource: "Manual",
|
||||
addFact: "Add fact",
|
||||
addFactTitle: "Add memory fact",
|
||||
editFactTitle: "Edit memory fact",
|
||||
addFactSuccess: "Fact created",
|
||||
editFactSuccess: "Fact updated",
|
||||
clearAll: "Clear all memory",
|
||||
clearAllConfirmTitle: "Clear all memory?",
|
||||
clearAllConfirmDescription:
|
||||
"This will remove all saved summaries and facts. This action cannot be undone.",
|
||||
clearAllSuccess: "All memory cleared",
|
||||
factDeleteConfirmTitle: "Delete this fact?",
|
||||
factDeleteConfirmDescription:
|
||||
"This fact will be removed from memory immediately. This action cannot be undone.",
|
||||
factDeleteSuccess: "Fact deleted",
|
||||
factContentLabel: "Content",
|
||||
factCategoryLabel: "Category",
|
||||
factConfidenceLabel: "Confidence",
|
||||
factContentPlaceholder: "Describe the memory fact you want to save",
|
||||
factCategoryPlaceholder: "context",
|
||||
factConfidenceHint: "Use a number between 0 and 1.",
|
||||
factSave: "Save fact",
|
||||
factValidationContent: "Fact content cannot be empty.",
|
||||
factValidationConfidence: "Confidence must be a number between 0 and 1.",
|
||||
noFacts: "No saved facts yet.",
|
||||
summaryReadOnly:
|
||||
"Summary sections are read-only for now. You can currently add, edit, or delete individual facts, or clear all memory.",
|
||||
memoryFullyEmpty: "No memory saved yet.",
|
||||
factPreviewLabel: "Fact to delete",
|
||||
searchPlaceholder: "Search memory",
|
||||
filterAll: "All",
|
||||
filterFacts: "Facts",
|
||||
filterSummaries: "Summaries",
|
||||
noMatches: "No matching memory found.",
|
||||
markdown: {
|
||||
overview: "Overview",
|
||||
userContext: "User context",
|
||||
work: "Work",
|
||||
personal: "Personal",
|
||||
topOfMind: "Top of mind",
|
||||
historyBackground: "History",
|
||||
recentMonths: "Recent months",
|
||||
earlierContext: "Earlier context",
|
||||
longTermBackground: "Long-term background",
|
||||
updatedAt: "Updated at",
|
||||
facts: "Facts",
|
||||
empty: "(empty)",
|
||||
table: {
|
||||
category: "Category",
|
||||
confidence: "Confidence",
|
||||
confidenceLevel: {
|
||||
veryHigh: "Very high",
|
||||
high: "High",
|
||||
normal: "Normal",
|
||||
unknown: "Unknown",
|
||||
},
|
||||
content: "Content",
|
||||
source: "Source",
|
||||
createdAt: "CreatedAt",
|
||||
view: "View",
|
||||
},
|
||||
},
|
||||
"CM Assistant's memory has two stores: the user profile is shared across all assistants, while working notes are isolated per assistant. You can view and manage both here.",
|
||||
loadError: "Failed to load memory",
|
||||
usageLabel: "Usage",
|
||||
entryEmpty: "No memory entries yet.",
|
||||
userProfileTitle: "User profile",
|
||||
userProfileDesc: "Facts about you — name, role, preferences, communication style; shared across all assistants.",
|
||||
workNotesTitle: "Working notes",
|
||||
workNotesDesc: "Per-expert working notes — environment facts, conventions, lessons learned. Pick an expert to view its notes.",
|
||||
workNotesAgentLabel: "Expert",
|
||||
workNotesDefaultAgent: "Default assistant",
|
||||
addEntry: "Add entry",
|
||||
addEntryTitle: "Add memory entry",
|
||||
editEntryTitle: "Edit memory entry",
|
||||
entryContentLabel: "Content",
|
||||
entryContentPlaceholder: "Describe the memory you want to save",
|
||||
entrySaveSuccess: "Entry saved",
|
||||
entryValidationContent: "Entry content cannot be empty.",
|
||||
deleteEntryTitle: "Delete this memory?",
|
||||
deleteEntryDesc: "This memory will be removed immediately. This action cannot be undone.",
|
||||
deleteEntrySuccess: "Entry deleted",
|
||||
entryToDeleteLabel: "Entry to delete",
|
||||
save: "Save",
|
||||
settingsTitle: "Memory settings",
|
||||
settingsDesc: "Adjust how memory is injected and retrieved. Changes take effect immediately.",
|
||||
injectionEnabled: "Inject memory into conversations",
|
||||
injectionEnabledDesc: "The master switch for memory. When on, the assistant draws on what it remembers about you and past conversations; when off, it sees no memory at all and treats every chat as the first time. The options below only take effect when this is on.",
|
||||
includeBuiltin: "Inject local memory",
|
||||
includeBuiltinDesc: "Give the assistant locally stored memory as background knowledge: the user profile (your identity, habits and preferences) and work notes (key points the assistant saved while working with you). Kept in local server files — no external service required.",
|
||||
includeHindsight: "Inject Hindsight recall",
|
||||
includeHindsightDesc: "Before each reply, automatically retrieve memories relevant to the current topic from the Hindsight long-term memory store and add them as reference. Requires a deployed, reachable Hindsight service.",
|
||||
maxTokens: "Injection token limit",
|
||||
maxTokensDesc: "How much room injected memory may take up per turn (in tokens). Higher lets the assistant see more memory but leaves less space for the conversation itself. Allowed range 100–8000.",
|
||||
memoryMode: "Hindsight tool mode",
|
||||
memoryModeDesc: "How the assistant uses the Hindsight store: automatic background retrieval, an on-demand search tool the assistant calls itself, or both.",
|
||||
memoryModeContext: "context — background auto recall only, no action needed",
|
||||
memoryModeTools: "tools — search tool only, the assistant queries on demand",
|
||||
memoryModeHybrid: "hybrid — background auto recall + search tool, both enabled",
|
||||
recallBudget: "Recall depth",
|
||||
recallBudgetDesc: "How thorough each Hindsight search is. Higher finds more but is slower and more costly; lower is faster but may miss relevant memories.",
|
||||
recallBudgetLow: "Low",
|
||||
recallBudgetMid: "Mid",
|
||||
recallBudgetHigh: "High",
|
||||
bankMission: "Hindsight synthesis settings",
|
||||
bankMissionDesc: "For 'reflect' (synthesis) searches, tells Hindsight what role and perspective to take when summarizing memories. Leave empty to use the default.",
|
||||
bankMissionPlaceholder: "e.g. You are the user's long-term assistant, focused on their work preferences.",
|
||||
settingsSaved: "Settings saved",
|
||||
searchTitle: "Hindsight memory search",
|
||||
searchDesc: "Search historical memories in the Hindsight long-term memory store.",
|
||||
searchPlaceholder: "Search past memories…",
|
||||
searchModeRecall: "Recall",
|
||||
searchModeReflect: "Reflect",
|
||||
searchButton: "Search",
|
||||
searchUnavailable: "Hindsight is not configured or unavailable.",
|
||||
searchNoResults: "No relevant memories found.",
|
||||
searchResultsTitle: "Search results",
|
||||
searchEmpty: "Enter a keyword to search Hindsight memory.",
|
||||
},
|
||||
appearance: {
|
||||
themeTitle: "Theme",
|
||||
@@ -474,6 +485,16 @@ export const enUS: Translations = {
|
||||
deleteConfirm:
|
||||
"Delete skill \"{name}\"? This cannot be undone and the skill will be removed from any agent that referenced it.",
|
||||
deleteSuccess: "Skill deleted",
|
||||
detailSectionTitle: "Details",
|
||||
detailEmpty: "No details yet.",
|
||||
detailEditAction: "Edit details",
|
||||
detailPlaceholder:
|
||||
"Describe the skill's purpose, use cases and usage notes (Markdown supported).",
|
||||
detailSaveSuccess: "Details updated",
|
||||
detailAdminOnlyHint:
|
||||
"Only an admin or the skill publisher can edit the details.",
|
||||
contentSectionTitle: "SKILL.md source",
|
||||
viewDetailAction: "View skill details",
|
||||
stateSheet: {
|
||||
archivedTitle: "Archived skills",
|
||||
archivedDescription:
|
||||
@@ -555,6 +576,7 @@ export const enUS: Translations = {
|
||||
stale: "Stale",
|
||||
archived: "Archived",
|
||||
total: "Total",
|
||||
pinned: "Pinned",
|
||||
runNow: "Run now",
|
||||
runStarted: "Background run started",
|
||||
runBlockedEvolutionDisabled:
|
||||
@@ -574,22 +596,22 @@ export const enUS: Translations = {
|
||||
"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/.",
|
||||
"The master switch for skill evolution. When on, the agent gets the skill_manage tool — it can create, modify and delete skills under custom/, and is nudged to offer saving a workflow as a skill after a complex task. When off, those skill-writing abilities are removed and the agent can only view and use existing skills. All curator settings below require this to be on.",
|
||||
curatorEnabledLabel: "Curator runs automatically",
|
||||
curatorEnabledHelper:
|
||||
"When off, the curator only runs when triggered from this page.",
|
||||
"The curator is a background skill librarian that periodically scans your skill library: it flags long-unused skills as stale, then archives them, and uses an LLM to review whether duplicate skills should be merged. When on, it runs automatically on the interval below; when off, it only runs when you click \"Run now\" on this page.",
|
||||
intervalHoursLabel: "Run interval (hours)",
|
||||
intervalHoursHelper:
|
||||
"Minimum hours between automatic curator runs.",
|
||||
"Minimum hours between automatic curator runs. Default 168 (one week). Manual runs are not subject to this limit.",
|
||||
staleAfterDaysLabel: "Stale threshold (days)",
|
||||
staleAfterDaysHelper:
|
||||
"Skills inactive for this many days are flagged stale.",
|
||||
"A skill not used for this many days is flagged stale by the curator. Stale skills are no longer injected into the agent's prompt, but the files are kept — using one again restores it to active.",
|
||||
archiveAfterDaysLabel: "Archive threshold (days)",
|
||||
archiveAfterDaysHelper:
|
||||
"Stale skills inactive for this many additional days are archived.",
|
||||
"A skill idle for this many days since its last use is archived by the curator (counted as total idle days, not added on top of the stale threshold). Archiving is a soft delete — files stay under custom/ and can be restored anytime. Must be greater than the stale threshold.",
|
||||
curatorModelLabel: "Curator review model",
|
||||
curatorModelHelper:
|
||||
"Model for the curator's LLM review and skill security moderation. Defaults to the primary model.",
|
||||
"Model used for the curator's skill review (merge/prune decisions) and skill security moderation. Leave empty to use the primary model.",
|
||||
curatorModelDefault: "Primary model (default)",
|
||||
save: "Save changes",
|
||||
saveSuccess: "Configuration updated",
|
||||
|
||||
@@ -49,6 +49,7 @@ export interface Translations {
|
||||
export: string;
|
||||
exportAsMarkdown: string;
|
||||
exportAsJSON: string;
|
||||
exportAsWord: string;
|
||||
exportSuccess: string;
|
||||
};
|
||||
|
||||
@@ -194,6 +195,32 @@ export interface Translations {
|
||||
searchChats: string;
|
||||
};
|
||||
|
||||
// Conversation sharing
|
||||
threadShare: {
|
||||
importChats: string;
|
||||
shareTitle: string;
|
||||
shareDescription: string;
|
||||
generating: string;
|
||||
shareCodeLabel: string;
|
||||
copyCode: string;
|
||||
codeCopied: string;
|
||||
shareHint: string;
|
||||
revoke: string;
|
||||
revokeConfirm: string;
|
||||
revokeSuccess: string;
|
||||
importTitle: string;
|
||||
importDescription: string;
|
||||
codeInputPlaceholder: string;
|
||||
preview: string;
|
||||
previewConversation: string;
|
||||
previewFrom: string;
|
||||
doImport: string;
|
||||
importing: string;
|
||||
importSuccess: string;
|
||||
alreadyImported: string;
|
||||
emptyCode: string;
|
||||
};
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: string;
|
||||
@@ -277,75 +304,61 @@ export interface Translations {
|
||||
memory: {
|
||||
title: string;
|
||||
description: string;
|
||||
empty: string;
|
||||
rawJson: string;
|
||||
exportButton: string;
|
||||
exportSuccess: string;
|
||||
importButton: string;
|
||||
importConfirmTitle: string;
|
||||
importConfirmDescription: string;
|
||||
importFileLabel: string;
|
||||
importInvalidFile: string;
|
||||
importSuccess: string;
|
||||
manualFactSource: string;
|
||||
addFact: string;
|
||||
addFactTitle: string;
|
||||
editFactTitle: string;
|
||||
addFactSuccess: string;
|
||||
editFactSuccess: string;
|
||||
clearAll: string;
|
||||
clearAllConfirmTitle: string;
|
||||
clearAllConfirmDescription: string;
|
||||
clearAllSuccess: string;
|
||||
factDeleteConfirmTitle: string;
|
||||
factDeleteConfirmDescription: string;
|
||||
factDeleteSuccess: string;
|
||||
factContentLabel: string;
|
||||
factCategoryLabel: string;
|
||||
factConfidenceLabel: string;
|
||||
factContentPlaceholder: string;
|
||||
factCategoryPlaceholder: string;
|
||||
factConfidenceHint: string;
|
||||
factSave: string;
|
||||
factValidationContent: string;
|
||||
factValidationConfidence: string;
|
||||
noFacts: string;
|
||||
summaryReadOnly: string;
|
||||
memoryFullyEmpty: string;
|
||||
factPreviewLabel: string;
|
||||
loadError: string;
|
||||
usageLabel: string;
|
||||
entryEmpty: string;
|
||||
userProfileTitle: string;
|
||||
userProfileDesc: string;
|
||||
workNotesTitle: string;
|
||||
workNotesDesc: string;
|
||||
workNotesAgentLabel: string;
|
||||
workNotesDefaultAgent: string;
|
||||
addEntry: string;
|
||||
addEntryTitle: string;
|
||||
editEntryTitle: string;
|
||||
entryContentLabel: string;
|
||||
entryContentPlaceholder: string;
|
||||
entrySaveSuccess: string;
|
||||
entryValidationContent: string;
|
||||
deleteEntryTitle: string;
|
||||
deleteEntryDesc: string;
|
||||
deleteEntrySuccess: string;
|
||||
entryToDeleteLabel: string;
|
||||
save: string;
|
||||
settingsTitle: string;
|
||||
settingsDesc: string;
|
||||
injectionEnabled: string;
|
||||
injectionEnabledDesc: string;
|
||||
includeBuiltin: string;
|
||||
includeBuiltinDesc: string;
|
||||
includeHindsight: string;
|
||||
includeHindsightDesc: string;
|
||||
maxTokens: string;
|
||||
maxTokensDesc: string;
|
||||
memoryMode: string;
|
||||
memoryModeDesc: string;
|
||||
memoryModeContext: string;
|
||||
memoryModeTools: string;
|
||||
memoryModeHybrid: string;
|
||||
recallBudget: string;
|
||||
recallBudgetDesc: string;
|
||||
recallBudgetLow: string;
|
||||
recallBudgetMid: string;
|
||||
recallBudgetHigh: string;
|
||||
bankMission: string;
|
||||
bankMissionDesc: string;
|
||||
bankMissionPlaceholder: string;
|
||||
settingsSaved: string;
|
||||
searchTitle: string;
|
||||
searchDesc: string;
|
||||
searchPlaceholder: string;
|
||||
filterAll: string;
|
||||
filterFacts: string;
|
||||
filterSummaries: string;
|
||||
noMatches: string;
|
||||
markdown: {
|
||||
overview: string;
|
||||
userContext: string;
|
||||
work: string;
|
||||
personal: string;
|
||||
topOfMind: string;
|
||||
historyBackground: string;
|
||||
recentMonths: string;
|
||||
earlierContext: string;
|
||||
longTermBackground: string;
|
||||
updatedAt: string;
|
||||
facts: string;
|
||||
empty: string;
|
||||
table: {
|
||||
category: string;
|
||||
confidence: string;
|
||||
confidenceLevel: {
|
||||
veryHigh: string;
|
||||
high: string;
|
||||
normal: string;
|
||||
unknown: string;
|
||||
};
|
||||
content: string;
|
||||
source: string;
|
||||
createdAt: string;
|
||||
view: string;
|
||||
};
|
||||
};
|
||||
searchModeRecall: string;
|
||||
searchModeReflect: string;
|
||||
searchButton: string;
|
||||
searchUnavailable: string;
|
||||
searchNoResults: string;
|
||||
searchResultsTitle: string;
|
||||
searchEmpty: string;
|
||||
};
|
||||
appearance: {
|
||||
themeTitle: string;
|
||||
@@ -405,6 +418,14 @@ export interface Translations {
|
||||
deleteAction: string;
|
||||
deleteConfirm: string;
|
||||
deleteSuccess: string;
|
||||
detailSectionTitle: string;
|
||||
detailEmpty: string;
|
||||
detailEditAction: string;
|
||||
detailPlaceholder: string;
|
||||
detailSaveSuccess: string;
|
||||
detailAdminOnlyHint: string;
|
||||
contentSectionTitle: string;
|
||||
viewDetailAction: string;
|
||||
stateSheet: {
|
||||
archivedTitle: string;
|
||||
archivedDescription: string;
|
||||
@@ -480,6 +501,7 @@ export interface Translations {
|
||||
stale: string;
|
||||
archived: string;
|
||||
total: string;
|
||||
pinned: string;
|
||||
runNow: string;
|
||||
runStarted: string;
|
||||
runBlockedEvolutionDisabled: string;
|
||||
|
||||
@@ -48,6 +48,7 @@ export const zhCN: Translations = {
|
||||
export: "导出",
|
||||
exportAsMarkdown: "导出为 Markdown",
|
||||
exportAsJSON: "导出为 JSON",
|
||||
exportAsWord: "导出为 Word",
|
||||
exportSuccess: "对话已导出",
|
||||
},
|
||||
|
||||
@@ -238,6 +239,33 @@ export const zhCN: Translations = {
|
||||
searchChats: "搜索对话",
|
||||
},
|
||||
|
||||
// Conversation sharing
|
||||
threadShare: {
|
||||
importChats: "导入对话",
|
||||
shareTitle: "分享对话",
|
||||
shareDescription:
|
||||
"把这个对话分享给他人。对方在「导入对话」里填入分享码,即可把它复制一份到自己账号并继续聊天。",
|
||||
generating: "生成分享码中...",
|
||||
shareCodeLabel: "分享码",
|
||||
copyCode: "复制",
|
||||
codeCopied: "分享码已复制",
|
||||
shareHint: "知道分享码的人都能复制这个对话,请谨慎分享。",
|
||||
revoke: "撤销分享",
|
||||
revokeConfirm: "撤销后该分享码立即失效,确定撤销?",
|
||||
revokeSuccess: "已撤销分享",
|
||||
importTitle: "导入对话",
|
||||
importDescription: "填入对方给你的分享码,把 TA 分享的那个对话复制到你的账号。",
|
||||
codeInputPlaceholder: "粘贴分享码",
|
||||
preview: "预览",
|
||||
previewConversation: "对话:{title}",
|
||||
previewFrom: "来自用户 {user}",
|
||||
doImport: "导入",
|
||||
importing: "导入中...",
|
||||
importSuccess: "对话已导入",
|
||||
alreadyImported: "你已经导入过这个对话了",
|
||||
emptyCode: "请先填入分享码",
|
||||
},
|
||||
|
||||
// Page titles (document title)
|
||||
pages: {
|
||||
appName: "CM 助手",
|
||||
@@ -321,79 +349,62 @@ export const zhCN: Translations = {
|
||||
memory: {
|
||||
title: "记忆",
|
||||
description:
|
||||
"CM 助手 会在后台不断从你的对话中自动学习。这些记忆能帮助 CM 助手 更好地理解你,并提供更个性化的体验。",
|
||||
empty: "暂无可展示的记忆数据。",
|
||||
rawJson: "原始 JSON",
|
||||
exportButton: "导出记忆",
|
||||
exportSuccess: "记忆已导出",
|
||||
importButton: "导入记忆",
|
||||
importConfirmTitle: "导入记忆?",
|
||||
importConfirmDescription: "这会用选中的 JSON 备份覆盖当前记忆。",
|
||||
importFileLabel: "已选择文件",
|
||||
importInvalidFile: "读取记忆文件失败,请选择有效的 JSON 导出文件。",
|
||||
importSuccess: "记忆已导入",
|
||||
manualFactSource: "手动添加",
|
||||
addFact: "添加事实",
|
||||
addFactTitle: "添加记忆事实",
|
||||
editFactTitle: "编辑记忆事实",
|
||||
addFactSuccess: "事实已创建",
|
||||
editFactSuccess: "事实已更新",
|
||||
clearAll: "清空全部记忆",
|
||||
clearAllConfirmTitle: "要清空全部记忆吗?",
|
||||
clearAllConfirmDescription:
|
||||
"这会删除所有已保存的摘要和事实。此操作无法撤销。",
|
||||
clearAllSuccess: "已清空全部记忆",
|
||||
factDeleteConfirmTitle: "要删除这条事实吗?",
|
||||
factDeleteConfirmDescription:
|
||||
"这条事实会立即从记忆中删除。此操作无法撤销。",
|
||||
factDeleteSuccess: "事实已删除",
|
||||
factContentLabel: "内容",
|
||||
factCategoryLabel: "类别",
|
||||
factConfidenceLabel: "置信度",
|
||||
factContentPlaceholder: "描述你想保存的记忆事实",
|
||||
factCategoryPlaceholder: "context",
|
||||
factConfidenceHint: "请输入 0 到 1 之间的数字。",
|
||||
factSave: "保存事实",
|
||||
factValidationContent: "事实内容不能为空。",
|
||||
factValidationConfidence: "置信度必须是 0 到 1 之间的数字。",
|
||||
noFacts: "还没有保存的事实。",
|
||||
summaryReadOnly:
|
||||
"摘要分区当前仍为只读。现在你可以清空全部记忆或删除单条事实。",
|
||||
memoryFullyEmpty: "还没有保存任何记忆。",
|
||||
factPreviewLabel: "即将删除的事实",
|
||||
searchPlaceholder: "搜索记忆",
|
||||
filterAll: "全部",
|
||||
filterFacts: "事实",
|
||||
filterSummaries: "摘要",
|
||||
noMatches: "没有找到匹配的记忆。",
|
||||
markdown: {
|
||||
overview: "概览",
|
||||
userContext: "用户上下文",
|
||||
work: "工作",
|
||||
personal: "个人",
|
||||
topOfMind: "近期关注(Top of mind)",
|
||||
historyBackground: "历史背景",
|
||||
recentMonths: "近几个月",
|
||||
earlierContext: "更早上下文",
|
||||
longTermBackground: "长期背景",
|
||||
updatedAt: "更新于",
|
||||
facts: "事实",
|
||||
empty: "(空)",
|
||||
table: {
|
||||
category: "类别",
|
||||
confidence: "置信度",
|
||||
confidenceLevel: {
|
||||
veryHigh: "极高",
|
||||
high: "较高",
|
||||
normal: "一般",
|
||||
unknown: "未知",
|
||||
},
|
||||
content: "内容",
|
||||
source: "来源",
|
||||
createdAt: "创建时间",
|
||||
view: "查看",
|
||||
},
|
||||
},
|
||||
"CM 助手 的记忆分两类:用户画像跨所有助手共享,工作笔记按当前助手隔离。你可以在这里查看和管理。",
|
||||
loadError: "加载记忆失败",
|
||||
usageLabel: "用量",
|
||||
entryEmpty: "还没有记忆条目。",
|
||||
userProfileTitle: "用户画像",
|
||||
userProfileDesc: "关于你的事实 —— 姓名、角色、偏好、沟通风格,所有助手共享。",
|
||||
workNotesTitle: "工作笔记",
|
||||
workNotesDesc: "按专家隔离的工作笔记 —— 环境事实、约定、踩过的坑。选择专家查看对应笔记。",
|
||||
workNotesAgentLabel: "专家",
|
||||
workNotesDefaultAgent: "默认助手",
|
||||
addEntry: "添加条目",
|
||||
addEntryTitle: "添加记忆条目",
|
||||
editEntryTitle: "编辑记忆条目",
|
||||
entryContentLabel: "内容",
|
||||
entryContentPlaceholder: "描述你想保存的记忆内容",
|
||||
entrySaveSuccess: "条目已保存",
|
||||
entryValidationContent: "条目内容不能为空。",
|
||||
deleteEntryTitle: "要删除这条记忆吗?",
|
||||
deleteEntryDesc: "这条记忆会立即删除。此操作无法撤销。",
|
||||
deleteEntrySuccess: "条目已删除",
|
||||
entryToDeleteLabel: "即将删除的条目",
|
||||
save: "保存",
|
||||
settingsTitle: "记忆设置",
|
||||
settingsDesc: "调整记忆的注入与检索行为。修改即时生效。",
|
||||
injectionEnabled: "注入记忆到对话",
|
||||
injectionEnabledDesc: "记忆功能的总开关。开启后,助手回答时会参考它记住的关于你的信息和过往对话;关闭后,助手在对话中看不到任何记忆,每次都像第一次和你交流。下面几项都需要此开关开启才会生效。",
|
||||
includeBuiltin: "注入本地记忆",
|
||||
includeBuiltinDesc: "把保存在本机的记忆作为背景知识提供给助手,包括「用户画像」(你的身份、习惯、偏好等长期信息)和「工作笔记」(助手在协作中为你记下的要点)。这类记忆存在服务器本地文件里,不依赖任何外部服务。",
|
||||
includeHindsight: "注入 Hindsight 召回",
|
||||
includeHindsightDesc: "每次回答前,自动从 Hindsight 长期记忆库中检索与当前话题相关的历史记忆,临时补充给助手参考。需要已部署并连通 Hindsight 服务才会生效。",
|
||||
maxTokens: "注入 token 上限",
|
||||
maxTokensDesc: "每次注入的记忆最多占用多少篇幅(单位 token,中文大致相当于字数)。设得越大,助手能看到的记忆越多,但会挤占对话本身的可用空间。可填 100–8000。",
|
||||
memoryMode: "Hindsight 工具模式",
|
||||
memoryModeDesc: "决定助手如何使用 Hindsight 记忆库:由系统在后台自动检索、把检索工具交给助手按需自己调用、或两者都启用。",
|
||||
memoryModeContext: "context —— 仅后台自动召回,助手无需操作",
|
||||
memoryModeTools: "tools —— 仅提供检索工具,由助手按需自己查",
|
||||
memoryModeHybrid: "hybrid —— 后台自动召回 + 检索工具,两者都启用",
|
||||
recallBudget: "召回深度",
|
||||
recallBudgetDesc: "Hindsight 每次检索的彻底程度。越高找得越全面,但响应越慢、计算开销越大;越低越快,但可能漏掉相关记忆。",
|
||||
recallBudgetLow: "低",
|
||||
recallBudgetMid: "中",
|
||||
recallBudgetHigh: "高",
|
||||
bankMission: "Hindsight 综合推理设定",
|
||||
bankMissionDesc: "使用「综合」模式检索时,告诉 Hindsight 以什么身份、从什么角度来归纳整理记忆。留空则使用默认设定。",
|
||||
bankMissionPlaceholder: "例如:你是用户的长期助手,关注其工作偏好。",
|
||||
settingsSaved: "设置已保存",
|
||||
searchTitle: "Hindsight 记忆检索",
|
||||
searchDesc: "在 Hindsight 长期记忆库里检索历史记忆。",
|
||||
searchPlaceholder: "搜索历史记忆…",
|
||||
searchModeRecall: "检索",
|
||||
searchModeReflect: "综合",
|
||||
searchButton: "搜索",
|
||||
searchUnavailable: "Hindsight 未配置或不可用。",
|
||||
searchNoResults: "没有找到相关记忆。",
|
||||
searchResultsTitle: "检索结果",
|
||||
searchEmpty: "输入关键词检索 Hindsight 记忆。",
|
||||
},
|
||||
appearance: {
|
||||
themeTitle: "主题",
|
||||
@@ -454,6 +465,14 @@ export const zhCN: Translations = {
|
||||
deleteAction: "删除技能",
|
||||
deleteConfirm: "确认删除技能 “{name}”?该操作不可恢复,且会从引用它的所有智能体中自动移除。",
|
||||
deleteSuccess: "技能已删除",
|
||||
detailSectionTitle: "详情",
|
||||
detailEmpty: "暂无详情说明。",
|
||||
detailEditAction: "编辑详情",
|
||||
detailPlaceholder: "用 Markdown 补充技能的用途、适用场景、使用说明等。",
|
||||
detailSaveSuccess: "详情已更新",
|
||||
detailAdminOnlyHint: "仅管理员或技能发布者可编辑详情。",
|
||||
contentSectionTitle: "SKILL.md 原文",
|
||||
viewDetailAction: "查看技能详情",
|
||||
stateSheet: {
|
||||
archivedTitle: "归档的技能",
|
||||
archivedDescription: "这些技能已被你或策展程序归档,可随时恢复。",
|
||||
@@ -531,6 +550,7 @@ export const zhCN: Translations = {
|
||||
stale: "陈旧",
|
||||
archived: "已归档",
|
||||
total: "合计",
|
||||
pinned: "被钉住",
|
||||
runNow: "立即执行",
|
||||
runStarted: "已触发后台执行",
|
||||
runBlockedEvolutionDisabled: "自我进化未开启 —— 请先在下方打开开关。",
|
||||
@@ -547,17 +567,23 @@ export const zhCN: Translations = {
|
||||
description:
|
||||
"保存后会写入 skill_evolution.json 并热更新,无需重启。",
|
||||
enabledLabel: "启用自我进化",
|
||||
enabledHelper: "关闭后 agent 不会创建或修改 custom/ 下的技能。",
|
||||
enabledHelper:
|
||||
"自我进化的总开关。开启后会给 agent 注册 skill_manage 工具 —— 它就能在对话中创建、修改、删除 custom/ 目录下的技能,并在完成复杂任务后主动提议把流程沉淀为技能。关闭后这些写技能的能力全部收起,agent 只能查看和使用已有技能。下方 curator 的所有设置都需要此开关开启才生效。",
|
||||
curatorEnabledLabel: "curator 自动运行",
|
||||
curatorEnabledHelper: "关闭后只能在此页面手动触发。",
|
||||
curatorEnabledHelper:
|
||||
"curator 是后台的技能策展程序,会定期巡检技能库:把长期不用的技能标记为陈旧、再归档,并用 LLM 评审是否该合并重复技能。开启后按下面的运行间隔自动运行;关闭后只能在本页面手动点「立即执行」触发。",
|
||||
intervalHoursLabel: "运行间隔(小时)",
|
||||
intervalHoursHelper: "两次自动运行之间至少经过的小时数。",
|
||||
intervalHoursHelper:
|
||||
"两次 curator 自动运行之间至少间隔的小时数,默认 168 小时(一周)。手动触发不受此限制。",
|
||||
staleAfterDaysLabel: "陈旧阈值(天)",
|
||||
staleAfterDaysHelper: "技能长时间不活跃后被标记为陈旧。",
|
||||
staleAfterDaysHelper:
|
||||
"一个技能连续这么多天没被使用,就会被 curator 标记为「陈旧」。陈旧技能不再注入 agent 的提示词,但文件仍保留;被重新用到时会自动恢复为活跃。",
|
||||
archiveAfterDaysLabel: "归档阈值(天)",
|
||||
archiveAfterDaysHelper: "标记陈旧后再经过这么多天会被自动归档。",
|
||||
archiveAfterDaysHelper:
|
||||
"一个技能从最后一次使用算起连续闲置这么多天,就会被 curator 自动「归档」(按总闲置天数计算,不是在标记陈旧之后再单独累加)。归档是软删除,文件仍留在 custom/ 目录、可随时手动恢复;此值需大于陈旧阈值。",
|
||||
curatorModelLabel: "技能优化模型",
|
||||
curatorModelHelper: "用于 curator 技能评审和技能安全审核的模型,为空则使用主模型。",
|
||||
curatorModelHelper:
|
||||
"curator 做技能评审(判断能否合并、精简)和技能安全审核时使用的模型。留空则使用主模型。",
|
||||
curatorModelDefault: "主模型(默认)",
|
||||
save: "保存修改",
|
||||
saveSuccess: "配置已更新",
|
||||
|
||||
+143
-149
@@ -1,149 +1,143 @@
|
||||
import { getBackendBaseURL } from "../config";
|
||||
import { apiFetch } from "../api";
|
||||
|
||||
import type {
|
||||
MemoryFactInput,
|
||||
MemoryFactPatchInput,
|
||||
UserMemory,
|
||||
} from "./types";
|
||||
|
||||
async function readMemoryResponse(
|
||||
response: Response,
|
||||
fallbackMessage: string,
|
||||
): Promise<UserMemory> {
|
||||
function formatErrorDetail(detail: unknown): string | null {
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail
|
||||
.map((item) => {
|
||||
if (typeof item === "string") {
|
||||
return item;
|
||||
}
|
||||
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.msg === "string") {
|
||||
return record.msg;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.stringify(record);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return String(item);
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return parts.length > 0 ? parts.join("; ") : null;
|
||||
}
|
||||
|
||||
if (detail && typeof detail === "object") {
|
||||
try {
|
||||
return JSON.stringify(detail);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
typeof detail === "string" ||
|
||||
typeof detail === "number" ||
|
||||
typeof detail === "boolean" ||
|
||||
typeof detail === "bigint"
|
||||
) {
|
||||
return String(detail);
|
||||
}
|
||||
|
||||
if (typeof detail === "symbol") {
|
||||
return detail.description ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json().catch(() => ({}))) as {
|
||||
detail?: unknown;
|
||||
};
|
||||
const detailMessage = formatErrorDetail(errorData.detail);
|
||||
throw new Error(
|
||||
detailMessage ?? `${fallbackMessage}: ${response.statusText}`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<UserMemory>;
|
||||
}
|
||||
|
||||
export async function loadMemory(): Promise<UserMemory> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory`);
|
||||
return readMemoryResponse(response, "Failed to fetch memory");
|
||||
}
|
||||
|
||||
export async function clearMemory(): Promise<UserMemory> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to clear memory");
|
||||
}
|
||||
|
||||
export async function deleteMemoryFact(factId: string): Promise<UserMemory> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
},
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to delete memory fact");
|
||||
}
|
||||
|
||||
export async function exportMemory(): Promise<UserMemory> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory/export`);
|
||||
return readMemoryResponse(response, "Failed to export memory");
|
||||
}
|
||||
|
||||
export async function importMemory(memory: UserMemory): Promise<UserMemory> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory/import`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(memory),
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to import memory");
|
||||
}
|
||||
|
||||
export async function createMemoryFact(
|
||||
input: MemoryFactInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory/facts`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return readMemoryResponse(response, "Failed to create memory fact");
|
||||
}
|
||||
|
||||
export async function updateMemoryFact(
|
||||
factId: string,
|
||||
input: MemoryFactPatchInput,
|
||||
): Promise<UserMemory> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/facts/${encodeURIComponent(factId)}`,
|
||||
{
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(input),
|
||||
},
|
||||
);
|
||||
return readMemoryResponse(response, "Failed to update memory fact");
|
||||
}
|
||||
// Memory V2 API 客户端。对应后端 /api/memory/v2 系列端点。
|
||||
|
||||
import { apiFetch } from "../api";
|
||||
import { getBackendBaseURL } from "../config";
|
||||
|
||||
import type {
|
||||
MemorySearchMode,
|
||||
MemorySearchResult,
|
||||
MemoryTarget,
|
||||
MemoryV2ConfigResponse,
|
||||
MemoryV2Data,
|
||||
} from "./types";
|
||||
|
||||
/** 从响应里提取后端的错误详情文本。 */
|
||||
function formatErrorDetail(detail: unknown): string | null {
|
||||
if (typeof detail === "string") {
|
||||
return detail;
|
||||
}
|
||||
if (Array.isArray(detail)) {
|
||||
const parts = detail
|
||||
.map((item) => {
|
||||
if (typeof item === "string") return item;
|
||||
if (item && typeof item === "object") {
|
||||
const record = item as Record<string, unknown>;
|
||||
if (typeof record.msg === "string") return record.msg;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
return parts.length > 0 ? parts.join("; ") : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function readJson<T>(response: Response, fallbackMessage: string): Promise<T> {
|
||||
if (!response.ok) {
|
||||
const errorData = (await response.json().catch(() => ({}))) as {
|
||||
detail?: unknown;
|
||||
};
|
||||
const detailMessage = formatErrorDetail(errorData.detail);
|
||||
throw new Error(detailMessage ?? `${fallbackMessage}: ${response.statusText}`);
|
||||
}
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
/** 拼接可选的 ?agent= 查询串。 */
|
||||
function agentQuery(agent?: string): string {
|
||||
return agent ? `?agent=${encodeURIComponent(agent)}` : "";
|
||||
}
|
||||
|
||||
// --- 本地记忆数据 (USER.md / MEMORY.md) ---
|
||||
|
||||
export async function loadMemoryV2(agent?: string): Promise<MemoryV2Data> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/v2${agentQuery(agent)}`,
|
||||
);
|
||||
return readJson<MemoryV2Data>(response, "Failed to fetch memory");
|
||||
}
|
||||
|
||||
export async function addMemoryEntry(
|
||||
target: MemoryTarget,
|
||||
content: string,
|
||||
agent?: string,
|
||||
): Promise<MemoryV2Data> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/v2/entries${agentQuery(agent)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target, content }),
|
||||
},
|
||||
);
|
||||
return readJson<MemoryV2Data>(response, "Failed to add memory entry");
|
||||
}
|
||||
|
||||
export async function replaceMemoryEntry(
|
||||
target: MemoryTarget,
|
||||
oldText: string,
|
||||
content: string,
|
||||
agent?: string,
|
||||
): Promise<MemoryV2Data> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/v2/entries${agentQuery(agent)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ target, old_text: oldText, content }),
|
||||
},
|
||||
);
|
||||
return readJson<MemoryV2Data>(response, "Failed to replace memory entry");
|
||||
}
|
||||
|
||||
export async function deleteMemoryEntry(
|
||||
target: MemoryTarget,
|
||||
oldText: string,
|
||||
agent?: string,
|
||||
): Promise<MemoryV2Data> {
|
||||
const params = new URLSearchParams({ target, old_text: oldText });
|
||||
if (agent) {
|
||||
params.set("agent", agent);
|
||||
}
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/v2/entries?${params.toString()}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
return readJson<MemoryV2Data>(response, "Failed to delete memory entry");
|
||||
}
|
||||
|
||||
// --- 配置 ---
|
||||
|
||||
export async function loadMemoryConfig(): Promise<MemoryV2ConfigResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory/v2/config`);
|
||||
return readJson<MemoryV2ConfigResponse>(response, "Failed to fetch memory config");
|
||||
}
|
||||
|
||||
export async function patchMemoryConfig(
|
||||
overrides: Record<string, unknown>,
|
||||
): Promise<MemoryV2ConfigResponse> {
|
||||
const response = await apiFetch(`${getBackendBaseURL()}/api/memory/v2/config`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ overrides }),
|
||||
});
|
||||
return readJson<MemoryV2ConfigResponse>(response, "Failed to update memory config");
|
||||
}
|
||||
|
||||
// --- Hindsight 检索 ---
|
||||
|
||||
export async function searchMemory(
|
||||
query: string,
|
||||
mode: MemorySearchMode = "recall",
|
||||
agent?: string,
|
||||
): Promise<MemorySearchResult> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/memory/v2/search${agentQuery(agent)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query, mode }),
|
||||
},
|
||||
);
|
||||
return readJson<MemorySearchResult>(response, "Failed to search memory");
|
||||
}
|
||||
@@ -1,84 +1,92 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
clearMemory,
|
||||
createMemoryFact,
|
||||
deleteMemoryFact,
|
||||
importMemory,
|
||||
loadMemory,
|
||||
updateMemoryFact,
|
||||
} from "./api";
|
||||
import type {
|
||||
MemoryFactInput,
|
||||
MemoryFactPatchInput,
|
||||
UserMemory,
|
||||
} from "./types";
|
||||
|
||||
export function useMemory() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["memory"],
|
||||
queryFn: () => loadMemory(),
|
||||
});
|
||||
return { memory: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
export function useClearMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: () => clearMemory(),
|
||||
onSuccess: (memory) => {
|
||||
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useDeleteMemoryFact() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (factId: string) => deleteMemoryFact(factId),
|
||||
onSuccess: (memory) => {
|
||||
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportMemory() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (memory: UserMemory) => importMemory(memory),
|
||||
onSuccess: (memory) => {
|
||||
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateMemoryFact() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (input: MemoryFactInput) => createMemoryFact(input),
|
||||
onSuccess: (memory) => {
|
||||
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateMemoryFact() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
factId,
|
||||
input,
|
||||
}: {
|
||||
factId: string;
|
||||
input: MemoryFactPatchInput;
|
||||
}) => updateMemoryFact(factId, input),
|
||||
onSuccess: (memory) => {
|
||||
queryClient.setQueryData<UserMemory>(["memory"], memory);
|
||||
},
|
||||
});
|
||||
}
|
||||
// Memory V2 React Query hooks。
|
||||
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
addMemoryEntry,
|
||||
deleteMemoryEntry,
|
||||
loadMemoryConfig,
|
||||
loadMemoryV2,
|
||||
patchMemoryConfig,
|
||||
replaceMemoryEntry,
|
||||
} from "./api";
|
||||
import type {
|
||||
MemoryTarget,
|
||||
MemoryV2ConfigResponse,
|
||||
MemoryV2Data,
|
||||
} from "./types";
|
||||
|
||||
const MEMORY_KEY = ["memory", "v2"] as const;
|
||||
const MEMORY_CONFIG_KEY = ["memory", "v2", "config"] as const;
|
||||
|
||||
/** 读取 builtin 本地记忆 (USER.md / MEMORY.md)。 */
|
||||
export function useMemory(agent?: string) {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: [...MEMORY_KEY, agent ?? ""],
|
||||
queryFn: () => loadMemoryV2(agent),
|
||||
});
|
||||
return { memory: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
/** 新增一条记忆条目。 */
|
||||
export function useAddMemoryEntry(agent?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ target, content }: { target: MemoryTarget; content: string }) =>
|
||||
addMemoryEntry(target, content, agent),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MemoryV2Data>([...MEMORY_KEY, agent ?? ""], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 替换一条记忆条目。 */
|
||||
export function useReplaceMemoryEntry(agent?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
target,
|
||||
oldText,
|
||||
content,
|
||||
}: {
|
||||
target: MemoryTarget;
|
||||
oldText: string;
|
||||
content: string;
|
||||
}) => replaceMemoryEntry(target, oldText, content, agent),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MemoryV2Data>([...MEMORY_KEY, agent ?? ""], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除一条记忆条目。 */
|
||||
export function useDeleteMemoryEntry(agent?: string) {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ target, oldText }: { target: MemoryTarget; oldText: string }) =>
|
||||
deleteMemoryEntry(target, oldText, agent),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MemoryV2Data>([...MEMORY_KEY, agent ?? ""], data);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** 读取生效记忆配置 + 白名单。 */
|
||||
export function useMemoryConfig() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: MEMORY_CONFIG_KEY,
|
||||
queryFn: () => loadMemoryConfig(),
|
||||
});
|
||||
return { configResponse: data ?? null, isLoading, error };
|
||||
}
|
||||
|
||||
/** 写入 per-user 配置覆盖。 */
|
||||
export function usePatchMemoryConfig() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (overrides: Record<string, unknown>) => patchMemoryConfig(overrides),
|
||||
onSuccess: (data) => {
|
||||
queryClient.setQueryData<MemoryV2ConfigResponse>(MEMORY_CONFIG_KEY, data);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,54 +1,57 @@
|
||||
export interface MemoryFact {
|
||||
id: string;
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
createdAt: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface MemoryFactInput {
|
||||
content: string;
|
||||
category: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
export interface MemoryFactPatchInput {
|
||||
content?: string;
|
||||
category?: string;
|
||||
confidence?: number;
|
||||
}
|
||||
|
||||
export interface UserMemory {
|
||||
version: string;
|
||||
lastUpdated: string;
|
||||
user: {
|
||||
workContext: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
personalContext: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
topOfMind: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
history: {
|
||||
recentMonths: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
earlierContext: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
longTermBackground: {
|
||||
summary: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
};
|
||||
facts: MemoryFact[];
|
||||
}
|
||||
// Memory V2 数据模型。对应后端 /api/memory/v2 系列端点。
|
||||
|
||||
/** builtin 本地记忆数据视图 (USER.md / MEMORY.md)。 */
|
||||
export interface MemoryV2Data {
|
||||
/** USER.md 条目 —— 用户画像,跨 agent 共享。 */
|
||||
user_entries: string[];
|
||||
/** MEMORY.md 条目 —— 当前 agent 的工作笔记。 */
|
||||
memory_entries: string[];
|
||||
/** USER.md 字符用量,如 "12% — 165/1375 字符"。 */
|
||||
user_usage: string;
|
||||
/** MEMORY.md 字符用量。 */
|
||||
memory_usage: string;
|
||||
}
|
||||
|
||||
/** 记忆条目所属类别。 */
|
||||
export type MemoryTarget = "user" | "memory";
|
||||
|
||||
/** 生效记忆配置 + 用户可改字段白名单。 */
|
||||
export interface MemoryV2ConfigResponse {
|
||||
/** 按 per-user 覆盖合并后的生效配置 (嵌套 dict)。 */
|
||||
config: MemoryV2Config;
|
||||
/** 用户可在运行时覆盖的字段 (点号路径)。 */
|
||||
writable_fields: string[];
|
||||
}
|
||||
|
||||
/** 生效记忆配置的关注字段 (后端 dict 的子集)。 */
|
||||
export interface MemoryV2Config {
|
||||
enabled: boolean;
|
||||
provider: string;
|
||||
injection: {
|
||||
enabled: boolean;
|
||||
max_tokens: number;
|
||||
include_builtin: boolean;
|
||||
include_hindsight: boolean;
|
||||
context_tag: string;
|
||||
};
|
||||
hindsight: {
|
||||
memory_mode: string;
|
||||
recall_budget: string;
|
||||
bank_mission: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
/** Hindsight 检索模式。 */
|
||||
export type MemorySearchMode = "recall" | "reflect";
|
||||
|
||||
/** Hindsight 检索结果。 */
|
||||
export interface MemorySearchResult {
|
||||
/** Hindsight 是否可用 (未配置/未装包时为 false)。 */
|
||||
available: boolean;
|
||||
/** recall 模式的检索结果。 */
|
||||
results: string[];
|
||||
/** reflect 模式的综合结论。 */
|
||||
text: string;
|
||||
}
|
||||
@@ -14,20 +14,20 @@ interface AssistantMessageGroup extends GenericMessageGroup<"assistant"> {}
|
||||
|
||||
interface AssistantPresentFilesGroup extends GenericMessageGroup<"assistant:present-files"> {}
|
||||
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:clarification"> {}
|
||||
|
||||
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
|
||||
|
||||
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
|
||||
|
||||
type MessageGroup =
|
||||
| HumanMessageGroup
|
||||
| AssistantProcessingGroup
|
||||
| AssistantMessageGroup
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
| AssistantPresentFilesGroup
|
||||
| AssistantClarificationGroup
|
||||
| AssistantSetupAgentApprovalGroup
|
||||
| AssistantSubagentGroup;
|
||||
|
||||
export function groupMessages<T>(
|
||||
messages: Message[],
|
||||
@@ -44,11 +44,11 @@ export function groupMessages<T>(
|
||||
function lastOpenGroup() {
|
||||
const last = groups[groups.length - 1];
|
||||
if (
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
last &&
|
||||
last.type !== "human" &&
|
||||
last.type !== "assistant" &&
|
||||
last.type !== "assistant:clarification" &&
|
||||
last.type !== "assistant:setup-agent-approval"
|
||||
) {
|
||||
return last;
|
||||
}
|
||||
@@ -70,23 +70,23 @@ export function groupMessages<T>(
|
||||
}
|
||||
|
||||
if (message.type === "tool") {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
if (isClarificationToolMessage(message)) {
|
||||
// Add to the preceding processing group to preserve tool-call association,
|
||||
// then also open a standalone clarification group for prominent display.
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:clarification",
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
messages: [message],
|
||||
});
|
||||
} else if (isSetupAgentApprovalToolMessage(message)) {
|
||||
lastOpenGroup()?.messages.push(message);
|
||||
groups.push({
|
||||
id: message.id,
|
||||
type: "assistant:setup-agent-approval",
|
||||
messages: [message],
|
||||
});
|
||||
} else {
|
||||
const open = lastOpenGroup();
|
||||
if (open) {
|
||||
open.messages.push(message);
|
||||
@@ -297,19 +297,19 @@ export function hasPresentFiles(message: Message) {
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function isClarificationToolMessage(message: Message) {
|
||||
return message.type === "tool" && message.name === "ask_clarification";
|
||||
}
|
||||
|
||||
export function isSetupAgentApprovalToolMessage(message: Message) {
|
||||
return (
|
||||
message.type === "tool" &&
|
||||
message.name === "setup_agent" &&
|
||||
typeof message.additional_kwargs?.setup_agent_approval === "object" &&
|
||||
message.additional_kwargs.setup_agent_approval !== null
|
||||
);
|
||||
}
|
||||
|
||||
export function extractPresentFilesFromMessage(message: Message) {
|
||||
if (message.type !== "ai" || !hasPresentFiles(message)) {
|
||||
return [];
|
||||
@@ -372,6 +372,13 @@ export function stripUploadedFilesTag(content: string): string {
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Strip injected <artifact ...>...</artifact> blocks from human message display. */
|
||||
export function stripArtifactTag(content: string): string {
|
||||
return content
|
||||
.replace(/<artifact[^>]*>[\s\S]*?<\/artifact>/g, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function parseUploadedFiles(content: string): FileInMessage[] {
|
||||
// Match <uploaded_files>...</uploaded_files> tag
|
||||
const uploadedFilesRegex = /<uploaded_files>([\s\S]*?)<\/uploaded_files>/;
|
||||
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ScheduledTaskResultFileContent,
|
||||
ScheduledTaskRun,
|
||||
ScheduledTaskRunFileUpdateResponse,
|
||||
ScheduledTaskRunMessagesResponse,
|
||||
ScheduledTaskSubscription,
|
||||
SchedulerThread,
|
||||
} from "./types";
|
||||
@@ -30,13 +31,13 @@ export async function getSchedulerThread() {
|
||||
|
||||
export interface ListScheduledTasksParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
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);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}${query ? `?${query}` : ""}`),
|
||||
@@ -79,6 +80,13 @@ export async function deleteScheduledTaskRun(runId: string) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Agent messages for a run's underlying agent run — used to inspect a stuck/running run. */
|
||||
export async function getScheduledTaskRunMessages(runId: string) {
|
||||
return parseResponse<ScheduledTaskRunMessagesResponse>(
|
||||
await apiFetch(`${baseUrl()}/runs/${encodeURIComponent(runId)}/messages`),
|
||||
);
|
||||
}
|
||||
|
||||
export function scheduledTaskRunDownloadUrl(runId: string) {
|
||||
return `${baseUrl()}/runs/${encodeURIComponent(runId)}/download`;
|
||||
}
|
||||
@@ -296,3 +304,61 @@ export async function unsubscribeScheduledTask(taskId: string) {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// --- Admin (cross-user management) ---
|
||||
|
||||
/** List every scheduled task across all users. Admin-only endpoint. */
|
||||
export async function listAllScheduledTasks(search?: string) {
|
||||
const qs = new URLSearchParams();
|
||||
if (search) qs.set("search", search);
|
||||
const query = qs.toString();
|
||||
return parseResponse<ScheduledTask[]>(
|
||||
await apiFetch(`${baseUrl()}/admin/all${query ? `?${query}` : ""}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** List runs for any task regardless of owner. Admin-only endpoint. */
|
||||
export async function adminListScheduledTaskRuns(taskId: string) {
|
||||
return parseResponse<ScheduledTaskRun[]>(
|
||||
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/runs`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Update any task regardless of owner. Admin-only endpoint. */
|
||||
export async function adminUpdateScheduledTask(
|
||||
taskId: string,
|
||||
data: Partial<{
|
||||
name: string;
|
||||
prompt: string;
|
||||
schedule: string;
|
||||
timezone: string;
|
||||
enabled: boolean;
|
||||
execution_agent_name: string | null;
|
||||
}>,
|
||||
) {
|
||||
return parseResponse<ScheduledTask>(
|
||||
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Delete any task regardless of owner. Admin-only endpoint. */
|
||||
export async function adminDeleteScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean }>(
|
||||
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}`, {
|
||||
method: "DELETE",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
/** Trigger an immediate run of any task regardless of owner. Admin-only. */
|
||||
export async function adminTriggerScheduledTask(taskId: string) {
|
||||
return parseResponse<{ success: boolean; task: ScheduledTask }>(
|
||||
await apiFetch(`${baseUrl()}/admin/${encodeURIComponent(taskId)}/trigger`, {
|
||||
method: "POST",
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -6,12 +6,18 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
adminDeleteScheduledTask,
|
||||
adminListScheduledTaskRuns,
|
||||
adminTriggerScheduledTask,
|
||||
adminUpdateScheduledTask,
|
||||
createScheduledTask,
|
||||
deleteScheduledTask,
|
||||
deleteScheduledTaskRun,
|
||||
getSchedulerThread,
|
||||
getScheduledTaskDeliveryProfile,
|
||||
getScheduledTaskRun,
|
||||
getScheduledTaskRunMessages,
|
||||
listAllScheduledTasks,
|
||||
listPublishedScheduledTasks,
|
||||
listScheduledTaskRuns,
|
||||
listScheduledTasks,
|
||||
@@ -35,16 +41,27 @@ export function useSchedulerThread() {
|
||||
});
|
||||
}
|
||||
|
||||
/** A task is currently executing — used to drive live polling of the task list. */
|
||||
function taskIsLive(status?: string | null): boolean {
|
||||
return status === "running" || status === "pending";
|
||||
}
|
||||
|
||||
export function useScheduledTasks(
|
||||
params: { search?: string; tagId?: string | null } = {},
|
||||
params: { search?: string; tagIds?: string[] } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "list", search, tagId],
|
||||
queryFn: () => listScheduledTasks({ search, tagId }),
|
||||
queryKey: ["scheduled-tasks", "list", search, [...tagIds].sort()],
|
||||
queryFn: () => listScheduledTasks({ search, tagIds }),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
// Poll the list while any task is currently executing so its last_status
|
||||
// and last_run_at refresh without a manual reload.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
|
||||
? 5000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -54,15 +71,44 @@ export function useScheduledTaskRuns(taskId?: string | null) {
|
||||
queryFn: () => listScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Poll the run list while any run is in flight so the user can watch
|
||||
// status transitions (pending → running → succeeded/failed) without
|
||||
// refreshing the page.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((run) => runIsLive(run.status))
|
||||
? 4000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** A run still in flight — used to drive live polling of the run/messages. */
|
||||
function runIsLive(status?: string | null): boolean {
|
||||
return status === "running" || status === "pending";
|
||||
}
|
||||
|
||||
export function useScheduledTaskRun(runId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId],
|
||||
queryFn: () => getScheduledTaskRun(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Keep refreshing while the run is still executing so the detail page
|
||||
// reflects status / result changes without a manual reload.
|
||||
refetchInterval: (query) => (runIsLive(query.state.data?.status) ? 5000 : false),
|
||||
});
|
||||
}
|
||||
|
||||
/** Agent messages for a run; polls every 4s while the run is still executing. */
|
||||
export function useScheduledTaskRunMessages(
|
||||
runId?: string | null,
|
||||
options: { live?: boolean } = {},
|
||||
) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "runs", runId, "messages"],
|
||||
queryFn: () => getScheduledTaskRunMessages(runId!),
|
||||
enabled: !!runId,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchInterval: options.live ? 4000 : false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -74,6 +120,74 @@ export function useScheduledTaskDeliveryProfile() {
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin: every scheduled task across all users. */
|
||||
export function useAllScheduledTasks(
|
||||
params: { search?: string; enabled?: boolean } = {},
|
||||
) {
|
||||
const search = params.search ?? "";
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "admin", "list", search],
|
||||
queryFn: () => listAllScheduledTasks(search),
|
||||
enabled: params.enabled ?? true,
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: keepPreviousData,
|
||||
// Mirror the user list polling so the admin view also refreshes live.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((task) => taskIsLive(task.last_status))
|
||||
? 5000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin: runs for any task regardless of owner. */
|
||||
export function useAdminScheduledTaskRuns(taskId?: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "admin", taskId, "runs"],
|
||||
queryFn: () => adminListScheduledTaskRuns(taskId!),
|
||||
enabled: !!taskId,
|
||||
refetchOnWindowFocus: false,
|
||||
// Poll while any run is still executing so admins see live progress.
|
||||
refetchInterval: (query) =>
|
||||
(query.state.data ?? []).some((run) => runIsLive(run.status))
|
||||
? 4000
|
||||
: false,
|
||||
});
|
||||
}
|
||||
|
||||
/** Admin mutations: edit / delete / trigger any task. */
|
||||
export function useAdminScheduledTaskActions() {
|
||||
const queryClient = useQueryClient();
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["scheduled-tasks"] });
|
||||
|
||||
return {
|
||||
update: useMutation({
|
||||
mutationFn: ({
|
||||
taskId,
|
||||
data,
|
||||
}: {
|
||||
taskId: string;
|
||||
data: Parameters<typeof adminUpdateScheduledTask>[1];
|
||||
}) => adminUpdateScheduledTask(taskId, data),
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
remove: useMutation({
|
||||
mutationFn: adminDeleteScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
// The DELETE /runs/{id} route is admin-aware, so the same call deletes
|
||||
// any user's run when the caller is an admin.
|
||||
removeRun: useMutation({
|
||||
mutationFn: deleteScheduledTaskRun,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
trigger: useMutation({
|
||||
mutationFn: adminTriggerScheduledTask,
|
||||
onSuccess: invalidate,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function usePublishedScheduledTasks(enabled: boolean = true) {
|
||||
return useQuery({
|
||||
queryKey: ["scheduled-tasks", "published"],
|
||||
|
||||
@@ -94,6 +94,7 @@ export type ScheduledTaskRun = {
|
||||
status: "pending" | "running" | "succeeded" | "failed" | string;
|
||||
error?: string | null;
|
||||
result?: ScheduledTaskRunResult | null;
|
||||
retry_count?: number | null;
|
||||
created_at?: string | null;
|
||||
updated_at?: string | null;
|
||||
can_edit?: boolean;
|
||||
@@ -106,3 +107,19 @@ export type ScheduledTaskRunFileUpdateResponse = {
|
||||
mime_type?: string | null;
|
||||
kind?: string | null;
|
||||
};
|
||||
|
||||
/** One agent message produced by a scheduled run's underlying agent run. */
|
||||
export type ScheduledTaskRunMessage = {
|
||||
seq: number;
|
||||
event_type: string;
|
||||
category: string;
|
||||
// string for human input; object (LangChain message dump) for ai/tool.
|
||||
content: unknown;
|
||||
metadata?: Record<string, unknown> | null;
|
||||
created_at?: string | null;
|
||||
};
|
||||
|
||||
export type ScheduledTaskRunMessagesResponse = {
|
||||
messages: ScheduledTaskRunMessage[];
|
||||
has_more: boolean;
|
||||
};
|
||||
@@ -5,13 +5,13 @@ import type { Skill, SkillState } from "./type";
|
||||
|
||||
export interface ListSkillsParams {
|
||||
search?: string;
|
||||
tagId?: string | null;
|
||||
tagIds?: string[];
|
||||
}
|
||||
|
||||
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);
|
||||
for (const id of params.tagIds ?? []) qs.append("tag_ids", id);
|
||||
const query = qs.toString();
|
||||
const skills = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills${query ? `?${query}` : ""}`,
|
||||
@@ -20,6 +20,16 @@ export async function loadSkills(params: ListSkillsParams = {}) {
|
||||
return json.skills as Skill[];
|
||||
}
|
||||
|
||||
export async function getSkill(skillName: string): Promise<Skill> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to load skill");
|
||||
}
|
||||
return response.json() as Promise<Skill>;
|
||||
}
|
||||
|
||||
export async function enableSkill(skillName: string, enabled: boolean) {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${skillName}`,
|
||||
@@ -212,3 +222,30 @@ export async function deleteCustomSkill(skillName: string): Promise<void> {
|
||||
throw await parseDetailedError(response, "Failed to delete skill");
|
||||
}
|
||||
}
|
||||
|
||||
export interface SetSkillDetailResponse {
|
||||
name: string;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a skill's long-form detail text. Owner/admin for custom skills,
|
||||
* admin-only for built-in skills (backend enforces). An empty string clears it.
|
||||
*/
|
||||
export async function setSkillDetail(
|
||||
skillName: string,
|
||||
detail: string,
|
||||
): Promise<SetSkillDetailResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/skills/${encodeURIComponent(skillName)}/detail`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ detail }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw await parseDetailedError(response, "Failed to update skill detail");
|
||||
}
|
||||
return response.json() as Promise<SetSkillDetailResponse>;
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import {
|
||||
import {
|
||||
deleteCustomSkill,
|
||||
enableSkill,
|
||||
getSkill,
|
||||
getSkillContent,
|
||||
installSkillUpload,
|
||||
setSkillDetail,
|
||||
setSkillPinned,
|
||||
setSkillPublished,
|
||||
setSkillState,
|
||||
@@ -19,12 +21,12 @@ import type { SkillState } from "./type";
|
||||
|
||||
import { loadSkills } from ".";
|
||||
|
||||
export function useSkills(params: { search?: string; tagId?: string | null } = {}) {
|
||||
export function useSkills(params: { search?: string; tagIds?: string[] } = {}) {
|
||||
const search = params.search ?? "";
|
||||
const tagId = params.tagId ?? null;
|
||||
const tagIds = params.tagIds ?? [];
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ["skills", search, tagId],
|
||||
queryFn: () => loadSkills({ search, tagId }),
|
||||
queryKey: ["skills", search, [...tagIds].sort()],
|
||||
queryFn: () => loadSkills({ search, tagIds }),
|
||||
placeholderData: keepPreviousData,
|
||||
});
|
||||
return { skills: data ?? [], isLoading, error };
|
||||
@@ -38,6 +40,26 @@ export function useSkillContent(skillName: string | null) {
|
||||
});
|
||||
}
|
||||
|
||||
export function useSkill(skillName: string | null) {
|
||||
return useQuery({
|
||||
queryKey: ["skill", skillName],
|
||||
queryFn: () => getSkill(skillName!),
|
||||
enabled: !!skillName,
|
||||
});
|
||||
}
|
||||
|
||||
export function useSetSkillDetail() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({ skillName, detail }: { skillName: string; detail: string }) =>
|
||||
setSkillDetail(skillName, detail),
|
||||
onSuccess: (_data, { skillName }) => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["skills"] });
|
||||
void queryClient.invalidateQueries({ queryKey: ["skill", skillName] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useEnableSkill() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -14,6 +14,9 @@ export interface Skill {
|
||||
owner_user_id?: string | null;
|
||||
published?: boolean;
|
||||
builtin?: boolean;
|
||||
// Long-form detail/introduction authored by the skill owner or an admin.
|
||||
// Distinct from ``description`` (parsed from SKILL.md); supports Markdown.
|
||||
detail?: string | null;
|
||||
// Lifecycle fields — populated by curator. The main list filters on these
|
||||
// so stale / archived skills appear in dedicated drawers instead.
|
||||
state?: SkillState;
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/**
|
||||
* Share modes:
|
||||
* - `import` — recipient (a logged-in user) copies the conversation into
|
||||
* their own account and can keep chatting in the copy.
|
||||
* - `view` — a public, read-only static page anyone can open without
|
||||
* logging in; viewers cannot send new messages.
|
||||
*/
|
||||
export type ThreadShareMode = "import" | "view";
|
||||
|
||||
export interface ThreadShare {
|
||||
share_code: string;
|
||||
owner_user_id: string;
|
||||
thread_id: string;
|
||||
revoked: boolean;
|
||||
mode: ThreadShareMode;
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
/** Read-only conversation snapshot returned by the public view-share endpoint. */
|
||||
export interface PublicShareSnapshot {
|
||||
share_code: string;
|
||||
title: string;
|
||||
mode: string;
|
||||
messages: Message[];
|
||||
artifacts: string[];
|
||||
created_at: string | null;
|
||||
}
|
||||
|
||||
export interface SharePreview {
|
||||
share_code: string;
|
||||
shared_by: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const baseUrl = () => `${getBackendBaseURL()}/api/thread-shares`;
|
||||
|
||||
async function parseError(response: Response, fallback: string): Promise<Error> {
|
||||
const data = (await response.json().catch(() => ({}))) as { detail?: unknown };
|
||||
const detail = data.detail;
|
||||
const message =
|
||||
typeof detail === "string" && detail
|
||||
? detail
|
||||
: `${fallback} (HTTP ${response.status})`;
|
||||
return new Error(message);
|
||||
}
|
||||
|
||||
/** Create (or reuse) a share code for one conversation in the given mode. */
|
||||
export async function createShare(
|
||||
threadId: string,
|
||||
mode: ThreadShareMode = "import",
|
||||
): Promise<ThreadShare> {
|
||||
const response = await apiFetch(baseUrl(), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ thread_id: threadId, mode }),
|
||||
});
|
||||
if (!response.ok) throw await parseError(response, "创建分享失败");
|
||||
return response.json() as Promise<ThreadShare>;
|
||||
}
|
||||
|
||||
/** List the current user's own share links. */
|
||||
export async function listShares(): Promise<ThreadShare[]> {
|
||||
const response = await apiFetch(baseUrl());
|
||||
if (!response.ok) throw await parseError(response, "加载分享列表失败");
|
||||
const json = (await response.json()) as { shares: ThreadShare[] };
|
||||
return json.shares;
|
||||
}
|
||||
|
||||
/** Revoke a share link so it can no longer be previewed or imported. */
|
||||
export async function revokeShare(shareCode: string): Promise<void> {
|
||||
const response = await apiFetch(`${baseUrl()}/${encodeURIComponent(shareCode)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (!response.ok) throw await parseError(response, "撤销分享失败");
|
||||
}
|
||||
|
||||
/** Preview a share link before importing it. */
|
||||
export async function previewShare(shareCode: string): Promise<SharePreview> {
|
||||
const response = await apiFetch(
|
||||
`${baseUrl()}/${encodeURIComponent(shareCode)}/preview`,
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "无法读取该分享");
|
||||
return response.json() as Promise<SharePreview>;
|
||||
}
|
||||
|
||||
/** Import every conversation behind a share link into the current account. */
|
||||
export async function importShare(shareCode: string): Promise<ImportResult> {
|
||||
const response = await apiFetch(
|
||||
`${baseUrl()}/${encodeURIComponent(shareCode)}/import`,
|
||||
{ method: "POST" },
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "导入失败");
|
||||
return response.json() as Promise<ImportResult>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the public read-only snapshot of a `view`-mode share.
|
||||
*
|
||||
* Uses a plain `fetch` (no auth header) — the `/api/public/` endpoint is
|
||||
* intentionally world-readable so anyone with the link can open it.
|
||||
*/
|
||||
export async function getPublicShareSnapshot(
|
||||
shareCode: string,
|
||||
): Promise<PublicShareSnapshot> {
|
||||
const response = await fetch(
|
||||
`${getBackendBaseURL()}/api/public/shares/${encodeURIComponent(shareCode)}`,
|
||||
);
|
||||
if (!response.ok) throw await parseError(response, "无法打开该分享");
|
||||
return response.json() as Promise<PublicShareSnapshot>;
|
||||
}
|
||||
|
||||
/** Build the shareable public URL for a `view`-mode share code. */
|
||||
export function buildPublicShareUrl(shareCode: string): string {
|
||||
// The app uses HashRouter, so the public viewer lives at `#/share/<code>`.
|
||||
return `${window.location.origin}${window.location.pathname}#/share/${shareCode}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract a share code from arbitrary pasted text — accepts either a raw code
|
||||
* or a URL/link that contains one. Share codes are 6-12 chars from the
|
||||
* unambiguous alphabet (no 0/1/O/I/L).
|
||||
*/
|
||||
export function extractShareCode(input: string): string {
|
||||
const trimmed = input.trim();
|
||||
const matches = trimmed.toUpperCase().match(/[A-HJ-NP-Z2-9]{6,12}/g);
|
||||
return matches && matches.length > 0 ? matches[matches.length - 1] : trimmed;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
createShare,
|
||||
importShare,
|
||||
listShares,
|
||||
previewShare,
|
||||
revokeShare,
|
||||
type ThreadShareMode,
|
||||
} from "./api";
|
||||
|
||||
export function useShares(enabled = true) {
|
||||
return useQuery({
|
||||
queryKey: ["thread-shares"],
|
||||
queryFn: listShares,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: ({
|
||||
threadId,
|
||||
mode = "import",
|
||||
}: {
|
||||
threadId: string;
|
||||
mode?: ThreadShareMode;
|
||||
}) => createShare(threadId, mode),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (shareCode: string) => revokeShare(shareCode),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: ["thread-shares"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function usePreviewShare() {
|
||||
return useMutation({
|
||||
mutationFn: (shareCode: string) => previewShare(shareCode),
|
||||
});
|
||||
}
|
||||
|
||||
export function useImportShare() {
|
||||
const queryClient = useQueryClient();
|
||||
return useMutation({
|
||||
mutationFn: (shareCode: string) => importShare(shareCode),
|
||||
onSuccess: () => {
|
||||
// Imported conversations show up in the user's own thread list.
|
||||
void queryClient.invalidateQueries({ queryKey: ["threads"] });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Message } from "@langchain/langgraph-sdk";
|
||||
|
||||
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
|
||||
|
||||
import {
|
||||
extractContentFromMessage,
|
||||
extractReasoningContentFromMessage,
|
||||
@@ -138,3 +140,17 @@ export function exportThreadAsJSON(thread: AgentThread, messages: Message[]) {
|
||||
const filename = `${sanitizeFilename(titleOfThread(thread))}.json`;
|
||||
downloadAsFile(json, filename, "application/json;charset=utf-8");
|
||||
}
|
||||
|
||||
/**
|
||||
* Export the conversation as a Word document (.docx). The whole thread is
|
||||
* serialised to Markdown first (same formatter the `.md` export uses), and
|
||||
* then converted to a docx Blob in the browser via `markdownToDocxBlob`.
|
||||
*/
|
||||
export async function exportThreadAsWord(
|
||||
thread: AgentThread,
|
||||
messages: Message[],
|
||||
): Promise<void> {
|
||||
const markdown = formatThreadAsMarkdown(thread, messages);
|
||||
const title = titleOfThread(thread);
|
||||
await downloadMarkdownAsDocx(markdown, sanitizeFilename(title), title);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { apiFetch } from "@/core/api";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
/** Coarse failure buckets, mirrored from the backend classifier. */
|
||||
export const TOOL_ERROR_CATEGORIES = [
|
||||
"rate_limited",
|
||||
"ip_blocked",
|
||||
"auth_error",
|
||||
"timeout",
|
||||
"network_error",
|
||||
"not_found",
|
||||
"empty_result",
|
||||
"tool_error",
|
||||
] as const;
|
||||
|
||||
export type ToolErrorCategory = (typeof TOOL_ERROR_CATEGORIES)[number];
|
||||
|
||||
/** Human-readable Chinese labels for each failure bucket. */
|
||||
export const TOOL_ERROR_CATEGORY_LABELS: Record<string, string> = {
|
||||
rate_limited: "限流 / 频率超限",
|
||||
ip_blocked: "IP 被封 / 拒绝访问",
|
||||
auth_error: "鉴权失败",
|
||||
timeout: "超时",
|
||||
network_error: "网络错误",
|
||||
not_found: "资源不存在",
|
||||
empty_result: "检索为空",
|
||||
tool_error: "工具内部错误",
|
||||
};
|
||||
|
||||
export interface ToolMetric {
|
||||
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;
|
||||
tool_name?: string | null;
|
||||
/** The Agent Skill this call targets (skill tool calls only). */
|
||||
skill_name?: string | null;
|
||||
duration_ms: number;
|
||||
status: "success" | "error" | string;
|
||||
error_category?: string | null;
|
||||
error_message?: string | null;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListResponse {
|
||||
items: ToolMetric[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
/** Per-skill aggregate — was the skill invoked, and how often did it succeed. */
|
||||
export interface SkillStat {
|
||||
skill_name: string;
|
||||
total: number;
|
||||
success: number;
|
||||
error: number;
|
||||
}
|
||||
|
||||
export interface ToolMetricsSummary {
|
||||
total: number;
|
||||
error_total: number;
|
||||
by_category: Record<string, number>;
|
||||
by_skill: SkillStat[];
|
||||
}
|
||||
|
||||
export interface ToolMetricsFilters {
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
tool_name?: string;
|
||||
/** Coarse call kind: "skill" keeps only Agent-Skill invocations; omit for all. */
|
||||
kind?: "skill" | "all";
|
||||
/** Filter to one specific Agent Skill by name. */
|
||||
skill_name?: string;
|
||||
status?: "success" | "error";
|
||||
error_category?: string;
|
||||
since?: string;
|
||||
until?: string;
|
||||
}
|
||||
|
||||
export interface ToolMetricsListQuery extends ToolMetricsFilters {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
function buildQuery(filters: ToolMetricsListQuery): 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 loadToolMetrics(
|
||||
query: ToolMetricsListQuery,
|
||||
): Promise<ToolMetricsListResponse> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics${buildQuery(query)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsListResponse;
|
||||
}
|
||||
|
||||
export async function loadToolMetricsSummary(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<ToolMetricsSummary> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/summary${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to load tool metrics summary");
|
||||
}
|
||||
return (await response.json()) as ToolMetricsSummary;
|
||||
}
|
||||
|
||||
export async function downloadToolMetricsExcel(
|
||||
filters: ToolMetricsFilters,
|
||||
): Promise<void> {
|
||||
const response = await apiFetch(
|
||||
`${getBackendBaseURL()}/api/admin/tool-metrics/export${buildQuery(filters)}`,
|
||||
);
|
||||
if (!response.ok) {
|
||||
const detail = await response.json().catch(() => ({ detail: response.statusText }));
|
||||
throw new Error(detail.detail ?? "Failed to export tool 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] ?? `tool_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,26 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
loadToolMetrics,
|
||||
loadToolMetricsSummary,
|
||||
type ToolMetricsFilters,
|
||||
type ToolMetricsListQuery,
|
||||
} from "./api";
|
||||
|
||||
export function useToolMetrics(query: ToolMetricsListQuery) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetrics", query],
|
||||
queryFn: () => loadToolMetrics(query),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
|
||||
export function useToolMetricsSummary(filters: ToolMetricsFilters) {
|
||||
return useQuery({
|
||||
queryKey: ["toolMetricsSummary", filters],
|
||||
queryFn: () => loadToolMetricsSummary(filters),
|
||||
refetchOnWindowFocus: false,
|
||||
placeholderData: (previous) => previous,
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ function readEnv(
|
||||
key:
|
||||
| "VITE_BACKEND_BASE_URL"
|
||||
| "VITE_LANGGRAPH_BASE_URL"
|
||||
| "VITE_CANVAS_LANGGRAPH_BASE_URL"
|
||||
| "VITE_STATIC_WEBSITE_ONLY"
|
||||
| "VITE_GITHUB_OAUTH_TOKEN",
|
||||
): EnvValue {
|
||||
@@ -14,6 +15,9 @@ function readEnv(
|
||||
export const env = {
|
||||
NEXT_PUBLIC_BACKEND_BASE_URL: readEnv("VITE_BACKEND_BASE_URL"),
|
||||
NEXT_PUBLIC_LANGGRAPH_BASE_URL: readEnv("VITE_LANGGRAPH_BASE_URL"),
|
||||
// Canvas page uses its own LangGraph base URL so it can be independently
|
||||
// routed (e.g. a separate canvas-agent server). Falls back to the main URL.
|
||||
NEXT_PUBLIC_CANVAS_LANGGRAPH_BASE_URL: readEnv("VITE_CANVAS_LANGGRAPH_BASE_URL"),
|
||||
NEXT_PUBLIC_STATIC_WEBSITE_ONLY: readEnv("VITE_STATIC_WEBSITE_ONLY"),
|
||||
GITHUB_OAUTH_TOKEN: readEnv("VITE_GITHUB_OAUTH_TOKEN"),
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
export interface ArticleType {
|
||||
id: string;
|
||||
key: string;
|
||||
label: string;
|
||||
description: string | null;
|
||||
prompt_hint: string | null;
|
||||
default_word_count: number;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateArticleTypeRequest {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
prompt_hint?: string;
|
||||
default_word_count?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateArticleTypeRequest {
|
||||
key?: string;
|
||||
label?: string;
|
||||
description?: string | null;
|
||||
prompt_hint?: string | null;
|
||||
default_word_count?: number;
|
||||
sort_order?: number;
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/article-types`;
|
||||
|
||||
export async function listArticleTypes(): Promise<ArticleType[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function createArticleType(data: CreateArticleTypeRequest): Promise<ArticleType> {
|
||||
const resp = await apiFetch(BASE(), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 409) throw new Error(`文章类型标识 "${data.key}" 已存在`);
|
||||
if (!resp.ok) throw new Error(`创建文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function updateArticleType(id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
if (resp.status === 409) throw new Error(`文章类型标识已存在`);
|
||||
if (resp.status === 404) throw new Error(`文章类型不存在`);
|
||||
if (!resp.ok) throw new Error(`更新文章类型失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteArticleType(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (resp.status === 404) throw new Error(`文章类型不存在`);
|
||||
if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type { ProgressEvent, CompletedIntervention } from '../types/ai-writing';
|
||||
|
||||
export interface AIWritingSessionSummary {
|
||||
id: string;
|
||||
user_id: string | null;
|
||||
title: string;
|
||||
user_intent: string | null;
|
||||
status: string;
|
||||
draft_title: string | null;
|
||||
draft_markdown: string | null;
|
||||
completed_interventions: CompletedInterventionRecord[] | null;
|
||||
review_result: Record<string, unknown> | null;
|
||||
transcript: AIWritingTranscript | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CompletedInterventionRecord {
|
||||
pausePoint: string;
|
||||
action: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/** 完整对话时间线快照:与实时对话用同一份数据结构,历史回显时直接喂给 AIWritingTimeline。 */
|
||||
export interface AIWritingTranscript {
|
||||
progressEvents: ProgressEvent[];
|
||||
completedInterventions: CompletedIntervention[];
|
||||
}
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing/sessions`;
|
||||
|
||||
export async function listAIWritingSessions(): Promise<AIWritingSessionSummary[]> {
|
||||
const resp = await apiFetch(BASE());
|
||||
if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function getAIWritingSession(id: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`);
|
||||
if (!resp.ok) throw new Error(`获取会话失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function renameAIWritingSession(id: string, title: string): Promise<AIWritingSessionSummary> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`重命名失败: ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
export async function deleteAIWritingSession(id: string): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(`删除失败: ${resp.status}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。
|
||||
*
|
||||
* 该功能属于「锦上添花」:保存失败不应影响写作主流程,因此本函数永不抛错,
|
||||
* 内部捕获所有异常(网络错误 / 接口 500 等),仅打印告警并返回是否成功。
|
||||
*/
|
||||
export async function saveAIWritingTranscript(
|
||||
id: string,
|
||||
transcript: AIWritingTranscript,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ transcript }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn(`[ai-writing] 保存对话时间线失败: HTTP ${resp.status}(已忽略,不影响写作流程)`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.warn('[ai-writing] 保存对话时间线异常(已忽略,不影响写作流程):', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type {
|
||||
AIWritingRequest,
|
||||
ProgressEvent,
|
||||
ProgressEventType,
|
||||
UserInterventionPayload,
|
||||
} from '../types/ai-writing';
|
||||
|
||||
const BASE = () => `${getBackendBaseURL()}/api/ai-writing`;
|
||||
|
||||
/** 启动写作流程,返回 session_id */
|
||||
export async function startWritingSession(req: AIWritingRequest): Promise<string> {
|
||||
const resp = await apiFetch(`${BASE()}/start`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
user_intent: req.userIntent,
|
||||
article_type: req.articleType,
|
||||
target_audience: req.targetAudience,
|
||||
word_count_target: req.wordCountTarget,
|
||||
model_name: req.modelName ?? '',
|
||||
keyword_count: req.keywordCount,
|
||||
writing_mode: req.writingMode,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`启动失败: ${resp.status}`);
|
||||
const data = await resp.json();
|
||||
return data.session_id;
|
||||
}
|
||||
|
||||
const SSE_EVENT_TYPES: ProgressEventType[] = [
|
||||
'connected', 'step_start', 'step_done', 'research_progress',
|
||||
'materials_ready', 'outline_chunk', 'outline_ready',
|
||||
'draft_chunk', 'draft_ready',
|
||||
'review_chunk', 'review_item_done', 'review_ready',
|
||||
'await_user', 'resumed', 'notice', 'done', 'error',
|
||||
];
|
||||
|
||||
// ── 后端返回 snake_case,前端类型是 camelCase,统一在此转换 ──────────────────
|
||||
|
||||
function normalizeMaterial(m: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...m,
|
||||
relevanceScore: m.relevance_score ?? m.relevanceScore ?? 0,
|
||||
publishedAt: m.published_at ?? m.publishedAt ?? '',
|
||||
sourceType: m.source_type ?? m.sourceType ?? 'internet',
|
||||
recUuid: m.recUuid ?? m.rec_uuid ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutlineSection(s: Record<string, unknown>): Record<string, unknown> {
|
||||
return {
|
||||
...s,
|
||||
sectionTitle: s.section_title ?? s.sectionTitle ?? '',
|
||||
keyPoints: s.key_points ?? s.keyPoints ?? [],
|
||||
materialIds: s.material_ids ?? s.materialIds ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeOutline(o: unknown): unknown {
|
||||
if (!o || typeof o !== 'object') return o;
|
||||
const obj = o as Record<string, unknown>;
|
||||
const sections = Array.isArray(obj.sections)
|
||||
? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record<string, unknown>))
|
||||
: [];
|
||||
return { ...obj, sections };
|
||||
}
|
||||
|
||||
function normalizeReviewIssue(i: Record<string, unknown>): Record<string, unknown> {
|
||||
return { ...i };
|
||||
}
|
||||
|
||||
function normalizeReviewResult(r: unknown): unknown {
|
||||
if (!r || typeof r !== 'object') return r;
|
||||
const obj = r as Record<string, unknown>;
|
||||
return {
|
||||
...obj,
|
||||
factScore: obj.fact_score ?? obj.factScore ?? 0,
|
||||
logicScore: obj.logic_score ?? obj.logicScore ?? 0,
|
||||
languageScore: obj.language_score ?? obj.languageScore ?? 0,
|
||||
overallScore: obj.overall_score ?? obj.overallScore ?? 0,
|
||||
passThreshold: obj.pass_threshold ?? obj.passThreshold ?? 75,
|
||||
revisionNotes: Array.isArray(obj.revision_notes)
|
||||
? obj.revision_notes.map((x: unknown) => normalizeReviewIssue(x as Record<string, unknown>))
|
||||
: Array.isArray(obj.revisionNotes)
|
||||
? obj.revisionNotes
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEvent(parsed: Record<string, unknown>): Partial<ProgressEvent> {
|
||||
const out: Record<string, unknown> = { ...parsed };
|
||||
|
||||
// 通用字段
|
||||
if ('agent_name' in out) { out.agentName = out.agent_name; }
|
||||
if ('pause_point' in out) { out.pausePoint = out.pause_point; }
|
||||
|
||||
// materials 数组(materials_ready 和 await_user 里都可能有)
|
||||
if (Array.isArray(out.materials)) {
|
||||
out.materials = out.materials.map((m: unknown) =>
|
||||
normalizeMaterial(m as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
// outline
|
||||
if (out.outline) out.outline = normalizeOutline(out.outline);
|
||||
|
||||
// blocked_sections(section_help await_user)
|
||||
if (Array.isArray(out.blocked_sections)) {
|
||||
out.blockedSections = out.blocked_sections.map((b: unknown) => {
|
||||
const o = b as Record<string, unknown>;
|
||||
return {
|
||||
sectionTitle: o.section_title ?? o.sectionTitle ?? '',
|
||||
reason: o.reason ?? '',
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// draft_ready: 后端发 "title"/"markdown",前端期望 draftTitle/draftMarkdown
|
||||
if ('markdown' in out && !out.draftMarkdown) out.draftMarkdown = out.markdown;
|
||||
if ('title' in out && !out.draftTitle) out.draftTitle = out.title;
|
||||
if ('revision_count' in out) out.revisionCount = out.revision_count;
|
||||
|
||||
// review_ready
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
// review_item_done (streaming per-category result from sse_queue)
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
out.reviewItemIssues = out.issues.map((x: unknown) =>
|
||||
normalizeReviewIssue(x as Record<string, unknown>)
|
||||
);
|
||||
}
|
||||
|
||||
return out as Partial<ProgressEvent>;
|
||||
}
|
||||
|
||||
/** 监听 SSE 进度流,使用 fetch + ReadableStream 以支持认证头 */
|
||||
export function subscribeToProgress(
|
||||
sessionId: string,
|
||||
onEvent: (event: ProgressEvent) => void,
|
||||
signal?: AbortSignal,
|
||||
): () => void {
|
||||
let stopped = false;
|
||||
|
||||
apiFetch(`${BASE()}/${sessionId}/stream`, { signal })
|
||||
.then(async (response) => {
|
||||
if (!response.ok || !response.body) return;
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let currentType = '';
|
||||
let currentData = '';
|
||||
|
||||
while (!stopped) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() ?? '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('event: ')) {
|
||||
currentType = line.slice(7).trim();
|
||||
} else if (line.startsWith('data: ')) {
|
||||
currentData = line.slice(6).trim();
|
||||
} else if (line === '' && currentType && currentData) {
|
||||
if (SSE_EVENT_TYPES.includes(currentType as ProgressEventType)) {
|
||||
try {
|
||||
const parsed = JSON.parse(currentData);
|
||||
const normalized = normalizeEvent(parsed);
|
||||
onEvent({
|
||||
type: currentType as ProgressEventType,
|
||||
sessionId,
|
||||
message: '',
|
||||
...normalized,
|
||||
} as ProgressEvent);
|
||||
} catch {
|
||||
// malformed JSON — skip
|
||||
}
|
||||
}
|
||||
currentType = '';
|
||||
currentData = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reader.releaseLock();
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => { stopped = true; };
|
||||
}
|
||||
|
||||
/** 提交用户干预决策,恢复暂停的流程 */
|
||||
export async function resumeWriting(
|
||||
sessionId: string,
|
||||
payload: UserInterventionPayload,
|
||||
): Promise<void> {
|
||||
const resp = await apiFetch(`${BASE()}/${sessionId}/resume`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
action: payload.action,
|
||||
approved_material_ids: payload.approvedMaterialIds ?? [],
|
||||
extra_keywords: payload.extraKeywords ?? [],
|
||||
edited_outline: payload.editedOutline ?? null,
|
||||
outline_feedback: payload.outlineFeedback ?? '',
|
||||
user_revision_notes: payload.userRevisionNotes ?? '',
|
||||
override_verdict: payload.overrideVerdict ?? false,
|
||||
selected_issue_indices: payload.selectedIssueIndices ?? null,
|
||||
section_decisions: (payload.sectionDecisions ?? []).map((d) => ({
|
||||
section_title: d.sectionTitle,
|
||||
decision: d.decision,
|
||||
})),
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { apiFetch } from "@/core/api/fetch-client";
|
||||
import { getBackendBaseURL } from "@/core/config";
|
||||
|
||||
export type RewriteAction = "polish" | "expand" | "shorten" | "tone";
|
||||
export type RewriteTone =
|
||||
| "professional"
|
||||
| "casual"
|
||||
| "readable"
|
||||
| "academic"
|
||||
| "expressive";
|
||||
|
||||
export interface RewriteRequest {
|
||||
threadId?: string | null;
|
||||
selection: { from: number; to: number; text: string };
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
modelName?: string;
|
||||
surroundingContext?: { before?: string; after?: string };
|
||||
customInstruction?: string;
|
||||
followUpInstruction?: string;
|
||||
previousResult?: string;
|
||||
}
|
||||
|
||||
export type RewriteStreamEvent =
|
||||
| { type: "thinking"; chunk: string }
|
||||
| { type: "text"; chunk: string }
|
||||
| { type: "done" }
|
||||
| { type: "error"; message: string };
|
||||
|
||||
// ── SSE parser ────────────────────────────────────────────────────────────────
|
||||
|
||||
async function* parseSse(body: ReadableStream<Uint8Array>): AsyncGenerator<{ event: string; data: string }> {
|
||||
const reader = body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
let currentEvent = "";
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trimEnd();
|
||||
if (trimmed.startsWith("event: ")) {
|
||||
currentEvent = trimmed.slice(7);
|
||||
} else if (trimmed.startsWith("data: ")) {
|
||||
yield { event: currentEvent, data: trimmed.slice(6) };
|
||||
currentEvent = "";
|
||||
}
|
||||
// empty line resets event field (SSE spec)
|
||||
else if (trimmed === "") {
|
||||
currentEvent = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Streaming entry point ─────────────────────────────────────────────────────
|
||||
|
||||
export async function* streamRewrite(
|
||||
req: RewriteRequest,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<RewriteStreamEvent> {
|
||||
const res = await apiFetch(`${getBackendBaseURL()}/api/writing/rewrite`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(req),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({})) as Record<string, unknown>;
|
||||
const msg = (body?.detail as string | undefined) ?? (body?.message as string | undefined) ?? `HTTP ${res.status}`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
if (!res.body) throw new Error("No response body");
|
||||
|
||||
for await (const { event, data } of parseSse(res.body)) {
|
||||
if (signal?.aborted) return;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
if (event === "thinking") {
|
||||
yield { type: "thinking", chunk: parsed.chunk as string };
|
||||
} else if (event === "text") {
|
||||
yield { type: "text", chunk: parsed.chunk as string };
|
||||
} else if (event === "done") {
|
||||
yield { type: "done" };
|
||||
return;
|
||||
} else if (event === "error") {
|
||||
yield { type: "error", message: parsed.message as string };
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// skip malformed frame
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { FileText, PenLine } from 'lucide-react';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent, isCodeArtifact } from '../../utils/artifact';
|
||||
import { TextArtifactRenderer } from '../artifact/TextArtifactRenderer';
|
||||
import type { CanvasArtifactText } from '../../types/artifact';
|
||||
|
||||
function DraftWaitingPlaceholder() {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-5 select-none">
|
||||
<div className="relative">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted/60 flex items-center justify-center">
|
||||
<FileText className="w-8 h-8 text-muted-foreground/40" />
|
||||
</div>
|
||||
<div className="absolute -bottom-1.5 -right-1.5 w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center">
|
||||
<PenLine className="w-3.5 h-3.5 text-primary/60" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center space-y-1.5 max-w-[200px]">
|
||||
<p className="text-sm font-medium text-foreground/60">草稿将在此处显示</p>
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed">
|
||||
完成写作流程后,AI 生成的内容将实时出现在这里
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AIWritingDraftPanel() {
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10 border-l">
|
||||
<DraftWaitingPlaceholder />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const content = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden border-l">
|
||||
{/* 标题栏 */}
|
||||
<div className="h-10 border-b flex items-center gap-2 px-4 shrink-0 bg-muted/20">
|
||||
<FileText className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{content?.title ?? '草稿预览'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{content && !isCodeArtifact(content) ? (
|
||||
<TextArtifactRenderer content={content as CanvasArtifactText} />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
interface ModelOption {
|
||||
name: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
const INPUT_CLASS =
|
||||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||||
'focus-visible:ring-ring disabled:opacity-50';
|
||||
|
||||
export function AIWritingForm({ onSubmit, disabled }: AIWritingFormProps) {
|
||||
const [intent, setIntent] = useState('');
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [keywordCountInput, setKeywordCountInput] = useState('1');
|
||||
const [strictMode, setStrictMode] = useState(false);
|
||||
const [modelName, setModelName] = useState('');
|
||||
const [models, setModels] = useState<ModelOption[]>([]);
|
||||
|
||||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||||
|
||||
// 初始化时加载文章类型
|
||||
useEffect(() => {
|
||||
refreshTypes();
|
||||
}, [refreshTypes]);
|
||||
|
||||
// 类型加载完成后:若当前选中值不在列表中则切换为第一项
|
||||
useEffect(() => {
|
||||
if (articleTypes.length === 0) return;
|
||||
const keys = articleTypes.map((t) => t.key);
|
||||
if (!keys.includes(articleType)) {
|
||||
const first = articleTypes[0];
|
||||
setArticleType(first.key);
|
||||
setWordCountInput(String(first.default_word_count));
|
||||
}
|
||||
}, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// 切换文章类型时自动填入该类型的默认字数
|
||||
function handleArticleTypeChange(key: string) {
|
||||
setArticleType(key);
|
||||
const found = articleTypes.find((t) => t.key === key);
|
||||
if (found) {
|
||||
setWordCountInput(String(found.default_word_count));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
apiFetch(`${getBackendBaseURL()}/api/models`)
|
||||
.then((r) => r.json())
|
||||
.then((data) => {
|
||||
const list: ModelOption[] = (data.models ?? []).map(
|
||||
(m: { name: string; display_name?: string }) => ({
|
||||
name: m.name,
|
||||
displayName: m.display_name || m.name,
|
||||
}),
|
||||
);
|
||||
setModels(list);
|
||||
if (list.length > 0 && !modelName) {
|
||||
setModelName(list[0].name);
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
function handleWordCountBlur() {
|
||||
const num = parseInt(wordCountInput, 10);
|
||||
if (isNaN(num) || num < 100) {
|
||||
setWordCountInput('800');
|
||||
} else {
|
||||
setWordCountInput(String(num));
|
||||
}
|
||||
}
|
||||
|
||||
// 检索关键词数量限定 1~10,非法值回退默认 6
|
||||
function handleKeywordCountBlur() {
|
||||
const num = parseInt(keywordCountInput, 10);
|
||||
if (isNaN(num)) {
|
||||
setKeywordCountInput('1');
|
||||
} else {
|
||||
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
const keywordCount = Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || 6));
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
articleType,
|
||||
targetAudience: audience.trim() || '普通读者',
|
||||
wordCountTarget: wordCount,
|
||||
keywordCount,
|
||||
writingMode: strictMode ? 'strict' : 'loose',
|
||||
modelName: modelName || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
|
||||
<Textarea
|
||||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
|
||||
value={intent}
|
||||
onChange={(e) => setIntent(e.target.value)}
|
||||
disabled={disabled}
|
||||
rows={4}
|
||||
className="resize-none text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||||
<Select
|
||||
value={articleType}
|
||||
onValueChange={handleArticleTypeChange}
|
||||
disabled={disabled || typesLoading}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{articleTypes.map((t) => (
|
||||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={100}
|
||||
step={100}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="800"
|
||||
value={wordCountInput}
|
||||
onChange={(e) => setWordCountInput(e.target.value)}
|
||||
onBlur={handleWordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={10}
|
||||
step={1}
|
||||
className={INPUT_CLASS}
|
||||
placeholder="6"
|
||||
value={keywordCountInput}
|
||||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||||
onBlur={handleKeywordCountBlur}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<p className="text-[11px] text-muted-foreground/70">
|
||||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="strict-mode"
|
||||
checked={strictMode}
|
||||
onChange={(e) => setStrictMode(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||||
<input
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||||
value={audience}
|
||||
onChange={(e) => setAudience(e.target.value)}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{models.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
|
||||
<Select
|
||||
value={modelName}
|
||||
onValueChange={setModelName}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs">
|
||||
<SelectValue placeholder="选择模型…" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{models.map((m) => (
|
||||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
|
||||
<PenLine className="size-3.5" />
|
||||
开始 AI 写作
|
||||
</Button>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { History, Trash2, Check, X, RefreshCw } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '进行中', color: 'text-blue-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
done: { label: '完成', color: 'text-green-500' },
|
||||
error: { label: '错误', color: 'text-destructive' },
|
||||
};
|
||||
|
||||
function formatRelative(isoStr: string): string {
|
||||
const ms = Date.now() - new Date(isoStr).getTime();
|
||||
const mins = Math.floor(ms / 60000);
|
||||
if (mins < 1) return '刚刚';
|
||||
if (mins < 60) return `${mins} 分钟前`;
|
||||
const hrs = Math.floor(mins / 60);
|
||||
if (hrs < 24) return `${hrs} 小时前`;
|
||||
const days = Math.floor(hrs / 24);
|
||||
if (days < 7) return `${days} 天前`;
|
||||
return new Date(isoStr).toLocaleDateString('zh-CN', { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
interface SessionItemProps {
|
||||
session: AIWritingSessionSummary;
|
||||
isSelected: boolean;
|
||||
onSelect: () => void;
|
||||
onDelete: () => void;
|
||||
onRename: (title: string) => void;
|
||||
}
|
||||
|
||||
function SessionItem({ session, isSelected, onSelect, onDelete, onRename }: SessionItemProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editValue, setEditValue] = useState(session.title);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const { label, color } = STATUS_LABELS[session.status] ?? { label: session.status, color: 'text-muted-foreground' };
|
||||
|
||||
useEffect(() => {
|
||||
if (editing) inputRef.current?.focus();
|
||||
}, [editing]);
|
||||
|
||||
function commitRename() {
|
||||
const trimmed = editValue.trim();
|
||||
if (trimmed && trimmed !== session.title) onRename(trimmed);
|
||||
setEditing(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent) {
|
||||
if (e.key === 'Enter') commitRename();
|
||||
else if (e.key === 'Escape') { setEditValue(session.title); setEditing(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'group relative flex items-start gap-2 px-3 py-2.5 cursor-pointer transition-colors',
|
||||
isSelected ? 'bg-accent' : 'hover:bg-accent/60',
|
||||
)}
|
||||
onClick={!editing ? onSelect : undefined}
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
{editing ? (
|
||||
<div className="flex items-center gap-1" onClick={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="flex-1 text-xs bg-background border rounded px-1.5 py-0.5 outline-none focus:ring-1 focus:ring-ring"
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
/>
|
||||
<button className="shrink-0 text-green-500 hover:text-green-600" onClick={commitRename}>
|
||||
<Check className="w-3 h-3" />
|
||||
</button>
|
||||
<button className="shrink-0 text-muted-foreground hover:text-foreground" onClick={() => { setEditValue(session.title); setEditing(false); }}>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p
|
||||
className="text-xs font-medium truncate leading-snug"
|
||||
onDoubleClick={(e) => { e.stopPropagation(); setEditing(true); }}
|
||||
title="双击重命名"
|
||||
>
|
||||
{session.title}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 mt-0.5">
|
||||
<span className={cn('text-[10px] font-medium', color)}>{label}</span>
|
||||
<span className="text-[10px] text-muted-foreground">·</span>
|
||||
<span className="text-[10px] text-muted-foreground">{formatRelative(session.updated_at)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 删除按钮,hover 时显示 */}
|
||||
{!editing && (
|
||||
<button
|
||||
className="opacity-0 group-hover:opacity-100 shrink-0 p-0.5 text-muted-foreground hover:text-destructive transition-opacity"
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(); }}
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryDropdownProps {
|
||||
sessions: AIWritingSessionSummary[];
|
||||
selectedId: string | null;
|
||||
loading: boolean;
|
||||
onSelect: (session: AIWritingSessionSummary) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onRename: (id: string, title: string) => void;
|
||||
onRefresh: () => void;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryDropdown({
|
||||
sessions,
|
||||
selectedId,
|
||||
loading,
|
||||
onSelect,
|
||||
onDelete,
|
||||
onRename,
|
||||
onRefresh,
|
||||
}: AIWritingHistoryDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
function handleClickOutside(e: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, [open]);
|
||||
|
||||
function handleOpen() {
|
||||
setOpen((v) => {
|
||||
if (!v) onRefresh();
|
||||
return !v;
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1"
|
||||
onClick={handleOpen}
|
||||
title="查看历史记录"
|
||||
>
|
||||
<History className="size-3" />
|
||||
历史
|
||||
</Button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute right-0 top-full mt-1 z-50 w-64 rounded-lg border bg-popover shadow-lg overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<span className="text-xs font-semibold">写作历史</span>
|
||||
<button
|
||||
className={cn('text-muted-foreground hover:text-foreground transition-transform', loading && 'animate-spin')}
|
||||
onClick={onRefresh}
|
||||
title="刷新"
|
||||
>
|
||||
<RefreshCw className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 列表 */}
|
||||
<div className="max-h-72 overflow-y-auto">
|
||||
{sessions.length === 0 ? (
|
||||
<div className="text-center py-6 text-xs text-muted-foreground">
|
||||
{loading ? '加载中…' : '暂无历史记录'}
|
||||
</div>
|
||||
) : (
|
||||
sessions.map((s) => (
|
||||
<SessionItem
|
||||
key={s.id}
|
||||
session={s}
|
||||
isSelected={s.id === selectedId}
|
||||
onSelect={() => { onSelect(s); setOpen(false); }}
|
||||
onDelete={() => onDelete(s.id)}
|
||||
onRename={(title) => onRename(s.id, title)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { CheckCircle2, ChevronRight, FileText, Search, PenLine, ClipboardEdit, Star } from 'lucide-react';
|
||||
|
||||
import type { AIWritingSessionSummary, CompletedInterventionRecord } from '../../api/ai-writing-sessions';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
|
||||
const PAUSE_LABELS: Record<string, string> = {
|
||||
material_confirm: '素材确认',
|
||||
outline_confirm: '大纲确认',
|
||||
draft_confirm: '草稿确认',
|
||||
review_confirm: '编辑意见处理',
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
approve: '已批准',
|
||||
re_search: '重新搜索',
|
||||
re_outline: '重新规划',
|
||||
finalize: '直接定稿',
|
||||
to_editor: '提交审核',
|
||||
user_revise: '手动修改',
|
||||
accept_review: '接受意见',
|
||||
force_finalize: '强制定稿',
|
||||
};
|
||||
|
||||
const PAUSE_ICONS: Record<string, React.ElementType> = {
|
||||
material_confirm: Search,
|
||||
outline_confirm: PenLine,
|
||||
draft_confirm: FileText,
|
||||
review_confirm: ClipboardEdit,
|
||||
};
|
||||
|
||||
function InterventionTimelineItem({ record, isLast }: { record: CompletedInterventionRecord; isLast: boolean }) {
|
||||
const Icon = PAUSE_ICONS[record.pausePoint] ?? CheckCircle2;
|
||||
const ts = record.timestamp
|
||||
? new Date(record.timestamp).toLocaleString('zh-CN', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' })
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0">
|
||||
<Icon className="w-3 h-3 text-primary" />
|
||||
</div>
|
||||
{!isLast && <div className="w-px flex-1 bg-border/60 mt-1" />}
|
||||
</div>
|
||||
<div className="pb-4 min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs font-medium">
|
||||
{PAUSE_LABELS[record.pausePoint] ?? record.pausePoint}
|
||||
</span>
|
||||
<ChevronRight className="w-3 h-3 text-muted-foreground" />
|
||||
<span className="text-xs text-primary font-medium">
|
||||
{ACTION_LABELS[record.action] ?? record.action}
|
||||
</span>
|
||||
</div>
|
||||
{ts && <p className="text-[10px] text-muted-foreground mt-0.5">{ts}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface AIWritingHistoryViewProps {
|
||||
session: AIWritingSessionSummary;
|
||||
}
|
||||
|
||||
export function AIWritingHistoryView({ session }: AIWritingHistoryViewProps) {
|
||||
const interventions = session.completed_interventions ?? [];
|
||||
const transcript = session.transcript;
|
||||
// 有完整时间线快照时,复用实时对话的 AIWritingTimeline 渲染,保证回显与实时一致
|
||||
const hasTranscript = !!transcript && (transcript.progressEvents?.length ?? 0) > 0;
|
||||
const createdAt = session.created_at
|
||||
? new Date(session.created_at).toLocaleString('zh-CN', {
|
||||
year: 'numeric', month: 'short', day: 'numeric',
|
||||
hour: '2-digit', minute: '2-digit',
|
||||
})
|
||||
: '';
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{/* 元信息 */}
|
||||
<div className="px-4 pt-3">
|
||||
<div className="rounded-lg border bg-muted/30 px-3 py-2.5 space-y-1">
|
||||
<p className="text-xs font-semibold truncate">{session.draft_title ?? session.title}</p>
|
||||
{session.user_intent && (
|
||||
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
|
||||
{session.user_intent}
|
||||
</p>
|
||||
)}
|
||||
{createdAt && (
|
||||
<p className="text-[10px] text-muted-foreground">{createdAt}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasTranscript ? (
|
||||
/* 完整对话时间线回显 */
|
||||
<AIWritingTimeline
|
||||
events={transcript!.progressEvents}
|
||||
completedInterventions={transcript!.completedInterventions ?? []}
|
||||
currentPause={null}
|
||||
/>
|
||||
) : (
|
||||
/* 旧会话无时间线快照时,回退到精简阶段列表 */
|
||||
<div className="px-4 py-3 space-y-4">
|
||||
{interventions.length > 0 ? (
|
||||
<div>
|
||||
<p className="text-[11px] font-medium text-muted-foreground mb-2">写作阶段</p>
|
||||
<div>
|
||||
{interventions.map((rec, i) => (
|
||||
<InterventionTimelineItem
|
||||
key={i}
|
||||
record={rec}
|
||||
isLast={i === interventions.length - 1}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-4 text-xs text-muted-foreground">无阶段记录</div>
|
||||
)}
|
||||
|
||||
{session.status === 'done' && (
|
||||
<div className="flex items-center gap-2 text-xs text-green-600 dark:text-green-400">
|
||||
<Star className="w-3.5 h-3.5" />
|
||||
<span>写作完成</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ArrowLeft, PenLine } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { getCurrentArtifactContent } from '../../utils/artifact';
|
||||
|
||||
export function AIWritingPageHeader() {
|
||||
const navigate = useNavigate();
|
||||
const { artifact } = useCanvasGraphContext();
|
||||
const currentContent = getCurrentArtifactContent(artifact);
|
||||
|
||||
return (
|
||||
<div className="h-11 border-b flex items-center px-3 gap-2 shrink-0 bg-background">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 shrink-0"
|
||||
onClick={() => navigate('/page/canvas')}
|
||||
title="返回 Canvas 列表"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
|
||||
<PenLine className="h-4 w-4 text-primary shrink-0" />
|
||||
<span className="text-sm font-semibold">AI 智能写作</span>
|
||||
|
||||
{currentContent?.title && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<span className="text-sm text-muted-foreground truncate">
|
||||
{currentContent.title}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,457 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { ChevronsDown, Play, Plus, RotateCcw, Settings2, Square } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
|
||||
import { useCanvasGraphContext } from '../../contexts/CanvasGraphContext';
|
||||
import { useAIWriting } from '../../hooks/useAIWriting';
|
||||
import { useAIWritingSessions } from '../../hooks/useAIWritingSessions';
|
||||
import { AIWritingForm } from './AIWritingForm';
|
||||
import { AIWritingTimeline } from './AIWritingTimeline';
|
||||
import { InterventionCard } from './InterventionCard';
|
||||
import { WritingSkeletonCard } from './WritingSkeletonCard';
|
||||
import { DraftStreamingView } from './DraftStreamingView';
|
||||
import { OutlineStreamingView } from './OutlineStreamingView';
|
||||
import { ReviewStreamingView } from './ReviewStreamingView';
|
||||
import { RevisionSummaryCard } from './RevisionSummaryCard';
|
||||
import { AIWritingHistoryDropdown } from './AIWritingHistoryDropdown';
|
||||
import { AIWritingHistoryView } from './AIWritingHistoryView';
|
||||
import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
researching: '素材收集专家搜集素材中…',
|
||||
awaiting_material_confirm: '等待确认素材',
|
||||
writing_outline: '作家规划大纲中…',
|
||||
awaiting_outline_confirm: '等待确认大纲',
|
||||
writing_draft: '作家写作中…',
|
||||
awaiting_section_help: '等待处理素材不足的章节',
|
||||
awaiting_draft_confirm: '等待确认草稿',
|
||||
reviewing: '编辑审核中…',
|
||||
awaiting_review_confirm: '等待处理编辑意见',
|
||||
revising: '按意见修改中…',
|
||||
done: '写作完成 ✓',
|
||||
error: '出错了',
|
||||
};
|
||||
|
||||
export function AIWritingPanel() {
|
||||
const navigate = useNavigate();
|
||||
const { setArtifact } = useCanvasGraphContext();
|
||||
const { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset } = useAIWriting();
|
||||
const { sessions, loading: sessionsLoading, refresh, rename, remove } = useAIWritingSessions();
|
||||
const { articleTypes, refresh: refreshArticleTypes } = useArticleTypes();
|
||||
|
||||
useEffect(() => { refreshArticleTypes(); }, [refreshArticleTypes]);
|
||||
|
||||
// History view state — null means normal writing mode
|
||||
const [historySession, setHistorySession] = useState<AIWritingSessionSummary | null>(null);
|
||||
const [reqDialogOpen, setReqDialogOpen] = useState(false);
|
||||
|
||||
const isIdle = state.status === 'idle';
|
||||
const isDone = state.status === 'done';
|
||||
const isError = state.status === 'error';
|
||||
const isRunning = !isIdle && !isDone && !isError;
|
||||
const showSkeleton = isRunning && !state.currentPause && !state.isPaused;
|
||||
|
||||
// ── 自动滚底逻辑 ────────────────────────────────────────────────────────────
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
// 程序触发滚动时置 true,阻止 scroll 事件误判为用户上划
|
||||
const isAutoScrollingRef = useRef(false);
|
||||
// 同步 ref:让 scroll/wheel 回调可以立即读取最新值,不依赖 state 异步更新
|
||||
const autoScrollRef = useRef(true);
|
||||
const [autoScroll, setAutoScroll] = useState(true);
|
||||
|
||||
const disableAutoScroll = useCallback(() => {
|
||||
autoScrollRef.current = false;
|
||||
setAutoScroll(false);
|
||||
}, []);
|
||||
|
||||
// 新会话 / 重置后恢复自动滚底
|
||||
useEffect(() => {
|
||||
if (isIdle) {
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [isIdle]);
|
||||
|
||||
// 开始写作 / 点击继续后恢复自动滚底
|
||||
useEffect(() => {
|
||||
if (isRunning && !state.isPaused) {
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}
|
||||
}, [isRunning, state.isPaused]);
|
||||
|
||||
// 监听用户手动滚动(挂载一次)
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// wheel 事件:鼠标滚轮 / 触控板,只由用户触发
|
||||
const onWheel = (e: WheelEvent) => {
|
||||
if (e.deltaY < 0) disableAutoScroll(); // 向上滚
|
||||
};
|
||||
|
||||
// scroll 事件:兼容拖动滚动条的场景
|
||||
// 通过对比前后 scrollTop 判断是否向上滚(排除程序触发的向下滚)
|
||||
let lastTop = el.scrollTop;
|
||||
const onScroll = () => {
|
||||
const cur = el.scrollTop;
|
||||
if (!isAutoScrollingRef.current && cur < lastTop - 2) {
|
||||
disableAutoScroll();
|
||||
}
|
||||
lastTop = cur;
|
||||
};
|
||||
|
||||
el.addEventListener('wheel', onWheel, { passive: true });
|
||||
el.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => {
|
||||
el.removeEventListener('wheel', onWheel);
|
||||
el.removeEventListener('scroll', onScroll);
|
||||
};
|
||||
}, [disableAutoScroll]);
|
||||
|
||||
// 内容变化时若自动滚底开启则滚到底部
|
||||
useEffect(() => {
|
||||
// 检查同步 ref,避免 state 异步导致的时序竞争
|
||||
if (!autoScrollRef.current || !isRunning || !scrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
isAutoScrollingRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||||
}, [
|
||||
autoScroll, // 点击按钮后重新触发一次
|
||||
isRunning,
|
||||
state.progressEvents.length,
|
||||
state.streamingDraftSections.length,
|
||||
state.streamingReviewItems.length,
|
||||
state.outlineStreamingText,
|
||||
state.currentPause,
|
||||
]);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
isAutoScrollingRef.current = true;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
requestAnimationFrame(() => { isAutoScrollingRef.current = false; });
|
||||
autoScrollRef.current = true;
|
||||
setAutoScroll(true);
|
||||
}, []);
|
||||
|
||||
const handleSelectHistory = useCallback((session: AIWritingSessionSummary) => {
|
||||
setHistorySession(session);
|
||||
// Push historical draft to the right panel
|
||||
if (session.draft_markdown) {
|
||||
setArtifact({
|
||||
currentIndex: 0,
|
||||
contents: [{
|
||||
index: 0,
|
||||
type: 'text',
|
||||
title: session.draft_title ?? session.title,
|
||||
fullMarkdown: session.draft_markdown,
|
||||
}],
|
||||
});
|
||||
} else {
|
||||
setArtifact(null);
|
||||
}
|
||||
}, [setArtifact]);
|
||||
|
||||
const handleNewSession = useCallback(() => {
|
||||
setHistorySession(null);
|
||||
reset();
|
||||
}, [reset]);
|
||||
|
||||
const showNewButton = historySession !== null || state.status !== 'idle';
|
||||
|
||||
// History view mode
|
||||
if (historySession) {
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span className="text-sm font-semibold truncate flex-1 min-w-0 mr-2">
|
||||
{historySession.draft_title ?? historySession.title}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => navigate('/page/canvas/article-types')}
|
||||
title="文章类型配置"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建对话
|
||||
</Button>
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={historySession.id}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<AIWritingHistoryView session={historySession} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden bg-background">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b shrink-0">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-semibold shrink-0',
|
||||
!isIdle && 'cursor-pointer hover:text-primary transition-colors',
|
||||
)}
|
||||
onClick={() => { if (!isIdle && state.request) setReqDialogOpen(true); }}
|
||||
>
|
||||
AI 智能写作
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7"
|
||||
onClick={() => navigate('/page/canvas/article-types')}
|
||||
title="文章类型配置"
|
||||
>
|
||||
<Settings2 className="size-3.5" />
|
||||
</Button>
|
||||
{showNewButton && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={handleNewSession}>
|
||||
<Plus className="size-3" />
|
||||
新建
|
||||
</Button>
|
||||
)}
|
||||
{isRunning && !state.isPaused && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1 animate-breathe-stop text-red-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30"
|
||||
onClick={pauseWriting}
|
||||
>
|
||||
<Square className="size-3 fill-current" />
|
||||
停止
|
||||
</Button>
|
||||
)}
|
||||
{state.isPaused && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 px-2 text-xs gap-1 text-primary hover:text-primary hover:bg-primary/8"
|
||||
onClick={resumeWriting}
|
||||
>
|
||||
<Play className="size-3 fill-current" />
|
||||
继续
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||
<RotateCcw className="size-3" />
|
||||
重置
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(isDone || isError) && (
|
||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs gap-1" onClick={reset}>
|
||||
<RotateCcw className="size-3" />
|
||||
重置
|
||||
</Button>
|
||||
)}
|
||||
<AIWritingHistoryDropdown
|
||||
sessions={sessions}
|
||||
selectedId={null}
|
||||
loading={sessionsLoading}
|
||||
onSelect={handleSelectHistory}
|
||||
onDelete={remove}
|
||||
onRename={rename}
|
||||
onRefresh={refresh}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 主体:relative 父容器 + absolute 滚动容器,确保滚动容器尺寸始终正确 */}
|
||||
<div className="flex-1 min-h-0 relative">
|
||||
<div ref={scrollRef} className="absolute inset-0 overflow-y-auto">
|
||||
{/* 未开始时显示表单 */}
|
||||
{isIdle && (
|
||||
<AIWritingForm onSubmit={startWriting} />
|
||||
)}
|
||||
|
||||
{/* 运行中或完成后显示时间线 */}
|
||||
{!isIdle && (
|
||||
<AIWritingTimeline
|
||||
events={state.progressEvents}
|
||||
completedInterventions={state.completedInterventions}
|
||||
currentPause={state.currentPause}
|
||||
currentInterventionSlot={
|
||||
state.currentPause ? (
|
||||
<InterventionCard
|
||||
pauseEvent={state.currentPause}
|
||||
materials={state.materials}
|
||||
outline={state.outline}
|
||||
reviewResult={state.reviewResult}
|
||||
draftTitle={state.currentDraftTitle}
|
||||
revisionCount={state.revisionCount}
|
||||
onSubmit={submitIntervention}
|
||||
disabled={false}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 大纲流式思考框 */}
|
||||
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
|
||||
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
|
||||
)}
|
||||
|
||||
{/* 编辑审核流式结果 */}
|
||||
{state.status === 'reviewing' && state.streamingReviewItems.length > 0 && (
|
||||
<ReviewStreamingView items={state.streamingReviewItems} />
|
||||
)}
|
||||
|
||||
{/* 修改完成摘要 */}
|
||||
{state.revisionSummary && (
|
||||
<div className="px-4 pb-2">
|
||||
<RevisionSummaryCard summary={state.revisionSummary} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 草稿流式打字机视图 */}
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length > 0 && (
|
||||
<DraftStreamingView
|
||||
title={state.streamingDraftTitle}
|
||||
sections={state.streamingDraftSections}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showSkeleton &&
|
||||
state.status !== 'writing_draft' &&
|
||||
!(state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText)) &&
|
||||
!(state.status === 'reviewing' && state.streamingReviewItems.length > 0) && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.status === 'writing_draft' && state.streamingDraftSections.length === 0 && (
|
||||
<div className="px-4 pb-4">
|
||||
<WritingSkeletonCard status="writing_draft" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && state.error && (
|
||||
<div className="mx-4 mt-2 rounded-md bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
{state.error}
|
||||
</div>
|
||||
)}
|
||||
</div>{/* 滚动内容区结束 */}
|
||||
|
||||
{/* 回到底部悬浮按钮:生成中且用户已上划时出现 */}
|
||||
{isRunning && !autoScroll && (
|
||||
<div className="absolute inset-x-0 bottom-4 flex justify-center z-10 pointer-events-none">
|
||||
<button
|
||||
onClick={scrollToBottom}
|
||||
className="pointer-events-auto flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-full bg-background border shadow-md hover:bg-muted transition-colors whitespace-nowrap"
|
||||
>
|
||||
<ChevronsDown className="size-3.5" />
|
||||
回到底部
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>{/* 相对定位容器结束 */}
|
||||
|
||||
{/* 状态栏 */}
|
||||
{!isIdle && (
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 px-4 py-2 border-t text-xs flex items-center gap-2',
|
||||
isError ? 'text-destructive' : 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{state.isPaused ? (
|
||||
<>
|
||||
<span className="inline-block size-1.5 rounded-full bg-amber-400" />
|
||||
已暂停
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{isRunning && (
|
||||
<span className="inline-block size-1.5 rounded-full bg-blue-500 animate-pulse" />
|
||||
)}
|
||||
{state.status === 'researching' && state.researchProgress
|
||||
? state.researchProgress
|
||||
: STATUS_LABEL[state.status] ?? state.status}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 写作参数查看弹窗 */}
|
||||
{state.request && (
|
||||
<Dialog open={reqDialogOpen} onOpenChange={setReqDialogOpen}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>本次写作参数</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3 py-1 text-sm">
|
||||
<ReqRow label="写作意图">
|
||||
<p className="text-muted-foreground whitespace-pre-wrap leading-relaxed">{state.request.userIntent}</p>
|
||||
</ReqRow>
|
||||
<ReqRow label="文章类型">
|
||||
<span className="text-muted-foreground">
|
||||
{articleTypes.find((t) => t.key === state.request!.articleType)?.label ?? state.request.articleType}
|
||||
</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="目标读者">
|
||||
<span className="text-muted-foreground">{state.request.targetAudience}</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="目标字数">
|
||||
<span className="text-muted-foreground">{state.request.wordCountTarget} 字</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="检索关键词数量">
|
||||
<span className="text-muted-foreground">{state.request.keywordCount} 个</span>
|
||||
</ReqRow>
|
||||
<ReqRow label="写作模式">
|
||||
<span className="text-muted-foreground">
|
||||
{state.request.writingMode === 'strict' ? '严格依据素材' : '宽松(可结合通用知识)'}
|
||||
</span>
|
||||
</ReqRow>
|
||||
{state.request.modelName && (
|
||||
<ReqRow label="写作模型">
|
||||
<span className="text-muted-foreground">{state.request.modelName}</span>
|
||||
</ReqRow>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReqRow({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
<p className="text-xs font-medium text-foreground/60">{label}</p>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
|
||||
import { TimelineStep } from './TimelineStep';
|
||||
import { CompletedInterventionCard } from './CompletedInterventionCard';
|
||||
|
||||
interface AIWritingTimelineProps {
|
||||
events: ProgressEvent[];
|
||||
completedInterventions: CompletedIntervention[];
|
||||
currentPause: ProgressEvent | null;
|
||||
currentInterventionSlot?: React.ReactNode;
|
||||
}
|
||||
|
||||
const DISPLAYED_TYPES = new Set([
|
||||
'step_start',
|
||||
'step_done',
|
||||
'materials_ready',
|
||||
'outline_ready',
|
||||
'draft_ready',
|
||||
'review_ready',
|
||||
'await_user',
|
||||
'resumed',
|
||||
'notice',
|
||||
'done',
|
||||
'error',
|
||||
]);
|
||||
|
||||
export function AIWritingTimeline({
|
||||
events,
|
||||
completedInterventions,
|
||||
currentPause,
|
||||
currentInterventionSlot,
|
||||
}: AIWritingTimelineProps) {
|
||||
const visible = events.filter((e) => DISPLAYED_TYPES.has(e.type));
|
||||
|
||||
if (visible.length === 0) return null;
|
||||
|
||||
// 遍历时追踪 await_user 事件的序号,以便从 completedInterventions 取对应记录
|
||||
let awaitUserIdx = 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-0">
|
||||
{visible.map((event, idx) => {
|
||||
const isLast = idx === visible.length - 1;
|
||||
let slot: React.ReactNode = null;
|
||||
|
||||
if (event.type === 'await_user') {
|
||||
const completedRecord = completedInterventions[awaitUserIdx];
|
||||
awaitUserIdx += 1;
|
||||
|
||||
if (completedRecord) {
|
||||
// 已完成的干预点 → 渲染折叠历史卡
|
||||
slot = <CompletedInterventionCard record={completedRecord} />;
|
||||
} else if (currentPause && isLast) {
|
||||
// 当前活跃干预点 → 渲染操作卡
|
||||
slot = currentInterventionSlot;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineStep key={idx} event={event} isLast={isLast && !slot}>
|
||||
{slot}
|
||||
</TimelineStep>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
CheckCircle2, ChevronRight, ChevronDown,
|
||||
ExternalLink, ListTree, FileText,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { CompletedIntervention } from '../../types/ai-writing';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
import { ScoreBar } from './ScoreBar';
|
||||
|
||||
interface Props {
|
||||
record: CompletedIntervention;
|
||||
}
|
||||
|
||||
export function CompletedInterventionCard({ record }: Props) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (record.pausePoint === 'material_confirm') {
|
||||
return <MaterialCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'outline_confirm') {
|
||||
return <OutlineCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'draft_confirm') {
|
||||
return <DraftCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
if (record.pausePoint === 'review_confirm') {
|
||||
return <ReviewCompleted record={record} expanded={expanded} onToggle={() => setExpanded(v => !v)} />;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── 素材确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const count = record.materials?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<FileText className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="flex-1 font-medium">
|
||||
{count === 0 && record.action !== 're_search' ? (
|
||||
'已在无素材的情况下继续写作'
|
||||
) : (
|
||||
<>
|
||||
已确认{' '}
|
||||
<span className="text-muted-foreground font-normal">{count} 条素材</span>
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
{count > 0 && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.materials && record.materials.length > 0 && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
{record.materials.map((m) => {
|
||||
const pct = Math.round(m.relevanceScore * 100);
|
||||
const barColor =
|
||||
m.relevanceScore >= 0.8 ? 'bg-green-500'
|
||||
: m.relevanceScore >= 0.5 ? 'bg-orange-400'
|
||||
: 'bg-red-400';
|
||||
return (
|
||||
<div key={m.id} className="rounded border border-border/50 bg-background px-2.5 py-1.5 space-y-1">
|
||||
<p className="font-medium leading-snug line-clamp-2">{m.title}</p>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<span className="truncate max-w-[100px]">{m.source}</span>
|
||||
{m.publishedAt && <><span className="text-muted-foreground/40">·</span><span>{m.publishedAt}</span></>}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div className={cn('h-full rounded-full', barColor)} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={cn('shrink-0 text-[10px]', barColor.replace('bg-', 'text-'))}>{pct}%</span>
|
||||
</div>
|
||||
{m.recUuid && (
|
||||
<button type="button"
|
||||
onClick={() => window.open(buildIntranetSourceUrl(m.recUuid), '_blank', 'noopener,noreferrer')}
|
||||
className="shrink-0 text-primary hover:underline inline-flex items-center gap-0.5">
|
||||
原文 <ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 大纲确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function OutlineCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const sectionCount = record.outline?.sections.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors text-left"
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="flex-1 font-medium">
|
||||
已确认大纲
|
||||
{record.outline?.title && (
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
《{record.outline.title}》
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-muted-foreground font-normal">共 {sectionCount} 章节</span>
|
||||
</span>
|
||||
{record.outline && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.outline && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<OutlineCard outline={record.outline} readOnly />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 草稿确认历史卡 ────────────────────────────────────────────────────────────
|
||||
|
||||
function DraftCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const title = record.draftTitle ? `《${record.draftTitle}》` : '草稿';
|
||||
const wordCount = record.draftWordCount ?? 0;
|
||||
const hasReview = !!record.reviewResult;
|
||||
|
||||
const actionLabel =
|
||||
record.action === 'finalize' ? '已确认定稿' :
|
||||
record.action === 'user_revise' ? '已要求修改' :
|
||||
'已提交编辑审核';
|
||||
|
||||
const actionColor =
|
||||
record.action === 'finalize' ? 'text-green-600 dark:text-green-400' :
|
||||
record.action === 'user_revise' ? 'text-orange-500 dark:text-orange-400' :
|
||||
'text-blue-500 dark:text-blue-400';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasReview ? onToggle : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left',
|
||||
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span className={cn('font-medium shrink-0', actionColor)}>{actionLabel}</span>
|
||||
<span className="text-muted-foreground flex-1 truncate">
|
||||
{title}
|
||||
{wordCount > 0 && <span className="ml-1">({wordCount} 字)</span>}
|
||||
</span>
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ReviewCard review={record.reviewResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 编辑审核确认历史卡 ────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewCompleted({ record, expanded, onToggle }: { record: CompletedIntervention; expanded: boolean; onToggle: () => void }) {
|
||||
const isForce = record.action === 'force_finalize';
|
||||
const hasReview = !!record.reviewResult;
|
||||
|
||||
const actionLabel = isForce ? '已强制定稿(忽略编辑意见)' : '已采纳编辑意见,重新写作';
|
||||
const actionColor = isForce ? 'text-orange-500 dark:text-orange-400' : 'text-blue-500 dark:text-blue-400';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200/60 bg-green-50/30 dark:border-green-900/30 dark:bg-green-950/10 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={hasReview ? onToggle : undefined}
|
||||
className={cn(
|
||||
'w-full flex items-center gap-2 px-3 py-2 text-left',
|
||||
hasReview && 'hover:bg-green-50/60 dark:hover:bg-green-950/20 transition-colors',
|
||||
)}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
<span className={cn('font-medium flex-1', actionColor)}>{actionLabel}</span>
|
||||
{hasReview && record.reviewResult && (
|
||||
<span className="text-muted-foreground tabular-nums shrink-0">
|
||||
{record.reviewResult.overallScore} / 100
|
||||
</span>
|
||||
)}
|
||||
{hasReview && (
|
||||
expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
{expanded && record.reviewResult && (
|
||||
<div className="px-2.5 pb-2.5 pt-1 space-y-1.5 border-t border-green-200/40 dark:border-green-900/20">
|
||||
<ScoreBar label="事实准确性" score={record.reviewResult.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={record.reviewResult.logicScore} />
|
||||
<ScoreBar label="语言文字" score={record.reviewResult.languageScore} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Brain, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StreamingDraftSection } from '../../types/ai-writing';
|
||||
|
||||
interface DraftStreamingViewProps {
|
||||
title: string;
|
||||
sections: StreamingDraftSection[];
|
||||
}
|
||||
|
||||
function TypewriterCursor() {
|
||||
return (
|
||||
<span
|
||||
aria-hidden
|
||||
className="inline-block w-0.5 h-4 bg-foreground align-middle ml-0.5 animate-[blink_1s_step-end_infinite]"
|
||||
style={{ verticalAlign: 'text-bottom' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionThinking({ thinkingText, isDone }: { thinkingText: string; isDone: boolean }) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinkingText, open]);
|
||||
|
||||
if (!thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="mb-2 rounded-lg border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-1.5 px-2.5 py-1.5 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3 text-amber-500 shrink-0" />
|
||||
<span className="text-[11px] font-medium text-amber-600 dark:text-amber-400">思考过程</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{open ? <ChevronDown className="size-3" /> : <ChevronRight className="size-3" />}
|
||||
</span>
|
||||
</button>
|
||||
{open && (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[10px] font-mono leading-relaxed px-2.5 py-2 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-32 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{!isDone && (
|
||||
<span className="inline-block w-0.5 h-3 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** 章节正文:把 `### 要点` 行渲染成小标题,其余按段落显示(流式纯文本,非完整 markdown 解析) */
|
||||
function SectionBody({ text, showCursor }: { text: string; showCursor: boolean }) {
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<div className="m-0">
|
||||
{lines.map((line, i) => {
|
||||
// 严格模式下作家声明素材不足的哨兵行,不展示给用户
|
||||
if (line.includes('[[SECTION_BLOCKED]]')) return null;
|
||||
const heading = /^[ \t]*#{1,6}[ \t]+/.exec(line);
|
||||
if (heading) {
|
||||
return (
|
||||
<p key={i} className="text-xs font-semibold text-foreground mt-2 mb-1">
|
||||
{line.slice(heading[0].length)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
if (!line.trim()) return null; // 跳过空行,避免大段空白
|
||||
return (
|
||||
<p
|
||||
key={i}
|
||||
className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0 mb-1"
|
||||
>
|
||||
{line}
|
||||
</p>
|
||||
);
|
||||
})}
|
||||
{showCursor && <TypewriterCursor />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DraftStreamingView({ title, sections }: DraftStreamingViewProps) {
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}, [sections]);
|
||||
|
||||
const activeSectionIdx = sections.reduce(
|
||||
(max, s) => (!s.isDone ? Math.max(max, s.sectionIndex) : max),
|
||||
-1,
|
||||
);
|
||||
|
||||
if (sections.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-4">
|
||||
<div className="rounded-xl border bg-card shadow-sm p-4 prose prose-sm dark:prose-invert max-w-none">
|
||||
{title && (
|
||||
<h1 className="text-base font-bold text-center mb-4 text-foreground">{title}</h1>
|
||||
)}
|
||||
|
||||
{sections.map((sec) => (
|
||||
<div key={sec.sectionIndex} className="mb-4">
|
||||
<h2 className={cn(
|
||||
'text-sm font-semibold mb-1.5',
|
||||
sec.isDone ? 'text-foreground' : 'text-purple-500',
|
||||
)}>
|
||||
{sec.sectionTitle}
|
||||
</h2>
|
||||
|
||||
{/* 该章节的思考过程(可折叠) */}
|
||||
<SectionThinking thinkingText={sec.thinkingText} isDone={sec.isDone} />
|
||||
|
||||
{/* 正文内容(### 要点行渲染为小标题) */}
|
||||
<SectionBody
|
||||
text={sec.text}
|
||||
showCursor={sec.sectionIndex === activeSectionIdx && !sec.isDone}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,663 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import {
|
||||
PauseCircle, CheckCircle2, RotateCcw, Search,
|
||||
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
|
||||
Trash2, BookOpen,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type {
|
||||
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
|
||||
ProgressEvent, UserInterventionPayload,
|
||||
BlockedSection, SectionDecisionKind,
|
||||
} from '../../types/ai-writing';
|
||||
import { MaterialsCard } from './MaterialsCard';
|
||||
import { MaterialsToolbar } from './MaterialsToolbar';
|
||||
import { OutlineCard } from './OutlineCard';
|
||||
import { ReviewCard } from './ReviewCard';
|
||||
|
||||
interface InterventionCardProps {
|
||||
pauseEvent: ProgressEvent;
|
||||
materials: AIMaterial[];
|
||||
outline: AIOutline | null;
|
||||
reviewResult: ReviewResult | null;
|
||||
draftTitle: string;
|
||||
revisionCount: number;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function InterventionCard({
|
||||
pauseEvent, materials, outline, reviewResult,
|
||||
draftTitle, revisionCount, onSubmit, disabled,
|
||||
}: InterventionCardProps) {
|
||||
const pausePoint = pauseEvent.pausePoint;
|
||||
|
||||
if (pausePoint === 'material_confirm') {
|
||||
return <MaterialConfirmCard materials={materials} onSubmit={onSubmit} disabled={disabled} />;
|
||||
}
|
||||
|
||||
if (pausePoint === 'outline_confirm') {
|
||||
return (
|
||||
<OutlineConfirmCard
|
||||
outline={outline ?? pauseEvent.outline ?? null}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'draft_confirm') {
|
||||
return (
|
||||
<DraftConfirmCard
|
||||
draftTitle={draftTitle}
|
||||
revisionCount={revisionCount}
|
||||
reviewResult={reviewResult}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'section_help') {
|
||||
return (
|
||||
<SectionHelpCard
|
||||
blockedSections={pauseEvent.blockedSections ?? []}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (pausePoint === 'review_confirm') {
|
||||
return (
|
||||
<ReviewConfirmCard
|
||||
reviewResult={reviewResult ?? (pauseEvent.pauseContext?.reviewResult as ReviewResult | null) ?? null}
|
||||
revisionCount={revisionCount}
|
||||
onSubmit={onSubmit}
|
||||
disabled={disabled}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-yellow-200 bg-yellow-50/50 dark:border-yellow-900/40 dark:bg-yellow-950/20 px-3 py-2.5 text-xs text-muted-foreground flex items-center gap-2">
|
||||
<PauseCircle className="size-3.5 text-yellow-500 shrink-0" />
|
||||
等待用户确认({pausePoint})…
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeDefaultSelection(issues: ReviewIssue[]): Set<number> {
|
||||
return new Set(issues.map((_, i) => i));
|
||||
}
|
||||
|
||||
function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number>): string {
|
||||
const selected = issues.filter((_, i) => selectedIndices.has(i));
|
||||
if (selected.length === 0) return '';
|
||||
return selected
|
||||
.map((iss, n) => `${n + 1}. [${iss.category}] ${iss.issue}:${iss.suggestion}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
// ── PAUSE-1 素材确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface MaterialConfirmCardProps {
|
||||
materials: AIMaterial[];
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function MaterialConfirmCard({ materials, onSubmit, disabled }: MaterialConfirmCardProps) {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(
|
||||
() => new Set(materials.map((m) => m.id)),
|
||||
);
|
||||
const [extraKeywords, setExtraKeywords] = useState<string[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'confirm', approvedMaterialIds: [...selectedIds], extraKeywords });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleReSearch() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 're_search', approvedMaterialIds: [], extraKeywords });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasExtra = extraKeywords.length > 0;
|
||||
|
||||
// 空数据态:未检索到任何素材,提示用户重新搜索或在无素材情况下继续
|
||||
if (materials.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">未检索到任何素材</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
素材收集专家本次没有检索到相关数据。你可以补充关键词重新搜索,或在无素材的情况下直接继续写作(文章将仅依据写作意图生成)。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialsToolbar
|
||||
materials={materials}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set())}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleConfirm} disabled={disabled || submitting}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />无数据继续写作
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,或补充关键词追加搜索。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MaterialsCard materials={materials} selectedIds={selectedIds} onToggle={(id) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(id) ? next.delete(id) : next.add(id);
|
||||
return next;
|
||||
});
|
||||
}} />
|
||||
|
||||
<MaterialsToolbar
|
||||
materials={materials}
|
||||
selectedIds={selectedIds}
|
||||
onSelectAll={() => setSelectedIds(new Set(materials.map((m) => m.id)))}
|
||||
onDeselectAll={() => setSelectedIds(new Set())}
|
||||
extraKeywords={extraKeywords}
|
||||
onAddKeyword={(kw) => setExtraKeywords((prev) => [...prev, kw])}
|
||||
onRemoveKeyword={(kw) => setExtraKeywords((prev) => prev.filter((k) => k !== kw))}
|
||||
/>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleConfirm} disabled={disabled || submitting || selectedIds.size === 0}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认素材,开始规划大纲
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0"
|
||||
onClick={handleReSearch} disabled={disabled || submitting}
|
||||
>
|
||||
{hasExtra
|
||||
? <><Search className="size-3.5" />追加搜索</>
|
||||
: <><RotateCcw className="size-3.5" />重新搜索</>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-2 大纲确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface OutlineConfirmCardProps {
|
||||
outline: AIOutline | null;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function OutlineConfirmCard({ outline: initialOutline, onSubmit, disabled }: OutlineConfirmCardProps) {
|
||||
const [editedOutline, setEditedOutline] = useState<AIOutline | null>(initialOutline);
|
||||
const [feedback, setFeedback] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
async function handleConfirm() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({
|
||||
action: 'confirm',
|
||||
editedOutline: editedOutline !== initialOutline ? editedOutline ?? undefined : undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleReplan() {
|
||||
const text = feedback.trim();
|
||||
if (!text) return;
|
||||
setSubmitting(true);
|
||||
// 带上卡片里的当前大纲,让 AI 在此基础上按要求增删/调整,而非从零重生成
|
||||
await onSubmit({
|
||||
action: 're_outline',
|
||||
outlineFeedback: text,
|
||||
editedOutline: editedOutline ?? undefined,
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleReplan();
|
||||
}
|
||||
}
|
||||
|
||||
// 自动调整高度
|
||||
function handleInput(e: React.ChangeEvent<HTMLTextAreaElement>) {
|
||||
setFeedback(e.target.value);
|
||||
const el = e.target;
|
||||
el.style.height = 'auto';
|
||||
el.style.height = `${el.scrollHeight}px`;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">请确认大纲后继续写作</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
可直接点击章节标题或要点进行编辑,或在下方输入意见让 AI 重新规划。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editedOutline
|
||||
? <OutlineCard outline={editedOutline} onChange={setEditedOutline} />
|
||||
: <p className="text-xs text-muted-foreground text-center py-2">大纲加载中…</p>}
|
||||
|
||||
{/* 重新规划输入框 */}
|
||||
<div className="flex items-end gap-1.5 rounded-md border border-input bg-background px-2.5 py-1.5 focus-within:ring-1 focus-within:ring-ring">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={feedback}
|
||||
onChange={handleInput}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="输入修改意见后按 Enter 重新规划大纲,Shift+Enter 换行…"
|
||||
disabled={disabled || submitting}
|
||||
className="flex-1 resize-none overflow-hidden bg-transparent text-xs placeholder:text-muted-foreground focus:outline-none disabled:opacity-50 leading-5 py-0.5 min-h-[20px] max-h-32"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleReplan}
|
||||
disabled={disabled || submitting || !feedback.trim()}
|
||||
className="shrink-0 mb-0.5 p-1 rounded text-muted-foreground hover:text-primary hover:bg-primary/8 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
||||
title="发送(Enter)"
|
||||
>
|
||||
{submitting
|
||||
? <RefreshCw className="size-3.5 animate-spin" />
|
||||
: <SendHorizonal className="size-3.5" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||||
onClick={handleConfirm} disabled={disabled || submitting || !editedOutline}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认大纲,开始写作
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-3 草稿确认 ──────────────────────────────────────────────────────────
|
||||
|
||||
interface DraftConfirmCardProps {
|
||||
draftTitle: string;
|
||||
revisionCount: number;
|
||||
reviewResult: ReviewResult | null;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function DraftConfirmCard({ draftTitle, revisionCount, reviewResult, onSubmit, disabled }: DraftConfirmCardProps) {
|
||||
const [notes, setNotes] = useState('');
|
||||
const [showNotes, setShowNotes] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||||
);
|
||||
|
||||
function toggleIssue(index: number) {
|
||||
setSelectedIssueIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(index) ? next.delete(index) : next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function submit(action: string, extra?: Partial<UserInterventionPayload>) {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action, userRevisionNotes: notes.trim(), ...extra });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleApplyReview() {
|
||||
if (!reviewResult) return;
|
||||
const formattedNotes = formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices);
|
||||
setSubmitting(true);
|
||||
await onSubmit({
|
||||
action: 'user_revise',
|
||||
userRevisionNotes: formattedNotes,
|
||||
selectedIssueIndices: [...selectedIssueIndices],
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasReview = reviewResult != null && reviewResult.revisionNotes.length > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">草稿已完成,请确认后继续</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{draftTitle ? `《${draftTitle}》` : '草稿'}
|
||||
{revisionCount > 0 ? `(第 ${revisionCount} 次修改稿)` : '(初稿)'}
|
||||
· 右侧可预览完整内容
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 编辑审核结果(若已审核,支持勾选意见) */}
|
||||
{reviewResult && (
|
||||
<ReviewCard
|
||||
review={reviewResult}
|
||||
revisionCount={revisionCount}
|
||||
selectedIndices={hasReview ? selectedIssueIndices : undefined}
|
||||
onToggleIssue={hasReview ? toggleIssue : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 用户自定义修改意见 */}
|
||||
{showNotes && (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">您的修改意见</label>
|
||||
<textarea
|
||||
rows={2} value={notes} onChange={(e) => setNotes(e.target.value)}
|
||||
placeholder="描述希望修改的内容,例如:第二段数据来源不清晰,结尾过于仓促…"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col gap-1.5 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('finalize')} disabled={disabled || submitting}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />确认定稿
|
||||
</Button>
|
||||
|
||||
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
|
||||
{hasReview && (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm"
|
||||
className="w-full h-8 text-xs gap-1.5 border-orange-200 text-orange-700 hover:bg-orange-50 dark:border-orange-800 dark:text-orange-400 dark:hover:bg-orange-950/30"
|
||||
onClick={handleApplyReview}
|
||||
disabled={disabled || submitting || selectedIssueIndices.size === 0}
|
||||
>
|
||||
<ClipboardEdit className="size-3.5" />
|
||||
按编辑审核意见修改
|
||||
{selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
{showNotes ? (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('user_revise')} disabled={disabled || submitting || !notes.trim()}
|
||||
>
|
||||
<PenLine className="size-3.5" />按我的意见修改
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => setShowNotes(true)} disabled={disabled || submitting}
|
||||
>
|
||||
<PenLine className="size-3.5" />给出修改意见…
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={() => submit('to_editor')} disabled={disabled || submitting}
|
||||
>
|
||||
<ArrowRight className="size-3.5" />让编辑继续审核
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── PAUSE-4 编辑打回确认 ──────────────────────────────────────────────────────
|
||||
|
||||
interface ReviewConfirmCardProps {
|
||||
reviewResult: ReviewResult | null;
|
||||
revisionCount: number;
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
function ReviewConfirmCard({ reviewResult, revisionCount, onSubmit, disabled }: ReviewConfirmCardProps) {
|
||||
const [extraNotes, setExtraNotes] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedIssueIndices, setSelectedIssueIndices] = useState<Set<number>>(
|
||||
() => makeDefaultSelection(reviewResult?.revisionNotes ?? []),
|
||||
);
|
||||
|
||||
function toggleIssue(index: number) {
|
||||
setSelectedIssueIndices((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.has(index) ? next.delete(index) : next.add(index);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAccept() {
|
||||
setSubmitting(true);
|
||||
const issueNotes = reviewResult
|
||||
? formatSelectedIssues(reviewResult.revisionNotes, selectedIssueIndices)
|
||||
: '';
|
||||
const combined = [issueNotes, extraNotes.trim()].filter(Boolean).join('\n\n');
|
||||
await onSubmit({
|
||||
action: 'accept_review',
|
||||
userRevisionNotes: combined || undefined,
|
||||
selectedIssueIndices: [...selectedIssueIndices],
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
async function handleForce() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({ action: 'force_finalize', overrideVerdict: true });
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
const hasIssues = (reviewResult?.revisionNotes.length ?? 0) > 0;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-background space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">编辑认为文章需要修改</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
勾选您希望采纳的意见,再让作家重写;或忽略编辑意见强制定稿。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{reviewResult ? (
|
||||
<ReviewCard
|
||||
review={reviewResult}
|
||||
revisionCount={revisionCount}
|
||||
selectedIndices={hasIssues ? selectedIssueIndices : undefined}
|
||||
onToggleIssue={hasIssues ? toggleIssue : undefined}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-1">审核结果加载中…</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">补充修改指令(可选)</label>
|
||||
<textarea
|
||||
rows={2} value={extraNotes} onChange={(e) => setExtraNotes(e.target.value)}
|
||||
placeholder="留空则只采纳已勾选意见;或在此补充您自己的修改方向…"
|
||||
className="w-full rounded-md border border-input bg-background px-3 py-1.5 text-xs resize-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-1">
|
||||
<Button
|
||||
type="button" size="sm" className="flex-1 h-8 text-xs gap-1.5"
|
||||
onClick={handleAccept}
|
||||
disabled={disabled || submitting || (hasIssues && selectedIssueIndices.size === 0 && !extraNotes.trim())}
|
||||
>
|
||||
<ClipboardEdit className="size-3.5" />按编辑意见修改
|
||||
{hasIssues && selectedIssueIndices.size < reviewResult!.revisionNotes.length && (
|
||||
<span className="text-[10px] opacity-70">
|
||||
(已选 {selectedIssueIndices.size}/{reviewResult!.revisionNotes.length})
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button" variant="outline" size="sm" className="h-8 text-xs gap-1.5 shrink-0 text-destructive hover:text-destructive"
|
||||
onClick={handleForce} disabled={disabled || submitting}
|
||||
>
|
||||
<ShieldAlert className="size-3.5" />强制定稿
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 素材不足求助 ──────────────────────────────────────────────────────────────
|
||||
|
||||
interface SectionHelpCardProps {
|
||||
blockedSections: BlockedSection[];
|
||||
onSubmit: (payload: UserInterventionPayload) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const DECISION_OPTIONS: { value: SectionDecisionKind; label: string; icon: typeof Search }[] = [
|
||||
{ value: 'supplement', label: '补充检索素材', icon: Search },
|
||||
{ value: 'loose', label: '按通用知识续写', icon: BookOpen },
|
||||
{ value: 'delete', label: '删除该章节', icon: Trash2 },
|
||||
];
|
||||
|
||||
function SectionHelpCard({ blockedSections, onSubmit, disabled }: SectionHelpCardProps) {
|
||||
const [decisions, setDecisions] = useState<Record<string, SectionDecisionKind>>(
|
||||
() => Object.fromEntries(blockedSections.map((b) => [b.sectionTitle, 'supplement'])),
|
||||
);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const hasSupplement = Object.values(decisions).some((d) => d === 'supplement');
|
||||
|
||||
async function handleSubmit() {
|
||||
setSubmitting(true);
|
||||
await onSubmit({
|
||||
action: 'section_help',
|
||||
sectionDecisions: blockedSections.map((b) => ({
|
||||
sectionTitle: b.sectionTitle,
|
||||
decision: decisions[b.sectionTitle] ?? 'supplement',
|
||||
})),
|
||||
});
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/50 dark:border-amber-900/40 dark:bg-amber-950/20 space-y-3 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<ShieldAlert className="size-4 text-amber-500 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-xs font-medium">部分章节素材不足,需要你决定</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
严格依据素材模式下,以下 {blockedSections.length} 个章节缺少足够素材支撑写作。请为每个章节选择处理方式。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{blockedSections.map((b) => (
|
||||
<div key={b.sectionTitle} className="rounded-md border bg-background p-2.5 space-y-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium">{b.sectionTitle}</p>
|
||||
<p className="text-[11px] text-muted-foreground mt-0.5">{b.reason}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{DECISION_OPTIONS.map((opt) => {
|
||||
const active = (decisions[b.sectionTitle] ?? 'supplement') === opt.value;
|
||||
const Icon = opt.icon;
|
||||
return (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled || submitting}
|
||||
onClick={() => setDecisions((p) => ({ ...p, [b.sectionTitle]: opt.value }))}
|
||||
className={cn(
|
||||
'flex-1 flex items-center justify-center gap-1 rounded px-1.5 py-1 text-[11px] border transition-colors',
|
||||
active
|
||||
? 'border-primary bg-primary/10 text-primary font-medium'
|
||||
: 'border-input text-muted-foreground hover:bg-muted/50',
|
||||
)}
|
||||
>
|
||||
<Icon className="size-3 shrink-0" />
|
||||
{opt.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasSupplement && (
|
||||
<p className="text-[11px] text-amber-600 dark:text-amber-400">
|
||||
含「补充检索素材」的章节将触发重新检索并重新规划大纲,本轮其它选择不生效。
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
|
||||
onClick={handleSubmit} disabled={disabled || submitting}
|
||||
>
|
||||
<CheckCircle2 className="size-3.5" />提交并继续
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReviewIssue } from '../../types/ai-writing';
|
||||
|
||||
interface IssueListProps {
|
||||
issues: ReviewIssue[];
|
||||
selectedIndices?: Set<number>;
|
||||
onToggle?: (index: number) => void;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
fact: '事实',
|
||||
logic: '逻辑',
|
||||
language: '语言',
|
||||
};
|
||||
|
||||
const CATEGORY_COLOR: Record<string, string> = {
|
||||
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400',
|
||||
logic: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400',
|
||||
language: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
|
||||
};
|
||||
|
||||
export function IssueList({ issues, selectedIndices, onToggle }: IssueListProps) {
|
||||
if (issues.length === 0) {
|
||||
return <p className="text-xs text-muted-foreground text-center py-1">无需修改意见</p>;
|
||||
}
|
||||
|
||||
const indexed = issues.map((issue, i) => ({ issue, index: i }));
|
||||
const major = indexed.filter(({ issue }) => issue.severity === 'major');
|
||||
const minor = indexed.filter(({ issue }) => issue.severity === 'minor');
|
||||
const selectable = selectedIndices !== undefined && onToggle !== undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{major.length > 0 && (
|
||||
<IssueGroup
|
||||
label="必须修改"
|
||||
labelClass="text-red-500"
|
||||
items={major}
|
||||
selectable={selectable}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
{minor.length > 0 && (
|
||||
<IssueGroup
|
||||
label="建议修改"
|
||||
labelClass="text-yellow-500"
|
||||
items={minor}
|
||||
selectable={selectable}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface IssueGroupProps {
|
||||
label: string;
|
||||
labelClass: string;
|
||||
items: { issue: ReviewIssue; index: number }[];
|
||||
selectable: boolean;
|
||||
selectedIndices?: Set<number>;
|
||||
onToggle?: (index: number) => void;
|
||||
}
|
||||
|
||||
function IssueGroup({ label, labelClass, items, selectable, selectedIndices, onToggle }: IssueGroupProps) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className={cn('text-xs font-semibold', labelClass)}>{label}</p>
|
||||
{items.map(({ issue, index }) => {
|
||||
const isSelected = !selectable || (selectedIndices?.has(index) ?? true);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={cn(
|
||||
'rounded border bg-background px-2.5 py-2 space-y-1 text-xs transition-opacity',
|
||||
selectable ? 'cursor-pointer' : '',
|
||||
selectable && !isSelected ? 'opacity-40 border-border/30' : 'border-border/60',
|
||||
)}
|
||||
onClick={selectable ? () => onToggle?.(index) : undefined}
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{selectable && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={() => onToggle?.(index)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="size-3.5 shrink-0 accent-primary cursor-pointer"
|
||||
/>
|
||||
)}
|
||||
<span className={cn('rounded px-1.5 py-0.5 text-[10px] font-medium', CATEGORY_COLOR[issue.category] ?? 'bg-muted text-muted-foreground')}>
|
||||
{CATEGORY_LABEL[issue.category] ?? issue.category}
|
||||
</span>
|
||||
<span className="text-muted-foreground truncate">{issue.location}</span>
|
||||
</div>
|
||||
<p className={cn('text-foreground leading-snug', selectable ? 'pl-5' : '')}>
|
||||
{issue.issue}
|
||||
</p>
|
||||
<p className={cn('text-muted-foreground leading-snug', selectable ? 'pl-5' : '')}>
|
||||
<span className="text-primary mr-1">→</span>
|
||||
{issue.suggestion}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { buildIntranetSourceUrl } from '@/core/config';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
|
||||
interface MaterialItemProps {
|
||||
material: AIMaterial;
|
||||
checked: boolean;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
function relevanceColor(score: number): string {
|
||||
if (score >= 0.8) return 'bg-green-500';
|
||||
if (score >= 0.5) return 'bg-orange-400';
|
||||
return 'bg-red-400';
|
||||
}
|
||||
|
||||
function relevanceLabel(score: number): string {
|
||||
if (score >= 0.8) return '高相关';
|
||||
if (score >= 0.5) return '中相关';
|
||||
return '低相关';
|
||||
}
|
||||
|
||||
export function MaterialItem({ material, checked, onToggle }: MaterialItemProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const pct = Math.round(material.relevanceScore * 100);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md border text-xs transition-colors',
|
||||
checked ? 'border-border bg-background' : 'border-border/50 bg-muted/30',
|
||||
)}
|
||||
>
|
||||
{/* 头部行 */}
|
||||
<div className="flex items-start gap-2 px-2.5 py-2">
|
||||
{/* Checkbox */}
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={() => onToggle(material.id)}
|
||||
className="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-input accent-primary cursor-pointer"
|
||||
/>
|
||||
|
||||
{/* 标题 + 展开按钮 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="flex flex-1 items-start gap-1.5 text-left"
|
||||
>
|
||||
<span className={cn('flex-1 font-medium leading-snug line-clamp-2', !checked && 'text-muted-foreground')}>
|
||||
{material.title}
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDown className="mt-0.5 size-3 shrink-0 text-muted-foreground" />
|
||||
: <ChevronRight className="mt-0.5 size-3 shrink-0 text-muted-foreground" />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 元信息行 */}
|
||||
<div className="flex items-center gap-2 px-2.5 pb-2 pl-8">
|
||||
<span className="text-muted-foreground truncate max-w-[120px]">{material.source}</span>
|
||||
{material.publishedAt && (
|
||||
<>
|
||||
<span className="text-muted-foreground/40">·</span>
|
||||
<span className="text-muted-foreground shrink-0">{material.publishedAt}</span>
|
||||
</>
|
||||
)}
|
||||
{/* 相关度色条 */}
|
||||
<div className="flex flex-1 items-center gap-1 min-w-0">
|
||||
<div className="flex-1 h-1 rounded-full bg-muted overflow-hidden min-w-[40px]">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all', relevanceColor(material.relevanceScore))}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={cn('shrink-0', relevanceColor(material.relevanceScore).replace('bg-', 'text-'))}>
|
||||
{pct}%
|
||||
</span>
|
||||
<span className="text-muted-foreground/60 shrink-0">({relevanceLabel(material.relevanceScore)})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 展开内容 */}
|
||||
{expanded && (
|
||||
<div className="px-2.5 pb-2.5 pl-8 space-y-1.5 border-t">
|
||||
<p className="text-muted-foreground leading-relaxed pt-2 line-clamp-6">
|
||||
{material.content.slice(0, 300)}
|
||||
{material.content.length > 300 && '…'}
|
||||
</p>
|
||||
{material.recUuid && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
window.open(buildIntranetSourceUrl(material.recUuid), '_blank', 'noopener,noreferrer')
|
||||
}
|
||||
className="inline-flex items-center gap-1 text-primary hover:underline"
|
||||
>
|
||||
查看原文
|
||||
<ExternalLink className="size-2.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronDown, ChevronRight, FileText } from 'lucide-react';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
import { MaterialItem } from './MaterialItem';
|
||||
|
||||
interface MaterialsCardProps {
|
||||
materials: AIMaterial[];
|
||||
selectedIds: Set<string>;
|
||||
onToggle: (id: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsCard({ materials, selectedIds, onToggle }: MaterialsCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||
{/* 折叠头 */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-xs font-medium hover:bg-muted/40 transition-colors text-left"
|
||||
>
|
||||
<FileText className="size-3.5 text-blue-500 shrink-0" />
|
||||
<span className="flex-1">
|
||||
素材列表
|
||||
<span className="ml-1 text-muted-foreground font-normal">
|
||||
(共 {materials.length} 条,已选 {selectedIds.size} 条)
|
||||
</span>
|
||||
</span>
|
||||
{expanded
|
||||
? <ChevronDown className="size-3 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
{/* 列表 */}
|
||||
{expanded && (
|
||||
<div className="px-2.5 pb-2.5 space-y-1.5 border-t">
|
||||
{materials.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2 text-center">暂无素材</p>
|
||||
) : (
|
||||
materials.map((m) => (
|
||||
<MaterialItem
|
||||
key={m.id}
|
||||
material={m}
|
||||
checked={selectedIds.has(m.id)}
|
||||
onToggle={onToggle}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AIMaterial } from '../../types/ai-writing';
|
||||
|
||||
interface MaterialsToolbarProps {
|
||||
materials: AIMaterial[];
|
||||
selectedIds: Set<string>;
|
||||
onSelectAll: () => void;
|
||||
onDeselectAll: () => void;
|
||||
extraKeywords: string[];
|
||||
onAddKeyword: (kw: string) => void;
|
||||
onRemoveKeyword: (kw: string) => void;
|
||||
}
|
||||
|
||||
export function MaterialsToolbar({
|
||||
materials,
|
||||
selectedIds,
|
||||
onSelectAll,
|
||||
onDeselectAll,
|
||||
extraKeywords,
|
||||
onAddKeyword,
|
||||
onRemoveKeyword,
|
||||
}: MaterialsToolbarProps) {
|
||||
const [kwInput, setKwInput] = useState('');
|
||||
const allSelected = materials.length > 0 && selectedIds.size === materials.length;
|
||||
|
||||
function handleAddKeyword() {
|
||||
const kw = kwInput.trim();
|
||||
if (!kw || extraKeywords.includes(kw)) return;
|
||||
onAddKeyword(kw);
|
||||
setKwInput('');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{/* 全选/取消(无素材时隐藏) */}
|
||||
{materials.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
已选 {selectedIds.size} / {materials.length} 条素材
|
||||
</span>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={allSelected ? onDeselectAll : onSelectAll}
|
||||
className="text-xs text-primary hover:underline"
|
||||
>
|
||||
{allSelected ? '取消全选' : '全选'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 补充关键词 */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">补充搜索关键词(可选)</label>
|
||||
<div className="flex gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={kwInput}
|
||||
onChange={(e) => setKwInput(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleAddKeyword()}
|
||||
placeholder="输入关键词后按 Enter 或点击 +"
|
||||
className="flex-1 h-7 rounded-md border border-input bg-background px-2.5 py-1 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 px-2"
|
||||
onClick={handleAddKeyword}
|
||||
disabled={!kwInput.trim()}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 已添加关键词 Tag 列表 */}
|
||||
{extraKeywords.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{extraKeywords.map((kw) => (
|
||||
<span
|
||||
key={kw}
|
||||
className="inline-flex items-center gap-1 rounded-full bg-primary/10 text-primary px-2 py-0.5 text-xs"
|
||||
>
|
||||
{kw}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveKeyword(kw)}
|
||||
className="hover:text-destructive leading-none"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
import { useState } from 'react';
|
||||
import { ListTree, Plus } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import type { AIOutline, OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
import { OutlineSection } from './OutlineSection';
|
||||
|
||||
interface OutlineCardProps {
|
||||
outline: AIOutline;
|
||||
onChange?: (updated: AIOutline) => void;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
export function OutlineCard({ outline, onChange, readOnly = false }: OutlineCardProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(outline.title);
|
||||
const [dragIndex, setDragIndex] = useState<number | null>(null);
|
||||
const [overIndex, setOverIndex] = useState<number | null>(null);
|
||||
|
||||
const editable = !readOnly && !!onChange;
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed && trimmed !== outline.title) {
|
||||
onChange?.({ ...outline, title: trimmed });
|
||||
} else {
|
||||
setTitleDraft(outline.title);
|
||||
}
|
||||
}
|
||||
|
||||
function updateSection(i: number, updated: OutlineSectionType) {
|
||||
const sections = [...outline.sections];
|
||||
sections[i] = updated;
|
||||
onChange?.({ ...outline, sections });
|
||||
}
|
||||
|
||||
function removeSection(i: number) {
|
||||
onChange?.({ ...outline, sections: outline.sections.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function reorderSections(from: number, to: number) {
|
||||
if (from === to) return;
|
||||
const sections = [...outline.sections];
|
||||
const [moved] = sections.splice(from, 1);
|
||||
sections.splice(to, 0, moved);
|
||||
onChange?.({ ...outline, sections });
|
||||
}
|
||||
|
||||
function addSection() {
|
||||
onChange?.({
|
||||
...outline,
|
||||
sections: [...outline.sections, { sectionTitle: '', keyPoints: [], materialIds: [] }],
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b">
|
||||
<ListTree className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs font-medium flex-1">文章大纲</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
className="text-xs text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
{collapsed ? '展开' : '收起'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 文章标题 */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-muted-foreground shrink-0">标题:</span>
|
||||
{editingTitle && editable ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(outline.title); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-semibold"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={editable ? 'flex-1 font-semibold cursor-text hover:text-primary' : 'flex-1 font-semibold'}
|
||||
onClick={() => editable && setEditingTitle(true)}
|
||||
>
|
||||
{outline.title || <span className="text-muted-foreground italic font-normal">(未设置标题)</span>}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 章节列表 */}
|
||||
<div className="space-y-1.5">
|
||||
{outline.sections.map((section, i) => (
|
||||
<OutlineSection
|
||||
key={i}
|
||||
section={section}
|
||||
index={i}
|
||||
onChange={(updated) => updateSection(i, updated)}
|
||||
onRemove={editable ? () => removeSection(i) : undefined}
|
||||
readOnly={!editable}
|
||||
isDragging={dragIndex === i}
|
||||
isOver={overIndex === i && dragIndex !== null && dragIndex !== i}
|
||||
onDragStart={() => setDragIndex(i)}
|
||||
onDragEnter={() => setOverIndex(i)}
|
||||
onDrop={() => {
|
||||
if (dragIndex !== null && dragIndex !== i) reorderSections(dragIndex, i);
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
onDragEnd={() => {
|
||||
setDragIndex(null);
|
||||
setOverIndex(null);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 添加章节 */}
|
||||
{editable && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full h-7 text-xs gap-1.5 text-muted-foreground"
|
||||
onClick={addSection}
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加章节
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useState } from 'react';
|
||||
import { ChevronRight, ChevronDown, Plus, X, GripVertical } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { OutlineSection as OutlineSectionType } from '../../types/ai-writing';
|
||||
|
||||
interface OutlineSectionProps {
|
||||
section: OutlineSectionType;
|
||||
index: number;
|
||||
onChange: (updated: OutlineSectionType) => void;
|
||||
onRemove?: () => void;
|
||||
readOnly?: boolean;
|
||||
// Drag-and-drop
|
||||
isDragging?: boolean;
|
||||
isOver?: boolean;
|
||||
onDragStart?: () => void;
|
||||
onDragEnter?: () => void;
|
||||
onDrop?: () => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function OutlineSection({
|
||||
section, index, onChange, onRemove, readOnly,
|
||||
isDragging, isOver, onDragStart, onDragEnter, onDrop, onDragEnd,
|
||||
}: OutlineSectionProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [editingTitle, setEditingTitle] = useState(false);
|
||||
const [titleDraft, setTitleDraft] = useState(section.sectionTitle);
|
||||
|
||||
function commitTitle() {
|
||||
setEditingTitle(false);
|
||||
const trimmed = titleDraft.trim();
|
||||
if (trimmed && trimmed !== section.sectionTitle) {
|
||||
onChange({ ...section, sectionTitle: trimmed });
|
||||
} else {
|
||||
setTitleDraft(section.sectionTitle);
|
||||
}
|
||||
}
|
||||
|
||||
function updateKeyPoint(i: number, value: string) {
|
||||
const next = [...section.keyPoints];
|
||||
next[i] = value;
|
||||
onChange({ ...section, keyPoints: next });
|
||||
}
|
||||
|
||||
function removeKeyPoint(i: number) {
|
||||
onChange({ ...section, keyPoints: section.keyPoints.filter((_, idx) => idx !== i) });
|
||||
}
|
||||
|
||||
function addKeyPoint() {
|
||||
onChange({ ...section, keyPoints: [...section.keyPoints, ''] });
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
draggable={!readOnly}
|
||||
onDragStart={(e) => { e.stopPropagation(); onDragStart?.(); }}
|
||||
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); onDragEnter?.(); }}
|
||||
onDrop={(e) => { e.preventDefault(); e.stopPropagation(); onDrop?.(); }}
|
||||
onDragEnd={(e) => { e.stopPropagation(); onDragEnd?.(); }}
|
||||
className={cn(
|
||||
'rounded border bg-background text-xs transition-all duration-150 select-none',
|
||||
isDragging ? 'opacity-40 border-border/60' : isOver ? 'border-blue-400 ring-1 ring-blue-400/40' : 'border-border/60',
|
||||
)}
|
||||
>
|
||||
{/* 章节头 */}
|
||||
<div className="flex items-center gap-1.5 px-2.5 py-2">
|
||||
{!readOnly && (
|
||||
<GripVertical className="size-3 text-muted-foreground/40 shrink-0 cursor-grab active:cursor-grabbing" />
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
className="shrink-0"
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="size-3.5 text-muted-foreground" />
|
||||
: <ChevronRight className="size-3.5 text-muted-foreground" />}
|
||||
</button>
|
||||
|
||||
<span className="text-muted-foreground/60 shrink-0 w-4 text-center">{index + 1}.</span>
|
||||
|
||||
{editingTitle && !readOnly ? (
|
||||
<input
|
||||
autoFocus
|
||||
value={titleDraft}
|
||||
onChange={(e) => setTitleDraft(e.target.value)}
|
||||
onBlur={commitTitle}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') commitTitle();
|
||||
if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); }
|
||||
}}
|
||||
className="flex-1 bg-transparent border-b border-primary outline-none font-medium"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={cn('flex-1 font-medium', !readOnly && 'cursor-text hover:text-primary')}
|
||||
onClick={() => !readOnly && setEditingTitle(true)}
|
||||
>
|
||||
{section.sectionTitle || <span className="text-muted-foreground italic">(未命名)</span>}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<span className="text-muted-foreground/50 shrink-0">{section.keyPoints.length} 要点</span>
|
||||
{!readOnly && onRemove && (
|
||||
<button type="button" onClick={onRemove} className="shrink-0 text-muted-foreground/40 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 要点列表 */}
|
||||
{expanded && (
|
||||
<div className="px-8 pb-2.5 space-y-1 border-t">
|
||||
{section.keyPoints.map((kp, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5 pt-1.5">
|
||||
<span className="text-muted-foreground/40 shrink-0">•</span>
|
||||
{readOnly ? (
|
||||
<span className="flex-1 text-muted-foreground">{kp}</span>
|
||||
) : (
|
||||
<input
|
||||
value={kp}
|
||||
onChange={(e) => updateKeyPoint(i, e.target.value)}
|
||||
placeholder="输入要点…"
|
||||
className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
{!readOnly && (
|
||||
<button type="button" onClick={() => removeKeyPoint(i)} className="shrink-0 text-muted-foreground/30 hover:text-destructive">
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{!readOnly && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={addKeyPoint}
|
||||
className="flex items-center gap-1 text-muted-foreground/50 hover:text-primary pt-0.5"
|
||||
>
|
||||
<Plus className="size-3" />
|
||||
添加要点
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
|
||||
|
||||
interface OutlineStreamingViewProps {
|
||||
text: string;
|
||||
thinkingText?: string;
|
||||
}
|
||||
|
||||
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
const thinkRef = useRef<HTMLPreElement>(null);
|
||||
const [thinkOpen, setThinkOpen] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (thinkOpen && thinkRef.current) {
|
||||
thinkRef.current.scrollTop = thinkRef.current.scrollHeight;
|
||||
}
|
||||
}, [thinkingText, thinkOpen]);
|
||||
|
||||
if (!text && !thinkingText) return null;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2 space-y-2">
|
||||
{/* 思考过程区(仅在有思考内容时展示) */}
|
||||
{thinkingText && (
|
||||
<div className="rounded-xl border border-amber-200 dark:border-amber-900 bg-amber-50/50 dark:bg-amber-950/20 overflow-hidden shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setThinkOpen((v) => !v)}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 border-b border-amber-200/60 dark:border-amber-900/60 bg-amber-100/40 dark:bg-amber-900/30 hover:bg-amber-100/70 dark:hover:bg-amber-900/50 transition-colors"
|
||||
>
|
||||
<Brain className="size-3.5 text-amber-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
思考过程
|
||||
</span>
|
||||
<span className="ml-auto text-amber-400">
|
||||
{thinkOpen
|
||||
? <ChevronDown className="size-3.5" />
|
||||
: <ChevronRight className="size-3.5" />}
|
||||
</span>
|
||||
</button>
|
||||
{thinkOpen && (
|
||||
<pre
|
||||
ref={thinkRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-amber-700/70 dark:text-amber-300/60 whitespace-pre-wrap max-h-40 overflow-y-auto italic"
|
||||
>
|
||||
{thinkingText}
|
||||
{/* 思考仍在进行时显示光标 */}
|
||||
{!text && (
|
||||
<span className="inline-block w-0.5 h-3.5 bg-amber-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 正文流式区 */}
|
||||
{text && (
|
||||
<div className="rounded-xl border border-purple-200 dark:border-purple-900 bg-purple-50/50 dark:bg-purple-950/20 overflow-hidden shadow-sm">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
|
||||
<FileText className="size-3.5 text-purple-500 shrink-0" />
|
||||
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
|
||||
作家正在规划大纲…
|
||||
</span>
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-purple-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed p-3 text-muted-foreground whitespace-pre-wrap max-h-52 overflow-y-auto"
|
||||
>
|
||||
{text}
|
||||
<span className="inline-block w-0.5 h-3.5 bg-purple-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ReviewResult } from '../../types/ai-writing';
|
||||
import { ScoreBar } from './ScoreBar';
|
||||
import { IssueList } from './IssueList';
|
||||
|
||||
interface ReviewCardProps {
|
||||
review: ReviewResult;
|
||||
revisionCount?: number;
|
||||
selectedIndices?: Set<number>;
|
||||
onToggleIssue?: (index: number) => void;
|
||||
}
|
||||
|
||||
export function ReviewCard({ review, revisionCount, selectedIndices, onToggleIssue }: ReviewCardProps) {
|
||||
const isPassed = review.verdict === 'pass';
|
||||
|
||||
return (
|
||||
<div className="rounded-md border bg-muted/20 overflow-hidden text-xs">
|
||||
{/* 头部:综合评分 + 轮次 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">编辑审核结果</span>
|
||||
{revisionCount !== undefined && revisionCount > 0 && (
|
||||
<span className="text-muted-foreground">(第 {revisionCount} 次修改)</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
'rounded-full px-2 py-0.5 text-[10px] font-semibold',
|
||||
isPassed
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400',
|
||||
)}
|
||||
>
|
||||
{isPassed ? '通过' : '打回'}
|
||||
</span>
|
||||
<span className="font-semibold tabular-nums">
|
||||
{review.overallScore}
|
||||
<span className="text-muted-foreground font-normal"> / 100</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 分项评分 */}
|
||||
<div className="px-3 py-2.5 space-y-2 border-b">
|
||||
<ScoreBar label="事实准确性" score={review.factScore} />
|
||||
<ScoreBar label="逻辑结构" score={review.logicScore} />
|
||||
<ScoreBar label="语言文字" score={review.languageScore} />
|
||||
<p className="text-muted-foreground/60 text-[10px]">
|
||||
通过线:{review.passThreshold} 分
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 修改意见(支持勾选) */}
|
||||
{review.revisionNotes.length > 0 && (
|
||||
<div className="px-3 py-2.5">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-muted-foreground font-medium">修改意见</p>
|
||||
{selectedIndices !== undefined && onToggleIssue && (
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
已选 {selectedIndices.size} / {review.revisionNotes.length}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<IssueList
|
||||
issues={review.revisionNotes}
|
||||
selectedIndices={selectedIndices}
|
||||
onToggle={onToggleIssue}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { CheckCircle2, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { StreamingReviewItem, ReviewIssue } from '../../types/ai-writing';
|
||||
|
||||
const CATEGORY_META: Record<
|
||||
'fact' | 'logic' | 'language',
|
||||
{ label: string; order: number }
|
||||
> = {
|
||||
fact: { label: '事实准确性', order: 0 },
|
||||
logic: { label: '逻辑结构', order: 1 },
|
||||
language: { label: '语言文字', order: 2 },
|
||||
};
|
||||
|
||||
const CATEGORIES = (['fact', 'logic', 'language'] as const);
|
||||
|
||||
function ScoreBar({ score }: { score: number }) {
|
||||
const color =
|
||||
score >= 80 ? 'bg-green-400' :
|
||||
score >= 60 ? 'bg-amber-400' :
|
||||
'bg-red-400';
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||
style={{ width: `${score}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs font-semibold tabular-nums w-8 text-right">{score}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function IssueRow({ issue }: { issue: ReviewIssue }) {
|
||||
return (
|
||||
<div className="text-[11px] leading-relaxed border-l-2 border-orange-200 dark:border-orange-800 pl-2 py-0.5">
|
||||
<span className={cn(
|
||||
'font-medium mr-1',
|
||||
issue.severity === 'major' ? 'text-red-500' : 'text-amber-500',
|
||||
)}>
|
||||
{issue.severity === 'major' ? '严重' : '轻微'}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{issue.issue}</span>
|
||||
{issue.suggestion && (
|
||||
<span className="text-orange-600 dark:text-orange-400"> → {issue.suggestion}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 流式打字机文本块(审核进行中的分类显示)
|
||||
function StreamingText({ item }: { item: StreamingReviewItem }) {
|
||||
const preRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (preRef.current) {
|
||||
preRef.current.scrollTop = preRef.current.scrollHeight;
|
||||
}
|
||||
}, [item.streamingText, item.thinkingText]);
|
||||
|
||||
const hasContent = item.streamingText || item.thinkingText;
|
||||
if (!hasContent) {
|
||||
return (
|
||||
<p className="text-[11px] text-muted-foreground pl-5 italic">正在审阅…</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<pre
|
||||
ref={preRef}
|
||||
className="text-[11px] font-mono leading-relaxed mt-1 p-2 rounded bg-muted/40 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto"
|
||||
>
|
||||
{item.thinkingText && (
|
||||
<span className="italic text-amber-600/70 dark:text-amber-400/60">
|
||||
{item.thinkingText}
|
||||
</span>
|
||||
)}
|
||||
{item.streamingText}
|
||||
<span className="inline-block w-0.5 h-3 bg-orange-400 ml-0.5 align-text-bottom animate-[blink_1s_step-end_infinite]" />
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
interface ReviewStreamingViewProps {
|
||||
items: StreamingReviewItem[];
|
||||
}
|
||||
|
||||
export function ReviewStreamingView({ items }: ReviewStreamingViewProps) {
|
||||
if (items.length === 0) return null;
|
||||
|
||||
const doneItems = items.filter((it) => it.isDone);
|
||||
const allDone = doneItems.length === 3;
|
||||
const overallScore =
|
||||
doneItems.length > 0
|
||||
? Math.round(doneItems.reduce((s, it) => s + it.score, 0) / doneItems.length)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<div className="px-4 pb-2">
|
||||
<div className="rounded-xl border border-orange-200 dark:border-orange-900 bg-orange-50/50 dark:bg-orange-950/20 overflow-hidden shadow-sm">
|
||||
{/* 标题栏 */}
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-orange-200/60 dark:border-orange-900/60 bg-orange-100/40 dark:bg-orange-900/30">
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400">
|
||||
编辑正在审核…
|
||||
</span>
|
||||
{!allDone && (
|
||||
<span className="ml-auto inline-flex gap-1">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className="size-1 rounded-full bg-orange-400 animate-bounce"
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
)}
|
||||
{allDone && (
|
||||
<span className="ml-auto text-xs text-orange-600 dark:text-orange-400 font-semibold tabular-nums">
|
||||
综合 {overallScore}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 逐项审核结果 */}
|
||||
<div className="divide-y divide-orange-100 dark:divide-orange-900/50">
|
||||
{CATEGORIES.map((cat) => {
|
||||
const meta = CATEGORY_META[cat];
|
||||
const item = items.find((it) => it.category === cat);
|
||||
const isDone = item?.isDone ?? false;
|
||||
const isStreaming = item != null && !item.isDone;
|
||||
const isPending = item == null;
|
||||
|
||||
return (
|
||||
<div key={cat} className="px-3 py-2 space-y-1.5">
|
||||
{/* 分类行 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{isDone ? (
|
||||
<CheckCircle2 className="size-3.5 text-green-500 shrink-0" />
|
||||
) : isStreaming ? (
|
||||
<Loader2 className="size-3.5 text-orange-400 animate-spin shrink-0" />
|
||||
) : (
|
||||
<span className="size-3.5 rounded-full border border-muted-foreground/30 shrink-0" />
|
||||
)}
|
||||
<span className={cn(
|
||||
'text-xs font-medium',
|
||||
isPending ? 'text-muted-foreground' : 'text-foreground',
|
||||
)}>
|
||||
{meta.label}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* 审核进行中:流式打字机文本 */}
|
||||
{isStreaming && item && <StreamingText item={item} />}
|
||||
|
||||
{/* 审核完成:得分 + 问题列表 */}
|
||||
{isDone && item && (
|
||||
<>
|
||||
<ScoreBar score={item.score} />
|
||||
{item.issues.length > 0 ? (
|
||||
<div className="space-y-1 mt-1">
|
||||
{item.issues.slice(0, 3).map((iss, i) => (
|
||||
<IssueRow key={i} issue={iss} />
|
||||
))}
|
||||
{item.issues.length > 3 && (
|
||||
<p className="text-[11px] text-muted-foreground pl-2">
|
||||
+{item.issues.length - 3} 条意见…
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[11px] text-green-600 dark:text-green-400 pl-5">
|
||||
无问题
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import { CheckCircle2, XCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { RevisionSummary } from '../../types/ai-writing';
|
||||
|
||||
interface RevisionSummaryCardProps {
|
||||
summary: RevisionSummary;
|
||||
}
|
||||
|
||||
const CATEGORY_LABEL: Record<string, string> = {
|
||||
fact: '事实',
|
||||
logic: '逻辑',
|
||||
language: '语言',
|
||||
};
|
||||
|
||||
export function RevisionSummaryCard({ summary }: RevisionSummaryCardProps) {
|
||||
const { revisedIssues, skippedIssues, oldWordCount, newWordCount } = summary;
|
||||
const wordDiff = newWordCount - oldWordCount;
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-green-200 dark:border-green-900/50 bg-green-50/50 dark:bg-green-950/20 overflow-hidden text-xs">
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between px-3 py-2 border-b border-green-200/60 dark:border-green-900/60 bg-green-100/40 dark:bg-green-900/30">
|
||||
<span className="font-medium text-green-700 dark:text-green-400">修改完成</span>
|
||||
<span className="text-muted-foreground tabular-nums">
|
||||
{newWordCount.toLocaleString()} 字
|
||||
{wordDiff !== 0 && (
|
||||
<span className={cn('ml-1', wordDiff > 0 ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>
|
||||
({wordDiff > 0 ? '+' : ''}{wordDiff})
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="px-3 py-2.5 space-y-2">
|
||||
{/* 已修改 */}
|
||||
{revisedIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-green-600 dark:text-green-400 font-medium flex items-center gap-1">
|
||||
<CheckCircle2 className="size-3.5" />
|
||||
已修改 {revisedIssues.length} 条意见
|
||||
</p>
|
||||
{revisedIssues.map((iss, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground leading-snug">
|
||||
<span className={cn(
|
||||
'shrink-0 rounded px-1 py-0 text-[10px] font-medium mt-0.5',
|
||||
iss.category === 'fact' ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-400' :
|
||||
iss.category === 'logic' ? 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-400' :
|
||||
'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-400',
|
||||
)}>
|
||||
{CATEGORY_LABEL[iss.category] ?? iss.category}
|
||||
</span>
|
||||
<span>{iss.issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 已跳过 */}
|
||||
{skippedIssues.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
<p className="text-muted-foreground font-medium flex items-center gap-1">
|
||||
<XCircle className="size-3.5" />
|
||||
跳过 {skippedIssues.length} 条意见
|
||||
</p>
|
||||
{skippedIssues.map((iss, i) => (
|
||||
<div key={i} className="flex items-start gap-1.5 pl-5 text-muted-foreground/60 leading-snug line-through">
|
||||
<span>{iss.issue}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{revisedIssues.length === 0 && skippedIssues.length === 0 && (
|
||||
<p className="text-muted-foreground text-center py-1">已完成修改</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface ScoreBarProps {
|
||||
label: string;
|
||||
score: number;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ScoreBar({ label, score, className }: ScoreBarProps) {
|
||||
const clipped = Math.min(100, Math.max(0, score));
|
||||
|
||||
const color =
|
||||
clipped >= 80
|
||||
? 'bg-green-500'
|
||||
: clipped >= 60
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-red-500';
|
||||
|
||||
const verdict =
|
||||
clipped >= 80 ? '优秀' : clipped >= 60 ? '需改进' : '不合格';
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<div className="flex justify-between text-xs text-muted-foreground">
|
||||
<span>{label}</span>
|
||||
<span className="font-medium tabular-nums">
|
||||
{clipped}
|
||||
<span className="text-muted-foreground/60 ml-1">({verdict})</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className={cn('h-full rounded-full transition-all duration-500', color)}
|
||||
style={{ width: `${clipped}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { AlertCircle, AlertTriangle, CheckCircle2, Circle, Loader2, PauseCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { ProgressEvent } from '../../types/ai-writing';
|
||||
|
||||
type StepStatus = 'done' | 'running' | 'paused' | 'waiting' | 'error' | 'warning';
|
||||
|
||||
interface TimelineStepProps {
|
||||
event: ProgressEvent;
|
||||
isLast?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
if (event.type === 'error') return 'error';
|
||||
if (event.type === 'notice') return 'warning';
|
||||
if (event.type === 'await_user') return 'paused';
|
||||
// step_start 只有在列表末尾(后续事件还未到达)时才显示转圈,否则已有后续事件说明该步骤已完成
|
||||
if (event.type === 'step_start') return isLast ? 'running' : 'done';
|
||||
return 'done';
|
||||
}
|
||||
|
||||
const AGENT_COLOR: Record<string, string> = {
|
||||
素材收集专家: 'text-blue-500',
|
||||
作家: 'text-purple-500',
|
||||
编辑: 'text-orange-500',
|
||||
系统: 'text-muted-foreground',
|
||||
};
|
||||
|
||||
export function TimelineStep({ event, isLast, children }: TimelineStepProps) {
|
||||
const status = statusOf(event, isLast ?? true);
|
||||
// notice=非致命告警(如 ES 波动、模型兜底),error=致命错误,两者都展示「标题 + 真实原因」
|
||||
const isAlert = event.type === 'error' || event.type === 'notice';
|
||||
const isError = event.type === 'error';
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
{/* 竖线 + 图标 */}
|
||||
<div className="flex flex-col items-center">
|
||||
<StatusIcon status={status} />
|
||||
{!isLast && <div className="w-px flex-1 bg-border mt-1" />}
|
||||
</div>
|
||||
|
||||
{/* 内容 */}
|
||||
<div className={cn('pb-4 flex-1 min-w-0', isLast && 'pb-0')}>
|
||||
{event.agentName && (
|
||||
<span
|
||||
className={cn(
|
||||
'text-xs font-semibold',
|
||||
AGENT_COLOR[event.agentName] ?? 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{event.agentName}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{isAlert ? (
|
||||
<div
|
||||
className={cn(
|
||||
'mt-1 rounded-md border px-2.5 py-1.5',
|
||||
isError
|
||||
? 'border-red-200 bg-red-50/60 dark:border-red-900/40 dark:bg-red-950/20'
|
||||
: 'border-amber-200 bg-amber-50/60 dark:border-amber-900/40 dark:bg-amber-950/20',
|
||||
)}
|
||||
>
|
||||
<p
|
||||
className={cn(
|
||||
'text-xs font-medium',
|
||||
isError
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-amber-600 dark:text-amber-500',
|
||||
)}
|
||||
>
|
||||
{event.title || event.message || (isError ? '出错了' : '提示')}
|
||||
</p>
|
||||
{event.detail && (
|
||||
<p className="mt-0.5 text-[11px] leading-relaxed text-muted-foreground break-words">
|
||||
{event.detail}
|
||||
</p>
|
||||
)}
|
||||
{/* 接口解析失败时的原始错误响应体,默认折叠,点击展开 */}
|
||||
{event.raw && (
|
||||
<details className="mt-1">
|
||||
<summary className="cursor-pointer select-none text-[11px] font-medium text-muted-foreground hover:text-foreground">
|
||||
错误返回结果(点击展开)
|
||||
</summary>
|
||||
<pre className="mt-1 max-h-60 overflow-auto whitespace-pre-wrap break-all rounded bg-muted/60 p-2 text-[10px] leading-relaxed text-muted-foreground">
|
||||
{event.raw}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="ml-1.5 text-xs text-muted-foreground">{event.message}</span>
|
||||
)}
|
||||
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusIcon({ status }: { status: StepStatus }) {
|
||||
switch (status) {
|
||||
case 'done':
|
||||
return <CheckCircle2 className="size-4 text-green-500 shrink-0 mt-0.5" />;
|
||||
case 'running':
|
||||
return <Loader2 className="size-4 text-blue-500 shrink-0 mt-0.5 animate-spin" />;
|
||||
case 'paused':
|
||||
return <PauseCircle className="size-4 text-yellow-500 shrink-0 mt-0.5" />;
|
||||
case 'error':
|
||||
return <AlertCircle className="size-4 text-red-500 shrink-0 mt-0.5" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="size-4 text-amber-500 shrink-0 mt-0.5" />;
|
||||
default:
|
||||
return <Circle className="size-4 text-muted-foreground/40 shrink-0 mt-0.5" />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { WritingStatus } from '../../types/ai-writing';
|
||||
|
||||
interface WritingSkeletonCardProps {
|
||||
status: WritingStatus;
|
||||
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
|
||||
researchProgress?: string;
|
||||
}
|
||||
|
||||
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonLine({ className }: { className?: string }) {
|
||||
return (
|
||||
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
|
||||
return (
|
||||
<span className="inline-flex gap-1 items-center">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<span
|
||||
key={i}
|
||||
className={cn('size-1.5 rounded-full animate-bounce', color)}
|
||||
style={{ animationDelay: `${i * 150}ms` }}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
{/* 标题行:展示素材收集专家的实时进度 */}
|
||||
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
|
||||
<ThinkingDots color="bg-blue-400" />
|
||||
<span>{researchProgress || '素材收集专家搜索中…'}</span>
|
||||
</div>
|
||||
|
||||
{/* 关键词标签占位 */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{[60, 80, 50].map((w, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
|
||||
style={{ width: `${w}px` }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 素材卡片占位 */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||||
{/* 标题 */}
|
||||
<div className="flex items-start gap-2">
|
||||
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
|
||||
</div>
|
||||
{/* 来源 + 时间 + 相关度 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<SkeletonLine className="w-20 h-2" />
|
||||
<SkeletonLine className="w-16 h-2" />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
|
||||
style={{ width: `${55 + i * 8}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* 摘要行 */}
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function OutlineSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家规划大纲中…</span>
|
||||
</div>
|
||||
|
||||
{/* 文章标题占位 */}
|
||||
<SkeletonLine className="w-3/5 h-4 mx-auto" />
|
||||
|
||||
{/* 章节列表 */}
|
||||
{[0, 1, 2, 3].map((i) => (
|
||||
<div key={i} className="flex items-start gap-2 pl-1">
|
||||
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
|
||||
<div className="flex-1 space-y-1.5">
|
||||
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
|
||||
<SkeletonLine className="w-3/5 h-2" />
|
||||
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function DraftSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家写作中…</span>
|
||||
</div>
|
||||
|
||||
{/* 标题占位 */}
|
||||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||||
|
||||
{/* 章节内容 */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<SkeletonLine className="w-1/4 h-3.5" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-3/4 h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
|
||||
|
||||
function ReviewSkeleton() {
|
||||
return (
|
||||
<div className="space-y-3 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
|
||||
<ThinkingDots color="bg-orange-400" />
|
||||
<span>编辑审核中…</span>
|
||||
</div>
|
||||
|
||||
{/* 综合评分占位 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<SkeletonLine className="w-24 h-3" />
|
||||
<SkeletonLine className="w-12 h-4" />
|
||||
</div>
|
||||
|
||||
{/* 分项评分条 */}
|
||||
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
|
||||
<div key={label} className="space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<SkeletonLine className="w-8 h-3" />
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
|
||||
style={{ width: `${55 + i * 10}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 意见条目占位 */}
|
||||
<div className="space-y-2 pt-1 border-t">
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-5/6 h-2" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className="w-4/5 h-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
|
||||
|
||||
function RevisingSkeleton() {
|
||||
return (
|
||||
<div className="space-y-4 p-4">
|
||||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||||
<ThinkingDots color="bg-purple-400" />
|
||||
<span>作家按意见修改中…</span>
|
||||
</div>
|
||||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||||
{[0, 1, 2].map((i) => (
|
||||
<div key={i} className="space-y-1.5">
|
||||
<SkeletonLine className="w-1/4 h-3.5" />
|
||||
<SkeletonLine className="w-full h-2" />
|
||||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 状态映射 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
|
||||
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
||||
// 实际运行状态
|
||||
writing_outline: OutlineSkeleton,
|
||||
writing_draft: DraftSkeleton,
|
||||
reviewing: ReviewSkeleton,
|
||||
revising: RevisingSkeleton,
|
||||
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
|
||||
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
|
||||
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
|
||||
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
|
||||
};
|
||||
|
||||
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
|
||||
// 素材收集阶段单独渲染,以便透传实时进度文案
|
||||
if (status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<MaterialsSkeleton researchProgress={researchProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const Skeleton = SKELETON_MAP[status];
|
||||
if (!Skeleton) return null;
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<Skeleton />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,6 +87,20 @@ export function CanvasArtifactPanel() {
|
||||
);
|
||||
}
|
||||
|
||||
// While streaming but no partial content yet — show a loading placeholder
|
||||
// so the template picker doesn't flash during generation.
|
||||
if (isStreaming && !artifact) {
|
||||
return (
|
||||
<div className="h-full flex flex-col items-center justify-center gap-3 text-muted-foreground">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-foreground">AI 正在生成…</p>
|
||||
<p className="text-xs">内容将在右侧显示</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!artifact) {
|
||||
return (
|
||||
<div className="h-full bg-muted/10">
|
||||
|
||||
@@ -3,10 +3,9 @@ import { javascript } from "@codemirror/lang-javascript";
|
||||
import { json } from "@codemirror/lang-json";
|
||||
import { python } from "@codemirror/lang-python";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
import type { ViewUpdate } from "@codemirror/view";
|
||||
import { basicLightInit } from "@uiw/codemirror-theme-basic";
|
||||
import { monokaiInit } from "@uiw/codemirror-theme-monokai";
|
||||
import CodeMirror from "@uiw/react-codemirror";
|
||||
import CodeMirror, { type ViewUpdate } from "@uiw/react-codemirror";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useMemo } from "react";
|
||||
|
||||
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useDocumentOutline } from "../../hooks/useDocumentOutline";
|
||||
import type { OutlineHeading } from "../../hooks/useDocumentOutline";
|
||||
|
||||
interface DraftOutlineSidebarProps {
|
||||
/** Tiptap 编辑器实例,用于提取标题并解析标题 DOM */
|
||||
editor: Editor | null;
|
||||
/** 文档滚动容器,点击目录项 / scroll-spy 时在其内部滚动与定位 */
|
||||
scrollEl: HTMLElement | null;
|
||||
}
|
||||
|
||||
/** 点击跳转时标题距容器顶部预留的间距(px) */
|
||||
const SCROLL_TOP_OFFSET = 16;
|
||||
|
||||
/** scroll-spy:标题顶部进入此阈值内即视为「当前章节」(px) */
|
||||
const ACTIVE_THRESHOLD = 80;
|
||||
|
||||
/**
|
||||
* 目录项的展示层级:
|
||||
* - 文档 H1 = 文章标题,单独作顶部标题块(不进列表)
|
||||
* - 文档 H2 = 章节名称 → 列表一级项
|
||||
* - 文档 H3 = 要点 → 列表二级项
|
||||
*/
|
||||
function displayLevel(h: OutlineHeading): 1 | 2 {
|
||||
return h.level >= 3 ? 2 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 草稿文档左侧目录栏(仿豆包 / Word 目录窗格):
|
||||
* 顶部独立标题块 + 章节/要点两级列表,支持点击平滑跳转、滚动高亮、折叠。
|
||||
*/
|
||||
export function DraftOutlineSidebar({ editor, scrollEl }: DraftOutlineSidebarProps) {
|
||||
const headings = useDocumentOutline(editor);
|
||||
const [activeId, setActiveId] = useState<string | null>(null);
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
// 第一个 H1 作为文章标题,其余标题进目录列表
|
||||
const { titleHeading, items } = useMemo(() => {
|
||||
const titleIdx = headings.findIndex((h) => h.level === 1);
|
||||
return {
|
||||
titleHeading: titleIdx >= 0 ? headings[titleIdx] : null,
|
||||
items: headings.filter((_, i) => i !== titleIdx),
|
||||
};
|
||||
}, [headings]);
|
||||
|
||||
const scrollToHeading = useCallback(
|
||||
(pos: number, id: string) => {
|
||||
if (!editor || !scrollEl) return;
|
||||
const dom = editor.view.nodeDOM(pos) as HTMLElement | null;
|
||||
if (!dom) return;
|
||||
const elTop = dom.getBoundingClientRect().top;
|
||||
const containerTop = scrollEl.getBoundingClientRect().top;
|
||||
const target = scrollEl.scrollTop + (elTop - containerTop) - SCROLL_TOP_OFFSET;
|
||||
scrollEl.scrollTo({ top: Math.max(0, target), behavior: "smooth" });
|
||||
setActiveId(id);
|
||||
},
|
||||
[editor, scrollEl],
|
||||
);
|
||||
|
||||
// scroll-spy:随文档滚动高亮当前章节(rAF 节流)
|
||||
useEffect(() => {
|
||||
if (!editor || !scrollEl || headings.length === 0) return;
|
||||
|
||||
let raf = 0;
|
||||
const recompute = () => {
|
||||
raf = 0;
|
||||
const containerTop = scrollEl.getBoundingClientRect().top;
|
||||
let current = headings[0].id;
|
||||
for (const h of headings) {
|
||||
const dom = editor.view.nodeDOM(h.pos) as HTMLElement | null;
|
||||
if (!dom) continue;
|
||||
const relTop = dom.getBoundingClientRect().top - containerTop;
|
||||
// 标题在阈值内或之上 → 当前章节;首个落在阈值下方时其余皆下方,停止
|
||||
if (relTop <= ACTIVE_THRESHOLD) {
|
||||
current = h.id;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
setActiveId(current);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
if (raf) return;
|
||||
raf = requestAnimationFrame(recompute);
|
||||
};
|
||||
|
||||
recompute();
|
||||
scrollEl.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => {
|
||||
if (raf) cancelAnimationFrame(raf);
|
||||
scrollEl.removeEventListener("scroll", onScroll);
|
||||
};
|
||||
}, [editor, scrollEl, headings]);
|
||||
|
||||
// 没有可导航的章节时不渲染目录栏
|
||||
if (items.length === 0) return null;
|
||||
|
||||
// 折叠态:仅留一条窄轨 + 展开按钮
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div className="w-9 shrink-0 border-r bg-muted/10 flex flex-col items-center pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(false)}
|
||||
title="展开目录"
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<PanelLeftOpen className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-60 shrink-0 border-r bg-muted/10 flex flex-col overflow-hidden">
|
||||
{/* 头部:目录标签 + 折叠按钮 */}
|
||||
<div className="h-9 flex items-center justify-between pl-3 pr-1.5 shrink-0">
|
||||
<span className="text-[11px] font-medium tracking-wide text-muted-foreground/80">目录</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCollapsed(true)}
|
||||
title="收起目录"
|
||||
className="size-6 flex items-center justify-center rounded text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<PanelLeftClose className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 文章标题块 */}
|
||||
{titleHeading && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToHeading(titleHeading.pos, titleHeading.id)}
|
||||
title={titleHeading.text}
|
||||
className={cn(
|
||||
"shrink-0 w-full text-left px-3 pb-2.5 pt-0.5 border-b transition-colors",
|
||||
"text-[13px] font-semibold leading-snug line-clamp-2",
|
||||
activeId === titleHeading.id
|
||||
? "text-primary"
|
||||
: "text-foreground hover:text-primary",
|
||||
)}
|
||||
>
|
||||
{titleHeading.text}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 章节 / 要点列表 */}
|
||||
<nav className="flex-1 overflow-y-auto py-1.5">
|
||||
{items.map((h) => {
|
||||
const lvl = displayLevel(h);
|
||||
const isActive = h.id === activeId;
|
||||
return (
|
||||
<button
|
||||
key={h.id}
|
||||
type="button"
|
||||
onClick={() => scrollToHeading(h.pos, h.id)}
|
||||
title={h.text}
|
||||
className={cn(
|
||||
"group flex items-center w-full text-left py-1.5 pr-2 border-l-2 transition-colors",
|
||||
lvl === 1 ? "pl-3 text-xs" : "pl-6 text-[11px]",
|
||||
isActive
|
||||
? "border-primary bg-primary/[0.07] text-primary font-medium"
|
||||
: cn(
|
||||
"border-transparent hover:bg-muted/50",
|
||||
lvl === 1
|
||||
? "text-foreground/85 font-medium hover:text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
),
|
||||
)}
|
||||
>
|
||||
{/* 二级(要点)项前的小圆点 */}
|
||||
{lvl === 2 && (
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 size-1 rounded-full mr-1.5",
|
||||
isActive ? "bg-primary" : "bg-muted-foreground/40",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<span className="truncate">{h.text}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,18 +1,175 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { ArrowDown } from "lucide-react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { CanvasArtifactText } from "../../types/artifact";
|
||||
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
|
||||
import { TiptapEditor } from "../../editor/TiptapEditor";
|
||||
import { useWritingEditor } from "../../hooks/useWritingEditor";
|
||||
import { useWritingRewrite } from "../../hooks/useWritingRewrite";
|
||||
import { WritingToolbar } from "./WritingToolbar";
|
||||
import { WritingBubbleMenu } from "./WritingBubbleMenu";
|
||||
import { WritingParagraphMenu } from "./WritingParagraphMenu";
|
||||
import { WritingAiMenu } from "./WritingAiMenu";
|
||||
import { WritingRewritePreview } from "./WritingRewritePreview";
|
||||
import { DraftOutlineSidebar } from "./DraftOutlineSidebar";
|
||||
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
|
||||
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
|
||||
import { applyReplace, applyInsert } from "../../utils/apply-rewrite";
|
||||
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
|
||||
|
||||
interface TextArtifactRendererProps {
|
||||
content: CanvasArtifactText;
|
||||
isStreaming?: boolean;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { handleTextPointerUp } = useCanvasSelection();
|
||||
interface PanelPos {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
if (!content.fullMarkdown) {
|
||||
/** Phase of the AI rewrite flow */
|
||||
type FlowPhase = "idle" | "menu" | "preview";
|
||||
|
||||
interface LastAction {
|
||||
action: RewriteAction;
|
||||
tone?: RewriteTone;
|
||||
customInstruction?: string;
|
||||
}
|
||||
|
||||
export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRendererProps) {
|
||||
const { artifact, setArtifact } = useCanvasGraphContext();
|
||||
const { writingSelection } = useCanvasUIContext();
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [showScrollBtn, setShowScrollBtn] = useState(false);
|
||||
const autoScrollRef = useRef(true);
|
||||
|
||||
// 编辑模式文档滚动容器:用 state 而非 ref,使目录栏在容器挂载后能拿到它
|
||||
const [docScrollEl, setDocScrollEl] = useState<HTMLDivElement | null>(null);
|
||||
|
||||
// ── AI rewrite flow state ──────────────────────────────────────────────
|
||||
const [phase, setPhase] = useState<FlowPhase>("idle");
|
||||
const [panelPos, setPanelPos] = useState<PanelPos>({ x: 0, y: 0 });
|
||||
const lastActionRef = useRef<LastAction | null>(null);
|
||||
|
||||
const handleEditorChange = useCallback(
|
||||
(markdown: string) => {
|
||||
if (!artifact) return;
|
||||
setArtifact({
|
||||
...artifact,
|
||||
contents: artifact.contents.map((c) =>
|
||||
c.index === artifact.currentIndex && c.type === "text"
|
||||
? { ...c, fullMarkdown: markdown }
|
||||
: c,
|
||||
),
|
||||
});
|
||||
},
|
||||
[artifact, setArtifact],
|
||||
);
|
||||
|
||||
const editor = useWritingEditor({
|
||||
markdown: content.fullMarkdown,
|
||||
onChange: handleEditorChange,
|
||||
editable: !isStreaming,
|
||||
placeholder: "开始写作…",
|
||||
});
|
||||
|
||||
const { status, result, thinking, error, rewrite, rewriteFollowUp, reset } = useWritingRewrite(editor);
|
||||
|
||||
// ── Streaming auto-scroll ──────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!isStreaming) {
|
||||
autoScrollRef.current = true;
|
||||
setShowScrollBtn(false);
|
||||
return;
|
||||
}
|
||||
if (!autoScrollRef.current) return;
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [isStreaming, content.fullMarkdown]);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!isStreaming) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
||||
autoScrollRef.current = atBottom;
|
||||
setShowScrollBtn(!atBottom);
|
||||
}, [isStreaming]);
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
autoScrollRef.current = true;
|
||||
setShowScrollBtn(false);
|
||||
}, []);
|
||||
|
||||
// ── AI rewrite flow handlers ───────────────────────────────────────────
|
||||
|
||||
/** Compute anchor position from current DOM selection */
|
||||
const computePos = useCallback((): PanelPos => {
|
||||
const sel = window.getSelection();
|
||||
if (sel && sel.rangeCount > 0) {
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect();
|
||||
if (rect.width || rect.height) {
|
||||
return { x: rect.left, y: rect.bottom + 6 };
|
||||
}
|
||||
}
|
||||
return { x: window.innerWidth / 2 - 90, y: window.innerHeight / 2 };
|
||||
}, []);
|
||||
|
||||
const openAiMenu = useCallback(() => {
|
||||
reset();
|
||||
setPanelPos(computePos());
|
||||
setPhase("menu");
|
||||
}, [reset, computePos]);
|
||||
|
||||
const closeAll = useCallback(() => {
|
||||
editor?.commands.clearAiHighlight();
|
||||
setPhase("idle");
|
||||
reset();
|
||||
}, [reset, editor]);
|
||||
|
||||
const handleAction = useCallback(
|
||||
(action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
|
||||
lastActionRef.current = { action, tone, customInstruction };
|
||||
rewrite(action, tone, customInstruction);
|
||||
setPhase("preview");
|
||||
},
|
||||
[rewrite],
|
||||
);
|
||||
|
||||
const handleRetry = useCallback(() => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone, customInstruction } = lastActionRef.current;
|
||||
rewrite(action, tone, customInstruction);
|
||||
}, [rewrite]);
|
||||
|
||||
const handleFollowUp = useCallback(
|
||||
(instruction: string) => {
|
||||
if (!lastActionRef.current) return;
|
||||
const { action, tone } = lastActionRef.current;
|
||||
rewriteFollowUp(instruction, action, tone);
|
||||
},
|
||||
[rewriteFollowUp],
|
||||
);
|
||||
|
||||
const handleReplace = useCallback(() => {
|
||||
if (!editor || !writingSelection || !result) return;
|
||||
applyReplace(editor, writingSelection, result);
|
||||
closeAll();
|
||||
}, [editor, writingSelection, result, closeAll]);
|
||||
|
||||
const handleInsert = useCallback(() => {
|
||||
if (!editor || !writingSelection || !result) return;
|
||||
applyInsert(editor, writingSelection, result);
|
||||
closeAll();
|
||||
}, [editor, writingSelection, result, closeAll]);
|
||||
|
||||
// ── Empty state ────────────────────────────────────────────────────────
|
||||
if (!content.fullMarkdown && !isStreaming) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
@@ -22,125 +179,152 @@ export function TextArtifactRenderer({ content, isStreaming }: TextArtifactRende
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full overflow-y-auto"
|
||||
onMouseUp={() => handleTextPointerUp(content.fullMarkdown)}
|
||||
>
|
||||
<div className="max-w-3xl mx-auto px-8 py-8">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-2xl font-bold mb-4 mt-6 first:mt-0 text-foreground border-b border-border pb-2">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-xl font-semibold mb-3 mt-6 text-foreground">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-base font-semibold mb-2 mt-4 text-foreground">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
h4: ({ children }) => (
|
||||
<h4 className="text-sm font-semibold mb-2 mt-3 text-foreground">
|
||||
{children}
|
||||
</h4>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-7 text-sm text-foreground/90">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-5 list-disc space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-5 list-decimal space-y-1.5 text-sm text-foreground/90">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => (
|
||||
<li className="leading-7">{children}</li>
|
||||
),
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
if (isBlock) {
|
||||
return (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
table: ({ children }) => (
|
||||
<div className="mb-4 overflow-x-auto rounded-md border border-border">
|
||||
<table className="w-full text-sm border-collapse">
|
||||
{children}
|
||||
</table>
|
||||
</div>
|
||||
),
|
||||
thead: ({ children }) => (
|
||||
<thead className="bg-muted/50">{children}</thead>
|
||||
),
|
||||
th: ({ children }) => (
|
||||
<th className="px-4 py-2 text-left text-xs font-semibold text-foreground border-b border-border">
|
||||
{children}
|
||||
</th>
|
||||
),
|
||||
td: ({ children }) => (
|
||||
<td className="px-4 py-2 text-xs text-foreground/80 border-b border-border last:border-0">
|
||||
{children}
|
||||
</td>
|
||||
),
|
||||
hr: () => <hr className="my-6 border-border" />,
|
||||
a: ({ href, children }) => (
|
||||
<a
|
||||
href={href}
|
||||
className="text-primary underline underline-offset-2 hover:text-primary/80 transition-colors"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
),
|
||||
strong: ({ children }) => (
|
||||
<strong className="font-semibold text-foreground">
|
||||
{children}
|
||||
</strong>
|
||||
),
|
||||
em: ({ children }) => (
|
||||
<em className="italic text-foreground/80">{children}</em>
|
||||
),
|
||||
}}
|
||||
// ── Streaming mode ────────────────────────────────────────────────────
|
||||
if (isStreaming) {
|
||||
return (
|
||||
<div className="relative h-full flex flex-col overflow-hidden">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="flex-1 overflow-y-auto"
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
{isStreaming && (
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10 pb-32">
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
h1: ({ children }) => (
|
||||
<h1 className="text-[1.625rem] font-bold tracking-tight mb-3 mt-8 first:mt-0 text-foreground border-b border-border pb-2 leading-tight">
|
||||
{children}
|
||||
</h1>
|
||||
),
|
||||
h2: ({ children }) => (
|
||||
<h2 className="text-[1.25rem] font-semibold tracking-tight mb-2.5 mt-7 text-foreground leading-snug">
|
||||
{children}
|
||||
</h2>
|
||||
),
|
||||
h3: ({ children }) => (
|
||||
<h3 className="text-[1.0625rem] font-semibold tracking-tight mb-2 mt-5 text-foreground leading-snug">
|
||||
{children}
|
||||
</h3>
|
||||
),
|
||||
p: ({ children }) => (
|
||||
<p className="mb-4 leading-[1.8] text-[15px] text-foreground indent-[2em]">
|
||||
{children}
|
||||
</p>
|
||||
),
|
||||
ul: ({ children }) => (
|
||||
<ul className="mb-4 ml-6 list-disc space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ul>
|
||||
),
|
||||
ol: ({ children }) => (
|
||||
<ol className="mb-4 ml-6 list-decimal space-y-1.5 text-[15px] text-foreground">
|
||||
{children}
|
||||
</ol>
|
||||
),
|
||||
li: ({ children }) => <li className="leading-7">{children}</li>,
|
||||
blockquote: ({ children }) => (
|
||||
<blockquote className="border-l-4 border-primary/30 pl-4 my-4 text-muted-foreground italic text-sm">
|
||||
{children}
|
||||
</blockquote>
|
||||
),
|
||||
pre: ({ children }) => (
|
||||
<pre className="mb-4 rounded-md bg-muted/60 overflow-x-auto">
|
||||
{children}
|
||||
</pre>
|
||||
),
|
||||
code: ({ className, children }) => {
|
||||
const isBlock = Boolean(className?.startsWith("language-"));
|
||||
return isBlock ? (
|
||||
<code className="block p-4 text-xs font-mono text-foreground leading-relaxed">
|
||||
{children}
|
||||
</code>
|
||||
) : (
|
||||
<code className="bg-muted px-1.5 py-0.5 rounded text-xs font-mono text-foreground">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
},
|
||||
}}
|
||||
>
|
||||
{content.fullMarkdown}
|
||||
</ReactMarkdown>
|
||||
<span className="inline-block w-0.5 h-4 bg-primary animate-pulse align-middle ml-0.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showScrollBtn && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center z-10 pointer-events-none">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pointer-events-auto shadow-md gap-1.5"
|
||||
onClick={handleScrollToBottom}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
回到底部
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Edit mode ─────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="h-full flex flex-col overflow-hidden">
|
||||
<WritingToolbar editor={editor} />
|
||||
|
||||
<div className="flex-1 flex min-h-0 overflow-hidden">
|
||||
{/* 左侧目录栏(仿豆包):文档标题层级导航 */}
|
||||
<DraftOutlineSidebar editor={editor} scrollEl={docScrollEl} />
|
||||
|
||||
{/* 文档滚动区 */}
|
||||
<div ref={setDocScrollEl} className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-2xl mx-auto px-10 pt-10">
|
||||
{editor && (
|
||||
<>
|
||||
<WritingBubbleMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||
<WritingParagraphMenu editor={editor} onAiRewrite={openAiMenu} />
|
||||
</>
|
||||
)}
|
||||
<TiptapEditor editor={editor} className="tiptap-writing-editor" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Phase: AI action menu */}
|
||||
{phase === "menu" && (
|
||||
<WritingAiMenu
|
||||
pos={panelPos}
|
||||
status={status}
|
||||
error={error}
|
||||
onAction={handleAction}
|
||||
onAbort={reset}
|
||||
onRetry={handleRetry}
|
||||
onClose={closeAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Phase: result preview */}
|
||||
{phase === "preview" && (
|
||||
<WritingRewritePreview
|
||||
pos={panelPos}
|
||||
status={status}
|
||||
originalText={writingSelection?.text ?? ""}
|
||||
result={result}
|
||||
thinking={thinking}
|
||||
error={error}
|
||||
action={lastActionRef.current?.action ?? "polish"}
|
||||
tone={lastActionRef.current?.tone}
|
||||
onReplace={handleReplace}
|
||||
onInsert={handleInsert}
|
||||
onRetry={handleRetry}
|
||||
onAbort={reset}
|
||||
onClose={closeAll}
|
||||
onFollowUp={handleFollowUp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user