This commit is contained in:
xuhd committed 2026-05-19 09:40:19 +08:00
commit 070cd07199
372 files changed
+86240 -4969

No files matched your search

-2
View File
@@ -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,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,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)
File diff suppressed because it is too large. Load diff
+256 -204
View File
@@ -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> 标签,显示时剥离)
请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。
```
+109
View File
@@ -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
+188
View File
@@ -0,0 +1,188 @@
# 素材专家检索 —— 内网接口对接说明
本文档说明 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 自动重载),改完一般无需重启后端。
---
## 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`。
若内网原文详情页地址有变,只改这一个常量即可。
---
## 5. 联调上线步骤
1. 确认后端机器能访问 `10.106.57.180:7863`。
2. 把 `config.yaml` 的 `ai_writing.search_provider` 改为 `intranet`。
3. 后端配置热加载生效(或重启后端)。
4. 进入 AI 智能写作页面,发起一次写作,观察素材收集专家是否返回内网素材。
5. 校验素材卡片的标题/来源/时间显示正确,「查看原文」能跳到内网详情页。
6. 如检索为空,按 [4.1](#41-内网检索返回空--调不通) 排查。
+689 -1
View File
@@ -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
View File
@@ -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"
}
+3
View File
@@ -13,6 +13,7 @@ import { HashRouter, useLocation } from "react-router-dom";
import LandingPage from "./pages/LandingPage";
import LoginPage from "./pages/LoginPage";
import PageRoutes from "./pages/PageRoutes";
import PublicSharePage from "./pages/PublicSharePage";
import { NotFoundPage } from "./pages/NotFoundPage";
function AppProviders({ children }: { children: React.ReactNode }) {
@@ -47,6 +48,8 @@ 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 />} />
<Route path="/page/*" element={<PageRoutes />} />
<Route path="*" element={<NotFoundPage />} />
</Routes>
@@ -94,3 +94,7 @@ export function useArtifacts() {
}
return context;
}
export function useArtifactsOptional(): ArtifactsContextType | null {
return useContext(ArtifactsContext) ?? null;
}
@@ -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>(
@@ -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,33 @@ import { cn } from "@/lib/utils";
import { CopyButton } from "../copy-button";
import { ThreadContext } from "./context";
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,
@@ -101,6 +129,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 +138,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 +162,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 +170,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 +183,7 @@ function MessageContent_({
return <a {...props} href={href} />;
},
}),
[threadId],
[threadId, resolveArtifact],
);
const rawContent = extractContentFromMessage(message);
@@ -173,7 +203,7 @@ function MessageContent_({
const contentToDisplay = useMemo(() => {
if (isHuman) {
return rawContent ? stripUploadedFilesTag(rawContent) : "";
return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : "";
}
return rawContent ?? "";
}, [rawContent, isHuman]);
@@ -346,6 +376,7 @@ function RichFileCard({
threadId: string;
}) {
const { t } = useI18n();
const resolveArtifact = useArtifactResolver(threadId);
const isUploading = file.status === "uploading";
const isImage = isImageFile(file.filename);
@@ -378,7 +409,7 @@ function RichFileCard({
if (!file.path) return null;
const fileUrl = resolveArtifactURL(file.path, threadId);
const fileUrl = resolveArtifact(file.path);
if (isImage) {
return (
@@ -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";
}
@@ -1,10 +1,24 @@
"use client";
import { Loader2, PencilIcon } from "lucide-react";
import {
ChevronDownIcon,
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 {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from "@/components/ui/collapsible";
import {
Dialog,
DialogContent,
@@ -23,15 +37,18 @@ import {
} from "@/core/skills/hooks";
import type { Skill } from "@/core/skills/type";
import { reasoningPlugins } from "@/core/streamdown";
import { cn } from "@/lib/utils";
/**
* Whether the current user may edit a skill's detail text.
* Whether the current user may edit a skill's detail text and view its raw
* SKILL.md source.
*
* Admins can edit any skill; the publisher (owner of a custom skill) can edit
* Admins qualify for any skill; the publisher (owner of a custom skill) for
* their own. Built-in skills have no human owner, so only admins qualify.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail`.
* Mirrors the backend authz in `PUT /api/skills/{name}/detail` and
* `GET /api/skills/{name}/content`.
*/
function canEditSkillDetail(skill: Skill | null | undefined): boolean {
function canManageSkill(skill: Skill | null | undefined): boolean {
if (!skill) return false;
const auth = getStoredAuth();
if (!auth) return false;
@@ -45,8 +62,9 @@ function canEditSkillDetail(skill: Skill | null | undefined): boolean {
/**
* Reusable skill detail dialog. Given a skill name it fetches the skill
* metadata (including the admin/owner-authored `detail`) and the raw SKILL.md
* source, and lets authorized users edit the detail inline.
* metadata (including the admin/owner-authored `detail`) and lets authorized
* users edit the detail inline. The raw SKILL.md source is shown only to the
* publisher or an admin — everyone else sees just the description and detail.
*/
export function SkillDetailDialog({
skillName,
@@ -62,17 +80,22 @@ export function SkillDetailDialog({
// Only fetch while the dialog is open to avoid background requests.
const activeName = open ? skillName : null;
const { data: skill, isLoading: isSkillLoading } = useSkill(activeName);
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(activeName);
const setDetail = useSetSkillDetail();
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState("");
const [showSource, setShowSource] = useState(false);
const canManage = useMemo(() => canManageSkill(skill), [skill]);
// The SKILL.md source endpoint is publisher/admin-only — only request it
// when the viewer is actually allowed to see it.
const {
data: contentData,
isLoading: isContentLoading,
error: contentError,
} = useSkillContent(open && canManage ? skillName : null);
// Reset transient UI whenever the dialog closes or switches to another skill.
useEffect(() => {
if (!open) {
@@ -81,8 +104,6 @@ export function SkillDetailDialog({
}
}, [open, skillName]);
const canEdit = useMemo(() => canEditSkillDetail(skill), [skill]);
function startEditing() {
setDraft(skill?.detail ?? "");
setEditing(true);
@@ -101,22 +122,54 @@ export function SkillDetailDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="flex max-h-[80vh] flex-col gap-3 sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{skill?.name ?? skillName ?? ""}</DialogTitle>
<DialogDescription>{skill?.description}</DialogDescription>
<DialogContent className="flex max-h-[85vh] flex-col gap-0 overflow-hidden p-0 sm:max-w-2xl">
{/* Header */}
<DialogHeader className="space-y-0 border-b px-6 py-4 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-sky-700 dark:text-sky-300 text-xs"
>
<GlobeIcon className="size-3" />
{tx.badgePublished}
</Badge>
)}
</div>
<DialogDescription className="mt-1 text-sm leading-relaxed">
{skill?.description || "—"}
</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="min-h-0 flex-1 space-y-4 overflow-auto pr-1">
{/* Body */}
<div className="min-h-0 flex-1 space-y-5 overflow-auto px-6 py-5">
{/* Detail — human-authored long-form introduction */}
<section className="space-y-2">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium">{tx.detailSectionTitle}</h3>
{canEdit && !editing && (
<h3 className="text-foreground/90 flex items-center gap-1.5 text-sm font-semibold">
<FileTextIcon className="text-muted-foreground size-4" />
{tx.detailSectionTitle}
</h3>
{canManage && !editing && (
<Button
size="sm"
variant="ghost"
className="h-7"
className="text-muted-foreground hover:text-foreground h-7 px-2"
onClick={startEditing}
>
<PencilIcon className="size-3.5" />
@@ -124,15 +177,17 @@ export function SkillDetailDialog({
</Button>
)}
</div>
{editing ? (
<div className="space-y-2">
<Textarea
value={draft}
onChange={(e) => setDraft(e.currentTarget.value)}
placeholder={tx.detailPlaceholder}
className="max-h-[320px] min-h-40 resize-y overflow-y-auto text-sm"
className="max-h-[320px] min-h-44 resize-y overflow-y-auto text-sm leading-relaxed"
autoFocus
/>
<div className="flex justify-end gap-2">
<div className="flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
@@ -154,12 +209,12 @@ export function SkillDetailDialog({
</div>
</div>
) : isSkillLoading ? (
<div className="text-muted-foreground flex items-center gap-2 text-sm">
<div className="text-muted-foreground flex items-center gap-2 rounded-lg border border-dashed px-4 py-8 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : skill?.detail ? (
<div className="bg-muted/40 rounded-md border p-3 text-sm">
<div className="bg-muted/30 rounded-lg border p-4 text-sm leading-relaxed">
<MarkdownContent
content={skill.detail}
isLoading={false}
@@ -168,44 +223,55 @@ export function SkillDetailDialog({
/>
</div>
) : (
<p className="text-muted-foreground text-sm">
{tx.detailEmpty}
{!canEdit ? ` ${tx.detailAdminOnlyHint}` : ""}
</p>
)}
</section>
{/* SKILL.md source — collapsed by default */}
<section className="space-y-2">
<button
type="button"
className="text-muted-foreground hover:text-foreground text-sm font-medium"
onClick={() => setShowSource((v) => !v)}
>
{showSource ? "▾ " : "▸ "}
{tx.contentSectionTitle}
</button>
{showSource && (
<div className="bg-muted/40 overflow-auto rounded-md border">
{isContentLoading ? (
<div className="text-muted-foreground flex items-center justify-center gap-2 py-10 text-sm">
<Loader2 className="size-4 animate-spin" />
{t.common.loading}
</div>
) : contentError ? (
<div className="text-destructive p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/90 max-h-[320px] overflow-auto whitespace-pre-wrap wrap-break-word p-4 font-mono text-xs leading-relaxed">
{contentData?.content}
</pre>
<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">{tx.detailAdminOnlyHint}</p>
)}
</div>
)}
</section>
{/* SKILL.md source — publisher / admin only, collapsed by default */}
{canManage && (
<section>
<Collapsible open={showSource} onOpenChange={setShowSource}>
<CollapsibleTrigger className="bg-muted/30 hover:bg-muted/60 flex w-full items-center justify-between rounded-lg border px-4 py-2.5 text-sm font-semibold transition-colors">
<span className="flex items-center gap-1.5">
<FileCode2Icon className="text-muted-foreground size-4" />
{tx.contentSectionTitle}
</span>
<ChevronDownIcon
className={cn(
"text-muted-foreground size-4 transition-transform",
showSource && "rotate-180",
)}
/>
</CollapsibleTrigger>
<CollapsibleContent className="pt-2">
<div className="bg-muted/50 max-h-[340px] overflow-auto rounded-lg border">
{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 p-4 text-sm">
{contentError instanceof Error
? contentError.message
: String(contentError)}
</div>
) : (
<pre className="text-foreground/80 p-4 font-mono text-xs leading-relaxed break-words whitespace-pre-wrap">
{contentData?.content}
</pre>
)}
</div>
</CollapsibleContent>
</Collapsible>
</section>
)}
</div>
</DialogContent>
</Dialog>
@@ -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>
);
}
@@ -13,7 +13,8 @@ import {
} from "@/components/ui/sidebar";
import { getStoredAuth } from "@/core/auth";
import { useI18n } from "@/core/i18n/hooks";
import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
// import { BotIcon, BrainIcon, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
import { BotIcon, CalendarClock, ListChecks, MessageSquarePlus, MessagesSquare, SparklesIcon, UsersIcon } from "lucide-react";
export function WorkspaceNavChatList() {
const { t } = useI18n();
@@ -88,7 +89,7 @@ export function WorkspaceNavChatList() {
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname.startsWith("/page/workspace/admin")}
isActive={pathname === "/page/workspace/admin/users"}
asChild
>
<Link className="text-sidebar-foreground" href="/page/workspace/admin/users">
@@ -98,6 +99,22 @@ export function WorkspaceNavChatList() {
</SidebarMenuButton>
</SidebarMenuItem>
)}
{isAdmin && (
<SidebarMenuItem>
<SidebarMenuButton
isActive={pathname === "/page/workspace/admin/scheduled-tasks"}
asChild
>
<Link
className="text-sidebar-foreground"
href="/page/workspace/admin/scheduled-tasks"
>
<CalendarClock />
<span>定时任务管理</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
)}
</SidebarMenu>
</SidebarGroup>
);
@@ -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;
}
+30
View File
@@ -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(
@@ -250,6 +250,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",
@@ -194,6 +194,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;
@@ -238,6 +238,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 助手",
+45 -38
View File
@@ -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";
@@ -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,
@@ -57,12 +63,34 @@ export function useScheduledTaskRuns(taskId?: string | null) {
});
}
/** 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 +102,64 @@ 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,
});
}
/** 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,
});
}
/** 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;
};
+137
View File
@@ -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"] });
},
});
}
+4
View File
@@ -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,73 @@
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}`);
}
/** 保存会话的完整对话时间线,供历史回显出与实时对话一致的富时间线。 */
export async function saveAIWritingTranscript(
id: string,
transcript: AIWritingTranscript,
): Promise<void> {
const resp = await apiFetch(`${BASE()}/${id}/transcript`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ transcript }),
});
if (!resp.ok) throw new Error(`保存对话时间线失败: ${resp.status}`);
}
@@ -0,0 +1,208 @@
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 ?? '',
}),
});
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);
// 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,
}),
});
if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`);
}
+106
View File
@@ -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,201 @@
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 [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));
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
articleType,
targetAudience: audience.trim() || '普通读者',
wordCountTarget: wordCount,
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">目标读者</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,448 @@
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_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>
{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,113 @@
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>
);
}
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} />
{/* 正文内容 */}
<p className="text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap m-0">
{sec.text}
{sec.sectionIndex === activeSectionIdx && !sec.isDone && <TypewriterCursor />}
</p>
</div>
))}
<div ref={bottomRef} />
</div>
<style>{`
@keyframes blink {
0%, 100% { opacity: 1; }
50% { opacity: 0; }
}
`}</style>
</div>
);
}
@@ -0,0 +1,549 @@
import { useRef, useState } from 'react';
import {
PauseCircle, CheckCircle2, RotateCcw, Search,
RefreshCw, PenLine, ArrowRight, ShieldAlert, ClipboardEdit, SendHorizonal,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
ProgressEvent, UserInterventionPayload,
} 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 === '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);
await onSubmit({ action: 're_outline', outlineFeedback: text });
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>
);
}
@@ -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";
@@ -1,18 +1,171 @@
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 { 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);
// ── 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 +175,146 @@ 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 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>
{/* 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>
);
}
@@ -0,0 +1,288 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
Sparkles,
Wand2,
Expand,
Shrink,
Mic2,
AlertCircle,
RefreshCw,
ChevronRight,
X,
Square,
} from "lucide-react";
import { cn } from "@/lib/utils";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
// ── Types ─────────────────────────────────────────────────────────────────────
interface MenuPos {
x: number;
y: number;
}
// ── Tone options ───────────────────────────────────────────────────────────────
const TONE_OPTIONS: { value: RewriteTone; label: string }[] = [
{ value: "professional", label: "更专业" },
{ value: "casual", label: "更口语化" },
{ value: "readable", label: "更易读" },
{ value: "academic", label: "更学术" },
{ value: "expressive", label: "更有文采" },
];
// ── Action rows ────────────────────────────────────────────────────────────────
const ACTION_ROWS: {
action: RewriteAction;
label: string;
icon: React.ElementType;
}[] = [
{ action: "polish", label: "润色", icon: Wand2 },
{ action: "expand", label: "扩写", icon: Expand },
{ action: "shorten", label: "缩写", icon: Shrink },
{ action: "tone", label: "改语气", icon: Mic2 },
];
// ── Main component ─────────────────────────────────────────────────────────────
interface WritingAiMenuProps {
pos: MenuPos;
status: RewriteStatus;
error: string | null;
onAction: (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => void;
onAbort: () => void;
onRetry: () => void;
onClose: () => void;
}
export function WritingAiMenu({
pos,
status,
error,
onAction,
onAbort,
onRetry,
onClose,
}: WritingAiMenuProps) {
const [toneOpen, setToneOpen] = useState(false);
const [instruction, setInstruction] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// autoFocus is unreliable when the menu opens during a mousedown event sequence;
// explicit focus via setTimeout(0) runs after the browser finishes that sequence.
useEffect(() => {
const id = setTimeout(() => inputRef.current?.focus(), 0);
return () => clearTimeout(id);
}, []);
const isLoading = status === "loading";
const isError = status === "error";
// Measure actual height after render and compute safe position before browser paint
const menuWidth = 240;
const containerRef = useRef<HTMLDivElement>(null);
const [computedPos, setComputedPos] = useState<{ left: number; top: number } | null>(null);
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const h = el.getBoundingClientRect().height;
const safeX = Math.min(Math.max(pos.x, 8), window.innerWidth - menuWidth - 8);
const spaceBelow = window.innerHeight - pos.y - 8;
const spaceAbove = pos.y - 8;
let safeY: number;
if (spaceBelow >= h) {
safeY = pos.y + 4;
} else if (spaceAbove >= h) {
safeY = pos.y - h - 4;
} else if (spaceBelow >= spaceAbove) {
safeY = Math.max(8, window.innerHeight - h - 8);
} else {
safeY = 8;
}
setComputedPos({ left: safeX, top: safeY });
}, [pos.x, pos.y]);
const fireAction = (action: RewriteAction, tone?: RewriteTone) => {
onAction(action, tone, instruction.trim() || undefined);
};
const handleInputEnter = () => {
const trimmed = instruction.trim();
onAction("polish", undefined, trimmed || undefined);
};
return createPortal(
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
ref={containerRef}
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
top: computedPos?.top ?? (pos.y + 4),
width: menuWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="rounded-xl border bg-popover shadow-lg animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/30">
<span className="flex items-center gap-1.5 text-xs font-semibold text-foreground">
<Sparkles className="h-3.5 w-3.5 text-amber-500" />
AI 改写
</span>
<button
type="button"
onClick={onClose}
className="rounded p-0.5 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{/* ── Loading state ──────────────────────────────────────── */}
{isLoading && (
<div className="flex flex-col items-center gap-4 px-4 py-6">
{/* Bouncing dots */}
<div className="flex items-center gap-2">
{[0, 160, 320].map((delay) => (
<span
key={delay}
className="h-1.5 w-1.5 rounded-full bg-primary animate-bouncing"
style={{ animationDelay: `${delay}ms` }}
/>
))}
<span className="ml-1 text-xs text-muted-foreground">AI 正在生成…</span>
</div>
{/* Stop button — breathing animation */}
<button
type="button"
onClick={onAbort}
className={cn(
"animate-breathe-stop",
"flex items-center gap-1.5 px-5 py-2 text-xs font-medium rounded-lg",
"border border-red-300/70 bg-red-50/60 text-red-600",
"dark:border-red-800/50 dark:bg-red-950/20 dark:text-red-400",
"hover:bg-red-100/90 dark:hover:bg-red-950/40 transition-colors",
)}
>
<Square className="h-3 w-3 fill-current" />
停止生成
</button>
</div>
)}
{/* ── Error state ─────────────────────────────────────────── */}
{isError && (
<div className="px-3 py-3 space-y-2">
<p className="flex items-center gap-1.5 text-xs text-destructive">
<AlertCircle className="h-3.5 w-3.5 shrink-0" />
{error ?? "改写失败"}
</p>
<button
type="button"
onClick={onRetry}
className="flex items-center gap-1.5 text-xs text-primary hover:underline"
>
<RefreshCw className="h-3 w-3" />
重试
</button>
</div>
)}
{/* ── Idle state: input + action list ─────────────────────── */}
{!isLoading && !isError && (
<>
{/* Custom instruction input */}
<div className="px-2.5 pt-2.5 pb-2">
<input
ref={inputRef}
type="text"
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleInputEnter();
}
}}
placeholder="告诉 AI 你想要什么…"
className={cn(
"w-full text-xs rounded-md px-2.5 py-1.5 outline-none transition-colors",
"bg-muted/50 border border-input focus:border-primary/60",
"placeholder:text-muted-foreground/50",
)}
/>
</div>
{/* Divider */}
<div className="border-t border-border/50 mx-1" />
{/* Preset actions */}
<div className="p-1 space-y-0.5">
{ACTION_ROWS.map(({ action, label, icon: Icon }) => {
const isTone = action === "tone";
const isTonesOpen = isTone && toneOpen;
return (
<div key={action}>
<button
type="button"
onClick={() => {
if (isTone) {
setToneOpen((v) => !v);
} else {
fireAction(action);
}
}}
className={cn(
"flex w-full items-center gap-2 px-2.5 py-1.5 text-xs rounded-md transition-colors text-left",
"hover:bg-accent hover:text-accent-foreground",
isTonesOpen && "bg-accent text-accent-foreground",
)}
>
<Icon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="flex-1">{label}</span>
{isTone && (
<ChevronRight
className={cn(
"h-3.5 w-3.5 text-muted-foreground transition-transform duration-200",
isTonesOpen && "rotate-90",
)}
/>
)}
</button>
{/* Tone sub-list */}
{isTonesOpen && (
<div className="ml-5 border-l pl-2 my-0.5 space-y-0.5">
{TONE_OPTIONS.map(({ value, label: toneLabel }) => (
<button
key={value}
type="button"
onClick={() => fireAction("tone", value)}
className="flex w-full items-center px-2 py-1.5 text-xs rounded-md hover:bg-accent hover:text-accent-foreground transition-colors"
>
{toneLabel}
</button>
))}
</div>
)}
</div>
);
})}
</div>
</>
)}
</div>
</>,
document.body,
);
}
@@ -0,0 +1,220 @@
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/react";
import {
Sparkles,
Bold,
Italic,
Underline,
Heading1,
Copy,
Check,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
// ── Types ────────────────────────────────────────────────────────────────────
interface MenuPosition {
x: number;
y: number; // top of selection
bottom: number; // bottom of selection (used to flip menu below when near top)
}
// ── Sub-components ───────────────────────────────────────────────────────────
interface BubbleButtonProps {
onClick: () => void;
active?: boolean;
className?: string;
children: React.ReactNode;
}
function BubbleButton({ onClick, active, className, children }: BubbleButtonProps) {
return (
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // keep editor focused
onClick();
}}
className={cn(
"inline-flex h-7 items-center gap-1 px-2 text-xs font-medium rounded transition-colors",
"hover:bg-black/8 dark:hover:bg-white/10",
active && "bg-black/10 dark:bg-white/15",
className,
)}
>
{children}
</button>
);
}
function BubbleSep() {
return <div className="w-px h-4 bg-black/15 dark:bg-white/25 mx-0.5 shrink-0" />;
}
// ── Main component ───────────────────────────────────────────────────────────
interface WritingBubbleMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingBubbleMenu({ editor, onAiRewrite }: WritingBubbleMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [position, setPosition] = useState<MenuPosition | null>(null);
const [copied, setCopied] = useState(false);
const menuRef = useRef<HTMLDivElement>(null);
const [computedStyle, setComputedStyle] = useState<React.CSSProperties>({
position: "fixed", left: 0, top: 0, zIndex: 9999, visibility: "hidden",
});
useLayoutEffect(() => {
if (!position || !menuRef.current) return;
const { width: mw, height: mh } = menuRef.current.getBoundingClientRect();
// X: center on selection, clamp to viewport
const rawLeft = position.x - mw / 2;
const safeLeft = Math.max(8, Math.min(rawLeft, window.innerWidth - mw - 8));
// Y: prefer above; flip below if too close to top
let safeTop: number;
if (position.y - mh - 8 >= 8) {
safeTop = position.y - mh - 8;
} else {
safeTop = Math.min(position.bottom + 8, window.innerHeight - mh - 8);
}
setComputedStyle({ position: "fixed", left: safeLeft, top: safeTop, zIndex: 9999, visibility: "visible" });
}, [position]);
// Track selection → compute menu position
useEffect(() => {
const updatePosition = () => {
const { from, to, empty } = editor.state.selection;
if (empty || editor.isActive("codeBlock")) {
setPosition(null);
return;
}
// Use the native DOM selection for pixel coordinates
const domSel = window.getSelection();
if (!domSel || domSel.rangeCount === 0) {
setPosition(null);
return;
}
const range = domSel.getRangeAt(0);
const rect = range.getBoundingClientRect();
if (!rect.width && !rect.height) {
setPosition(null);
return;
}
// Store selection for AI rewrite
const text = editor.state.doc.textBetween(from, to, "\n");
if (text.trim()) {
setWritingSelection({ from, to, text });
}
setPosition({
x: rect.left + rect.width / 2,
y: rect.top,
bottom: rect.bottom,
});
};
editor.on("selectionUpdate", updatePosition);
editor.on("blur", () => setPosition(null));
return () => {
editor.off("selectionUpdate", updatePosition);
editor.off("blur", () => setPosition(null));
};
}, [editor, setWritingSelection]);
const handleAiRewrite = useCallback(() => {
const { from, to, empty } = editor.state.selection;
if (!empty) {
editor.commands.setAiHighlight(from, to);
}
onAiRewrite();
}, [onAiRewrite, editor]);
const handleCopy = useCallback(() => {
const { from, to } = editor.state.selection;
const text = editor.state.doc.textBetween(from, to, "\n");
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 1500);
});
}, [editor]);
if (!position) return null;
return createPortal(
<div
ref={menuRef}
style={computedStyle}
className="animate-in fade-in zoom-in-95 duration-100"
>
<div className="flex items-center gap-0 rounded-lg px-1 py-1 shadow-lg ring-1 ring-black/8 bg-popover text-foreground dark:bg-neutral-900 dark:ring-white/10">
{/* AI 改写 — primary */}
<BubbleButton
onClick={handleAiRewrite}
className="text-amber-500 hover:text-amber-600 font-semibold"
>
<Sparkles className="h-3.5 w-3.5" />
AI 改写
</BubbleButton>
<BubbleSep />
{/* Inline format */}
<BubbleButton
onClick={() => editor.chain().focus().toggleBold().run()}
active={editor.isActive("bold")}
>
<Bold className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleItalic().run()}
active={editor.isActive("italic")}
>
<Italic className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleButton
onClick={() => editor.chain().focus().toggleUnderline().run()}
active={editor.isActive("underline")}
>
<Underline className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Heading */}
<BubbleButton
onClick={() =>
editor.chain().focus().toggleHeading({ level: 1 }).run()
}
active={editor.isActive("heading", { level: 1 })}
>
<Heading1 className="h-3.5 w-3.5" />
</BubbleButton>
<BubbleSep />
{/* Copy */}
<BubbleButton onClick={handleCopy}>
{copied ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</BubbleButton>
</div>
</div>,
document.body,
);
}
@@ -0,0 +1,163 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import type { Editor } from "@tiptap/react";
import { Sparkles } from "lucide-react";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
const REWRITABLE_TYPES = new Set(["paragraph", "heading", "blockquote"]);
const BTN_SIZE = 24;
const BTN_GAP = 6; // gap between button right edge and text left edge
// Extra zone around the button that still counts as "near" (bridges the gap)
const HOVER_MARGIN = BTN_GAP + 8;
// Delay before hiding the button (ms) — long enough to cross the gap at slow speed
const HIDE_DELAY = 380;
interface HoverState {
top: number;
left: number;
height: number;
nodeFrom: number;
nodeTo: number;
text: string;
}
interface WritingParagraphMenuProps {
editor: Editor;
onAiRewrite: () => void;
}
export function WritingParagraphMenu({ editor, onAiRewrite }: WritingParagraphMenuProps) {
const { setWritingSelection } = useCanvasUIContext();
const [hover, setHover] = useState<HoverState | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Keep a ref in sync so the document-level handler always reads fresh state
const hoverRef = useRef<HoverState | null>(null);
hoverRef.current = hover;
const clearHideTimer = useCallback(() => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
}, []);
const scheduleHide = useCallback(() => {
clearHideTimer();
hideTimerRef.current = setTimeout(() => setHover(null), HIDE_DELAY);
}, [clearHideTimer]);
// Document-level mousemove: cancel hide when cursor is in the button's vicinity.
// This bridges the gap between the editor's text edge and the button.
useEffect(() => {
const handleDocMove = (e: MouseEvent) => {
const h = hoverRef.current;
if (!h) return;
const btnLeft = h.left - BTN_SIZE - BTN_GAP;
const btnTop = h.top + h.height / 2 - BTN_SIZE / 2;
const inZone =
e.clientX >= btnLeft - HOVER_MARGIN &&
e.clientX <= h.left + HOVER_MARGIN && // extend slightly into text side
e.clientY >= btnTop - HOVER_MARGIN &&
e.clientY <= btnTop + BTN_SIZE + HOVER_MARGIN;
if (inZone) clearHideTimer();
};
document.addEventListener("mousemove", handleDocMove);
return () => document.removeEventListener("mousemove", handleDocMove);
}, [clearHideTimer]);
useEffect(() => {
const editorDom = editor.view.dom as HTMLElement;
const handleMouseMove = (e: MouseEvent) => {
const pos = editor.view.posAtCoords({ left: e.clientX, top: e.clientY });
if (!pos) { scheduleHide(); return; }
const $pos = editor.state.doc.resolve(pos.pos);
const depth = Math.min(1, $pos.depth);
if (depth === 0) { scheduleHide(); return; }
const nodeStart = $pos.before(depth);
const node = editor.state.doc.nodeAt(nodeStart);
if (!node || !REWRITABLE_TYPES.has(node.type.name)) {
scheduleHide();
return;
}
const text = node.textContent.trim();
if (!text) { scheduleHide(); return; }
const domNode = editor.view.nodeDOM(nodeStart);
if (!domNode || !(domNode instanceof HTMLElement)) {
scheduleHide();
return;
}
const rect = domNode.getBoundingClientRect();
clearHideTimer();
setHover({
top: rect.top,
left: rect.left,
height: rect.height,
nodeFrom: nodeStart + 1,
nodeTo: nodeStart + node.nodeSize - 1,
text,
});
};
editorDom.addEventListener("mousemove", handleMouseMove);
editorDom.addEventListener("mouseleave", scheduleHide);
return () => {
editorDom.removeEventListener("mousemove", handleMouseMove);
editorDom.removeEventListener("mouseleave", scheduleHide);
clearHideTimer();
};
}, [editor, clearHideTimer, scheduleHide]);
const handleClick = useCallback(() => {
if (!hover) return;
editor.chain().focus().setTextSelection({ from: hover.nodeFrom, to: hover.nodeTo }).run();
editor.commands.setAiHighlight(hover.nodeFrom, hover.nodeTo);
setWritingSelection({ from: hover.nodeFrom, to: hover.nodeTo, text: hover.text });
onAiRewrite();
}, [hover, editor, setWritingSelection, onAiRewrite]);
if (!hover) return null;
const btnTop = hover.top + hover.height / 2 - BTN_SIZE / 2;
const btnLeft = Math.max(4, hover.left - BTN_SIZE - BTN_GAP);
return createPortal(
<button
type="button"
style={{
position: "fixed",
top: btnTop,
left: btnLeft,
width: BTN_SIZE,
height: BTN_SIZE,
zIndex: 9998,
}}
onMouseEnter={clearHideTimer}
onMouseLeave={scheduleHide}
onClick={handleClick}
className={cn(
"inline-flex items-center justify-center rounded-md",
"bg-background border border-border shadow-sm",
"text-muted-foreground hover:text-amber-500 hover:border-amber-400 hover:bg-amber-50 dark:hover:bg-amber-950/30",
"transition-all duration-150",
"animate-in fade-in zoom-in-90 duration-100",
)}
title="AI 改写此段落"
>
<Sparkles className="h-3.5 w-3.5" />
</button>,
document.body,
);
}
@@ -0,0 +1,307 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import {
RefreshCw, X, ArrowRightLeft, ArrowDownToLine,
Loader2, ChevronDown, Brain, SendHorizontal, Square,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button";
import type { RewriteAction, RewriteTone } from "../../api/rewrite";
import type { RewriteStatus } from "../../hooks/useWritingRewrite";
const ACTION_LABEL: Record<RewriteAction, string> = {
polish: "润色",
expand: "扩写",
shorten: "缩写",
tone: "改语气",
};
const TONE_LABEL: Record<RewriteTone, string> = {
professional: "更专业",
casual: "更口语化",
readable: "更易读",
academic: "更学术",
expressive: "更有文采",
};
interface MenuPos { x: number; y: number }
interface WritingRewritePreviewProps {
pos: MenuPos;
status: RewriteStatus;
originalText: string;
result: string | null;
thinking: string | null;
error: string | null;
action: RewriteAction;
tone?: RewriteTone;
onReplace: () => void;
onInsert: () => void;
onRetry: () => void;
onAbort: () => void;
onClose: () => void;
onFollowUp: (instruction: string) => void;
}
export function WritingRewritePreview({
pos,
status,
result,
thinking,
error,
action,
tone,
onReplace,
onInsert,
onRetry,
onAbort,
onClose,
onFollowUp,
}: WritingRewritePreviewProps) {
const isLoading = status === "loading";
const isError = status === "error";
const isSuccess = status === "success" && result !== null;
const hasResult = result !== null && result.length > 0;
// ── Follow-up input ─────────────────────────────────────────────────────
const [followUpInput, setFollowUpInput] = useState("");
const followUpInputRef = useRef<HTMLInputElement>(null);
// Auto-focus the follow-up input when generation finishes
useEffect(() => {
if (status === "success") {
setTimeout(() => followUpInputRef.current?.focus(), 50);
}
}, [status]);
const handleFollowUpSubmit = () => {
const instruction = followUpInput.trim();
if (!instruction || isLoading) return;
setFollowUpInput("");
onFollowUp(instruction);
};
// ── Thinking expand / collapse ──────────────────────────────────────────
const [thinkingOpen, setThinkingOpen] = useState(false);
const prevStatusRef = useRef<RewriteStatus>(status);
// Auto-expand when thinking content first streams in during loading
useEffect(() => {
if (isLoading && thinking) setThinkingOpen(true);
}, [isLoading, thinking]);
// Auto-collapse when generation finishes (loading → success/error)
useEffect(() => {
const prev = prevStatusRef.current;
prevStatusRef.current = status;
if (prev === "loading" && status !== "loading") {
setThinkingOpen(false);
}
}, [status]);
// ── Position ────────────────────────────────────────────────────────────
const panelWidth = 480;
const [computedPos, setComputedPos] = useState<{
left: number;
top?: number;
bottom?: number;
maxHeight: number;
} | null>(null);
useLayoutEffect(() => {
const MARGIN = 8;
const GAP = 6;
const MIN_HEIGHT = 160;
const safeX = Math.min(Math.max(pos.x, MARGIN), window.innerWidth - panelWidth - MARGIN);
const spaceBelow = window.innerHeight - pos.y - MARGIN;
const spaceAbove = pos.y - MARGIN;
if (spaceBelow >= spaceAbove) {
// More space below — anchor top at selection bottom, grow downward
setComputedPos({
left: safeX,
top: pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceBelow - GAP),
});
} else {
// More space above — anchor bottom at selection top, grow upward
setComputedPos({
left: safeX,
bottom: window.innerHeight - pos.y + GAP,
maxHeight: Math.max(MIN_HEIGHT, spaceAbove - GAP),
});
}
}, [pos.x, pos.y]);
const actionLabel = tone
? `${ACTION_LABEL[action]} · ${TONE_LABEL[tone]}`
: ACTION_LABEL[action];
return createPortal(
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[9990]" onMouseDown={onClose} />
<div
style={{
position: "fixed",
left: computedPos?.left ?? pos.x,
...(computedPos?.bottom !== undefined
? { bottom: computedPos.bottom }
: { top: computedPos?.top ?? (pos.y + 6) }),
maxHeight: computedPos?.maxHeight,
width: panelWidth,
zIndex: 9999,
visibility: computedPos ? "visible" : "hidden",
}}
className="flex flex-col rounded-xl border bg-popover shadow-xl animate-in fade-in zoom-in-95 duration-100 overflow-hidden"
>
{/* Header */}
<div className="flex items-center justify-between px-3 py-2 border-b bg-muted/40">
<span className="text-xs font-semibold text-foreground">{actionLabel}</span>
<div className="flex items-center gap-1">
{isLoading && (
<button
type="button"
onClick={onAbort}
className="rounded p-1 hover:bg-red-100 dark:hover:bg-red-950/40 text-red-500 hover:text-red-600 transition-colors"
title="停止生成"
>
<Square className="h-3.5 w-3.5 fill-current" />
</button>
)}
<button
type="button"
onClick={onRetry}
disabled={isLoading}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground disabled:opacity-40 transition-colors"
title="重试"
>
<RefreshCw className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={onClose}
className="rounded p-1 hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Body */}
<div className="p-3 space-y-2 overflow-y-auto flex-1 min-h-0">
{/* Thinking — collapsible with animation */}
{thinking && (
<div className="rounded-md border border-amber-200/60 bg-amber-50/40 dark:border-amber-800/40 dark:bg-amber-950/20 overflow-hidden">
{/* Toggle button */}
<button
type="button"
onClick={() => setThinkingOpen((v) => !v)}
className="flex w-full items-center gap-1.5 px-3 py-1.5 text-[10px] font-medium text-amber-700 dark:text-amber-400 hover:bg-amber-100/40 dark:hover:bg-amber-900/20 transition-colors"
>
<Brain className="h-3 w-3 shrink-0" />
<span className="flex-1 text-left">思考过程</span>
{isLoading && (
<span className="text-[9px] text-amber-600/70 dark:text-amber-500/60 mr-1">
思考中…
</span>
)}
<ChevronDown
className={cn(
"h-3 w-3 transition-transform duration-300",
thinkingOpen && "rotate-180",
)}
/>
</button>
{/* Animated content — grid-rows trick for smooth height transition */}
<div
style={{ gridTemplateRows: thinkingOpen ? "1fr" : "0fr" }}
className="grid transition-[grid-template-rows] duration-300 ease-in-out"
>
<div className="overflow-hidden">
<div className="px-3 pb-2.5 pt-1.5 text-[11px] text-amber-800/80 dark:text-amber-300/70 leading-relaxed whitespace-pre-wrap border-t border-amber-200/40 dark:border-amber-800/30 max-h-44 overflow-y-auto">
{thinking}
{isLoading && (
<span className="inline-block w-0.5 h-3 bg-amber-500 animate-pulse align-middle ml-0.5" />
)}
</div>
</div>
</div>
</div>
)}
{/* Result area */}
{/* Loading — no text yet */}
{isLoading && !hasResult && (
<div className="flex items-center gap-2 rounded-md border border-dashed px-3 py-3.5 text-xs text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0" />
AI 正在生成…
</div>
)}
{/* Error */}
{isError && (
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive leading-relaxed">
{error ?? "改写失败,请重试"}
</div>
)}
{/* Result text — show while streaming and after success */}
{hasResult && (
<div className="rounded-md border border-primary/30 bg-primary/5 px-3 py-2 text-xs text-foreground leading-relaxed whitespace-pre-wrap">
{result}
{isLoading && (
<span className="inline-block w-0.5 h-3.5 bg-primary animate-pulse align-middle ml-0.5" />
)}
</div>
)}
</div>
{/* Footer actions — only after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-muted/20">
<Button size="sm" className="flex-1 h-7 text-xs gap-1.5" onClick={onReplace}>
<ArrowRightLeft className="h-3 w-3" />
替换原文
</Button>
<Button size="sm" variant="outline" className="flex-1 h-7 text-xs gap-1.5" onClick={onInsert}>
<ArrowDownToLine className="h-3 w-3" />
插入后面
</Button>
</div>
)}
{/* Follow-up chat input — shown after success */}
{isSuccess && (
<div className="flex items-center gap-2 px-3 py-2 border-t bg-background">
<input
ref={followUpInputRef}
type="text"
value={followUpInput}
onChange={(e) => setFollowUpInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleFollowUpSubmit();
}
}}
placeholder="继续优化,如:更简洁一些、语气更正式…"
className="flex-1 text-xs bg-muted/50 rounded-md px-2.5 py-1.5 outline-none border border-input focus:border-primary/50 transition-colors placeholder:text-muted-foreground/50"
/>
<button
type="button"
onClick={handleFollowUpSubmit}
disabled={!followUpInput.trim()}
className="shrink-0 rounded-md p-1.5 bg-primary text-primary-foreground hover:bg-primary/90 disabled:opacity-40 transition-colors"
>
<SendHorizontal className="h-3.5 w-3.5" />
</button>
</div>
)}
</div>
</>,
document.body,
);
}
@@ -0,0 +1,182 @@
import type { Editor } from "@tiptap/react";
import {
Bold,
Italic,
Underline,
Strikethrough,
Heading1,
Heading2,
Heading3,
List,
ListOrdered,
Quote,
Code,
Pilcrow,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Separator } from "@/components/ui/separator";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { CanvasModelSelector } from "../selectors/CanvasModelSelector";
interface ToolbarButtonProps {
onClick: () => void;
active?: boolean;
disabled?: boolean;
tooltip: string;
children: React.ReactNode;
}
function ToolbarButton({ onClick, active, disabled, tooltip, children }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
onMouseDown={(e) => {
e.preventDefault(); // prevent editor losing focus
onClick();
}}
disabled={disabled}
className={cn(
"inline-flex h-7 w-7 items-center justify-center rounded-md text-sm transition-colors",
"hover:bg-accent hover:text-accent-foreground",
"disabled:pointer-events-none disabled:opacity-40",
active && "bg-accent text-accent-foreground font-semibold",
)}
>
{children}
</button>
</TooltipTrigger>
<TooltipContent side="bottom" className="text-xs">
{tooltip}
</TooltipContent>
</Tooltip>
);
}
interface WritingToolbarProps {
editor: Editor | null;
}
export function WritingToolbar({ editor }: WritingToolbarProps) {
if (!editor) return null;
return (
<div className="flex items-center gap-0.5 px-3 py-1.5 border-b bg-background/80 backdrop-blur-sm shrink-0 flex-wrap">
{/* Block type */}
<ToolbarButton
tooltip="正文"
active={editor.isActive("paragraph")}
onClick={() => editor.chain().focus().setParagraph().run()}
>
<Pilcrow className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="一级标题"
active={editor.isActive("heading", { level: 1 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 1 }).run()}
>
<Heading1 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="二级标题"
active={editor.isActive("heading", { level: 2 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 2 }).run()}
>
<Heading2 className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="三级标题"
active={editor.isActive("heading", { level: 3 })}
onClick={() => editor.chain().focus().toggleHeading({ level: 3 }).run()}
>
<Heading3 className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Lists */}
<ToolbarButton
tooltip="无序列表"
active={editor.isActive("bulletList")}
onClick={() => editor.chain().focus().toggleBulletList().run()}
>
<List className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="有序列表"
active={editor.isActive("orderedList")}
onClick={() => editor.chain().focus().toggleOrderedList().run()}
>
<ListOrdered className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Blocks */}
<ToolbarButton
tooltip="引用"
active={editor.isActive("blockquote")}
onClick={() => editor.chain().focus().toggleBlockquote().run()}
>
<Quote className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="代码块"
active={editor.isActive("codeBlock")}
onClick={() => editor.chain().focus().toggleCodeBlock().run()}
>
<Code className="h-3.5 w-3.5" />
</ToolbarButton>
<Separator orientation="vertical" className="mx-1 h-4" />
{/* Inline marks */}
<ToolbarButton
tooltip="加粗 (Ctrl+B)"
active={editor.isActive("bold")}
onClick={() => editor.chain().focus().toggleBold().run()}
>
<Bold className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="斜体 (Ctrl+I)"
active={editor.isActive("italic")}
onClick={() => editor.chain().focus().toggleItalic().run()}
>
<Italic className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="下划线 (Ctrl+U)"
active={editor.isActive("underline")}
onClick={() => editor.chain().focus().toggleUnderline().run()}
>
<Underline className="h-3.5 w-3.5" />
</ToolbarButton>
<ToolbarButton
tooltip="删除线"
active={editor.isActive("strike")}
onClick={() => editor.chain().focus().toggleStrike().run()}
>
<Strikethrough className="h-3.5 w-3.5" />
</ToolbarButton>
{/* Model selector — pushed to the right */}
<div className="ml-auto">
<CanvasModelSelector />
</div>
</div>
);
}
@@ -1,16 +1,47 @@
import { Loader2, SendHorizonal, Sparkles, User, X } from "lucide-react";
import { useEffect, useRef, useState, type KeyboardEvent } from "react";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import {
CheckIcon,
ChevronDownIcon,
GraduationCapIcon,
LightbulbIcon,
RocketIcon,
SendHorizonal,
Sparkles,
Square,
X,
ZapIcon,
} from "lucide-react";
import { useCallback, useMemo, useState, type KeyboardEvent } from "react";
import {
ModelSelector,
ModelSelectorContent,
ModelSelectorInput,
ModelSelectorItem,
ModelSelectorList,
ModelSelectorName,
ModelSelectorTrigger,
} from "@/components/ai-elements/model-selector";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Textarea } from "@/components/ui/textarea";
import { MarkdownContent } from "@/components/workspace/messages/markdown-content";
import { useRehypeSplitWordsIntoSpans } from "@/core/rehype";
import { MessageList } from "@/components/workspace/messages";
import { useModels } from "@/core/models/hooks";
import type { AgentThreadState } from "@/core/threads";
import { cn } from "@/lib/utils";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../../contexts/CanvasStreamContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { useCanvasSelection } from "../../hooks/useCanvasSelection";
import { useCanvasStream } from "../../hooks/useCanvasStream";
import type { CanvasMessage } from "../../types/thread";
import { getCurrentArtifactContent, isCodeArtifact } from "../../utils/artifact";
import {
buildMessageWithSelection,
@@ -22,174 +53,15 @@ import {
// Types
// ---------------------------------------------------------------------------
type ClarificationOption = {
id: string;
label: string;
description?: string;
type CanvasMode = "flash" | "thinking" | "pro" | "ultra";
const MODE_LABELS: Record<CanvasMode, string> = {
flash: "快速",
thinking: "推理",
pro: "深入",
ultra: "超级",
};
type ClarificationPayload = {
question?: string;
context?: string | null;
options?: ClarificationOption[];
allow_custom?: boolean;
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function stripThinkBlocks(text: string): string {
return text.replace(/<think>[\s\S]*?<\/think>/g, "").trim();
}
function getClarificationPayload(msg: CanvasMessage): ClarificationPayload | null {
const c = msg.additional_kwargs?.clarification;
if (typeof c !== "object" || c === null) return null;
return c as ClarificationPayload;
}
function getTextContent(msg: CanvasMessage): string {
return typeof msg.content === "string"
? msg.content
: JSON.stringify(msg.content);
}
// ---------------------------------------------------------------------------
// ClarificationCard
// ---------------------------------------------------------------------------
function ClarificationCard({
message,
disabled,
onSubmit,
}: {
message: CanvasMessage;
disabled: boolean;
onSubmit: (answer: string) => void;
}) {
const payload = getClarificationPayload(message)!;
const [customAnswer, setCustomAnswer] = useState("");
const [submitted, setSubmitted] = useState(false);
const options = (payload.options ?? []).filter(
(o) => typeof o.label === "string" && o.label.trim(),
);
const busy = disabled || submitted;
function submitAnswer(answer: string) {
const trimmed = answer.trim();
if (!trimmed || busy) return;
setSubmitted(true);
onSubmit(trimmed);
}
const fallback = stripThinkBlocks(getTextContent(message));
return (
<div className="border-border bg-card text-card-foreground w-full rounded-lg border p-4 shadow-sm">
<div className="space-y-2">
<div className="text-muted-foreground text-xs font-medium">需要你的确认</div>
{payload.context ? (
<p className="text-muted-foreground text-sm leading-6">{payload.context}</p>
) : null}
<div className="text-sm leading-6 font-medium">
{payload.question || fallback}
</div>
</div>
{options.length > 0 ? (
<div className="mt-4 grid gap-2">
{options.map((option) => (
<Button
key={option.id || option.label}
type="button"
variant="outline"
className="h-auto justify-start whitespace-normal px-3 py-2 text-left"
disabled={busy}
onClick={() => submitAnswer(`我选择:${option.label}`)}
>
<span className="flex min-w-0 flex-col items-start gap-1">
<span className="text-sm">{option.label}</span>
{option.description ? (
<span className="text-muted-foreground text-xs font-normal leading-5">
{option.description}
</span>
) : null}
</span>
</Button>
))}
</div>
) : null}
{payload.allow_custom !== false ? (
<div className="mt-4 flex flex-col gap-2">
<Textarea
value={customAnswer}
onChange={(e) => setCustomAnswer(e.currentTarget.value)}
placeholder="如果以上选项都不合适,可以在这里输入你的回答"
disabled={busy}
className="min-h-20 resize-none text-sm"
/>
<div className="flex justify-end">
<Button
type="button"
size="sm"
disabled={busy || !customAnswer.trim()}
onClick={() => submitAnswer(customAnswer)}
>
<SendHorizonal className="h-4 w-4" />
发送回答
</Button>
</div>
</div>
) : null}
</div>
);
}
// ---------------------------------------------------------------------------
// AiMessageContent — renders a single AI message bubble
// ---------------------------------------------------------------------------
function AiMessageContent({
message,
isActiveStream,
isStreaming,
onClarificationSubmit,
}: {
message: CanvasMessage;
isActiveStream: boolean;
isStreaming: boolean;
onClarificationSubmit: (answer: string) => void;
}) {
const rehypePlugins = useRehypeSplitWordsIntoSpans(isActiveStream);
const clarification = getClarificationPayload(message);
if (clarification) {
return (
<ClarificationCard
message={message}
disabled={isStreaming}
onSubmit={onClarificationSubmit}
/>
);
}
const displayText = stripThinkBlocks(getTextContent(message));
if (!displayText) return null;
return (
<MarkdownContent
content={displayText}
isLoading={isActiveStream}
rehypePlugins={rehypePlugins}
className="text-sm"
/>
);
}
// ---------------------------------------------------------------------------
// ChatWelcome
// ---------------------------------------------------------------------------
@@ -212,20 +84,47 @@ function ChatWelcome() {
);
}
// ---------------------------------------------------------------------------
// ModeIcon
// ---------------------------------------------------------------------------
function ModeIcon({
mode,
className,
}: {
mode: CanvasMode;
className?: string;
}) {
if (mode === "flash") return <ZapIcon className={className} />;
if (mode === "thinking") return <LightbulbIcon className={className} />;
if (mode === "pro") return <GraduationCapIcon className={className} />;
return <RocketIcon className={cn(className, "text-[#dabb5e]")} />;
}
// ---------------------------------------------------------------------------
// CanvasChatPanel
// ---------------------------------------------------------------------------
export function CanvasChatPanel() {
const { messages, isStreaming, artifact } = useCanvasGraphContext();
const { sendMessage } = useCanvasStream();
const { artifact } = useCanvasGraphContext();
const { thread, sendMessage, threadId } = useCanvasStreamContext();
const { modelName, setModelName } = useCanvasThreadContext();
const { selection, clearSelection } = useCanvasSelection();
const [input, setInput] = useState("");
const textareaRef = useRef<HTMLTextAreaElement>(null);
const bottomRef = useRef<HTMLDivElement>(null);
const { models } = useModels();
const [input, setInput] = useState("");
const [mode, setMode] = useState<CanvasMode>("pro");
const [modelDialogOpen, setModelDialogOpen] = useState(false);
const hasMessages = messages.length > 0;
const hasSelection = hasActiveSelection(selection);
const isStreaming = thread.isLoading;
const hasMessages = thread.messages.length > 0;
const selectedModel = useMemo(
() => models.find((m) => m.name === modelName) ?? models[0],
[models, modelName],
);
const supportsThinking = selectedModel?.supports_thinking ?? false;
// Resolve selected code snippet when artifact is a code artifact
const currentContent = getCurrentArtifactContent(artifact);
@@ -252,14 +151,13 @@ export function CanvasChatPanel() {
return null;
})();
// Scroll to bottom whenever messages change or streaming state updates
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages, isStreaming]);
async function handleSend() {
if (isStreaming) {
await thread.stop();
return;
}
const text = input.trim();
if (!text || isStreaming) return;
if (!text) return;
setInput("");
const finalText = hasSelection
@@ -267,7 +165,7 @@ export function CanvasChatPanel() {
: text;
clearSelection();
await sendMessage(finalText);
await sendMessage(finalText, { mode });
}
function handleKeyDown(e: KeyboardEvent<HTMLTextAreaElement>) {
@@ -277,136 +175,232 @@ export function CanvasChatPanel() {
}
}
// Index of the last AI message — used to decide which message gets streaming animation
const lastAiIndex = (() => {
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.type !== "human") return i;
}
return -1;
})();
const handleClarificationSubmit = useCallback(
async (answer: string) => {
await sendMessage(answer, { mode });
},
[sendMessage, mode],
);
// Whether the last AI message has any visible text (not just think blocks / empty)
const lastAiHasVisibleContent = (() => {
if (lastAiIndex < 0) return false;
const msg = messages[lastAiIndex]!;
if (getClarificationPayload(msg)) return true;
return stripThinkBlocks(getTextContent(msg)).trim().length > 0;
})();
const handleModelSelect = useCallback(
(name: string) => {
setModelName(name);
setModelDialogOpen(false);
// If selected model doesn't support thinking, fall back to flash
const model = models.find((m) => m.name === name);
if (!model?.supports_thinking && mode === "thinking") {
setMode("flash");
}
},
[models, mode, setModelName],
);
// MessageList expects BaseStream<AgentThreadState>. The canvas stream returns
// BaseStream<CanvasThreadValues> — structurally compatible for message rendering
// since MessageList only reads thread.messages, isLoading, and isThreadLoading.
const threadForList = thread as unknown as BaseStream<AgentThreadState>;
return (
<div className="h-full flex flex-col bg-background">
{/* Message list */}
<div className="flex-1 overflow-y-auto min-h-0">
{hasMessages ? (
<div className="p-4 space-y-4">
{messages.map((msg, i) => {
const isHuman = msg.type === "human";
return (
<div
key={msg.id ?? i}
className={`flex gap-2 ${isHuman ? "justify-end" : "justify-start"}`}
>
{!isHuman && (
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
)}
{isHuman ? (
<div className="max-w-[80%] rounded-lg px-3 py-2 text-sm bg-primary text-primary-foreground">
{getTextContent(msg)}
</div>
) : (
<div className="max-w-[85%] min-w-0">
<AiMessageContent
message={msg}
isActiveStream={isStreaming && i === lastAiIndex}
isStreaming={isStreaming}
onClarificationSubmit={(answer) => void sendMessage(answer)}
/>
</div>
)}
{isHuman && (
<div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center shrink-0 mt-0.5">
<User className="w-3 h-3" />
</div>
)}
</div>
);
})}
{isStreaming && !lastAiHasVisibleContent && (
<div className="flex gap-2 justify-start">
<div className="w-6 h-6 rounded-full bg-primary/10 flex items-center justify-center shrink-0 mt-0.5">
<Sparkles className="w-3 h-3 text-primary" />
</div>
<div className="bg-muted rounded-lg px-3 py-2 flex items-center gap-2">
<Loader2 className="w-3 h-3 animate-spin text-muted-foreground" />
<span className="text-xs text-muted-foreground">正在生成…</span>
</div>
</div>
)}
<div ref={bottomRef} />
</div>
{hasMessages || isStreaming || thread.isThreadLoading ? (
<MessageList
className="size-full"
threadId={threadId ?? "canvas"}
thread={threadForList}
paddingBottom={0}
onClarificationSubmit={handleClarificationSubmit}
/>
) : (
<ChatWelcome />
)}
</div>
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2 shrink-0">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Input area */}
<div className="border-t p-3 shrink-0">
<div className="flex gap-2 items-end">
<div className="p-3 shrink-0">
<div className="rounded-2xl border border-border/70 bg-background shadow-[0_2px_12px_-4px_rgba(0,0,0,0.08)] overflow-hidden transition-shadow focus-within:shadow-[0_2px_16px_-4px_rgba(0,0,0,0.14)] dark:shadow-none dark:focus-within:ring-1 dark:focus-within:ring-border">
{/* Selection badge */}
{hasSelection && selectionPreview && (
<div className="px-3 pt-2.5">
<Badge
variant="secondary"
className="flex items-center gap-1.5 max-w-full text-xs font-normal py-1 px-2 h-auto"
>
<span className="shrink-0 text-muted-foreground">
{selection.textSelection ? "选中文本:" : "选中代码:"}
</span>
<span className="truncate min-w-0 font-mono">{selectionPreview}</span>
<button
onClick={clearSelection}
className="shrink-0 ml-auto hover:text-foreground text-muted-foreground transition-colors"
aria-label="清除选区"
>
<X className="w-3 h-3" />
</button>
</Badge>
</div>
)}
{/* Textarea */}
<Textarea
ref={textareaRef}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={
hasSelection
? "针对选中内容发出指令…"
: "发送消息以开始… (Enter 发送,Shift+Enter 换行)"
: "发消息…"
}
className="min-h-[40px] max-h-[160px] resize-none text-sm"
rows={1}
className="border-0 shadow-none rounded-none resize-none text-sm min-h-[60px] max-h-[160px] focus-visible:ring-0 px-3 pt-3 pb-2"
rows={2}
disabled={isStreaming}
/>
<Button
size="icon"
className="h-9 w-9 shrink-0"
disabled={!input.trim() || isStreaming}
onClick={() => void handleSend()}
>
{isStreaming ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<SendHorizonal className="h-4 w-4" />
{/* Toolbar */}
<div className="flex items-center gap-1 px-2 pb-2 pt-1 border-t border-border/40">
{/* Mode selector */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60"
>
<ModeIcon mode={mode} className="h-3 w-3" />
<span>{MODE_LABELS[mode]}</span>
<ChevronDownIcon className="h-3 w-3 opacity-60" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className="w-52">
<DropdownMenuLabel className="text-xs text-muted-foreground">
推理模式
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "flash" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("flash")}
>
<ZapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">快速</span>
<span className="text-xs opacity-70">低延迟,即时响应</span>
</div>
{mode === "flash" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
{supportsThinking && (
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "thinking" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("thinking")}
>
<LightbulbIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">推理</span>
<span className="text-xs opacity-70">思维链推导</span>
</div>
{mode === "thinking" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
)}
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "pro" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("pro")}
>
<GraduationCapIcon className="h-3.5 w-3.5" />
<div className="flex flex-col flex-1">
<span className="font-medium">深入</span>
<span className="text-xs opacity-70">深度分析与研究</span>
</div>
{mode === "pro" && <CheckIcon className="h-3.5 w-3.5 ml-auto" />}
</DropdownMenuItem>
<DropdownMenuItem
className={cn(
"gap-2 text-sm",
mode === "ultra" ? "text-foreground" : "text-muted-foreground",
)}
onClick={() => setMode("ultra")}
>
<RocketIcon className={cn("h-3.5 w-3.5", mode === "ultra" && "text-[#dabb5e]")} />
<div className="flex flex-col flex-1">
<span className={cn("font-medium", mode === "ultra" && "text-[#dabb5e]")}>超级</span>
<span className="text-xs opacity-70">全力以赴,最强推理</span>
</div>
{mode === "ultra" && <CheckIcon className="h-3.5 w-3.5 ml-auto text-[#dabb5e]" />}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* Model selector */}
{models.length > 0 && (
<ModelSelector open={modelDialogOpen} onOpenChange={setModelDialogOpen}>
<ModelSelectorTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-7 gap-1 rounded-full px-2.5 text-xs font-normal text-muted-foreground hover:text-foreground hover:bg-muted/60 max-w-[120px]"
>
<ModelSelectorName className="truncate text-xs font-normal">
{selectedModel?.display_name ?? "选择模型"}
</ModelSelectorName>
<ChevronDownIcon className="h-3 w-3 shrink-0 opacity-60" />
</Button>
</ModelSelectorTrigger>
<ModelSelectorContent title="选择模型">
<ModelSelectorInput placeholder="搜索模型…" />
<ModelSelectorList>
{models.map((m) => (
<ModelSelectorItem
key={m.name}
value={m.name}
onSelect={() => handleModelSelect(m.name)}
>
<div className="flex min-w-0 flex-1 flex-col">
<ModelSelectorName>{m.display_name}</ModelSelectorName>
<span className="text-muted-foreground truncate text-[10px]">
{m.model}
</span>
</div>
{m.name === modelName ? (
<CheckIcon className="ml-auto size-4" />
) : (
<div className="ml-auto size-4" />
)}
</ModelSelectorItem>
))}
</ModelSelectorList>
</ModelSelectorContent>
</ModelSelector>
)}
</Button>
{/* Spacer */}
<div className="flex-1" />
{/* Send / Stop button */}
<Button
size="icon"
variant={isStreaming ? "secondary" : "default"}
className={cn(
"h-7 w-7 shrink-0 rounded-full",
isStreaming && "text-muted-foreground",
)}
onClick={() => void handleSend()}
disabled={!isStreaming && !input.trim()}
>
{isStreaming ? (
<Square className="h-3 w-3 fill-current" />
) : (
<SendHorizonal className="h-3.5 w-3.5" />
)}
</Button>
</div>
</div>
</div>
</div>
@@ -1,4 +1,4 @@
import { useEffect, useRef } from "react";
import { useEffect, useRef, useState } from "react";
import type { GroupImperativeHandle } from "react-resizable-panels";
import {
@@ -6,17 +6,23 @@ import {
ResizablePanel,
ResizablePanelGroup,
} from "@/components/ui/resizable";
import { cn } from "@/lib/utils";
import { useCanvasUIContext } from "../../contexts/CanvasUIContext";
import { CanvasStreamProvider } from "../../contexts/CanvasStreamContext";
import { CanvasChatPanel } from "../chat/CanvasChatPanel";
import { CanvasArtifactPanel } from "../artifact/CanvasArtifactPanel";
import { AIWritingPanel } from "../ai-writing/AIWritingPanel";
const LAYOUT_COLLAPSED = { chat: 0, artifact: 100 };
const LAYOUT_DEFAULT = { chat: 35, artifact: 65 };
type LeftMode = "chat" | "writing";
export function CanvasResizableLayout() {
const { isChatCollapsed } = useCanvasUIContext();
const layoutRef = useRef<GroupImperativeHandle>(null);
const [leftMode, setLeftMode] = useState<LeftMode>("chat");
useEffect(() => {
layoutRef.current?.setLayout(
@@ -25,6 +31,7 @@ export function CanvasResizableLayout() {
}, [isChatCollapsed]);
return (
<CanvasStreamProvider>
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
@@ -37,9 +44,29 @@ export function CanvasResizableLayout() {
minSize={0}
collapsible
collapsedSize={0}
className="overflow-hidden"
className="overflow-hidden flex flex-col"
>
<CanvasChatPanel />
{/* 模式切换标签栏 */}
<div className="flex shrink-0 border-b bg-muted/30">
{(["chat", "writing"] as LeftMode[]).map((mode) => (
<button
key={mode}
onClick={() => setLeftMode(mode)}
className={cn(
"flex-1 py-1.5 text-xs font-medium transition-colors",
leftMode === mode
? "border-b-2 border-primary text-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{mode === "chat" ? "对话" : "AI 写作"}
</button>
))}
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{leftMode === "chat" ? <CanvasChatPanel /> : <AIWritingPanel />}
</div>
</ResizablePanel>
<ResizableHandle withHandle />
@@ -54,5 +81,6 @@ export function CanvasResizableLayout() {
</ResizablePanel>
</ResizablePanelGroup>
</div>
</CanvasStreamProvider>
);
}
@@ -3,6 +3,7 @@ import { useEffect } from "react";
import { getCanvasThread } from "../../api/threads";
import { useCanvasGraphContext } from "../../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../../contexts/CanvasThreadContext";
import { TooltipProvider } from "@/components/ui/tooltip";
import { CanvasHeader } from "./CanvasHeader";
import { CanvasResizableLayout } from "./CanvasResizableLayout";
@@ -42,9 +43,11 @@ export function CanvasShell() {
}, [threadId, setMessages, setArtifact, setThreadId]);
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<CanvasHeader />
<CanvasResizableLayout />
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,24 @@
import { createContext, useContext, type ReactNode } from "react";
import { useCanvasThreadStream } from "../hooks/useCanvasThreadStream";
type CanvasStreamContextValue = ReturnType<typeof useCanvasThreadStream>;
const CanvasStreamContext = createContext<CanvasStreamContextValue | null>(null);
export function CanvasStreamProvider({ children }: { children: ReactNode }) {
const value = useCanvasThreadStream();
return (
<CanvasStreamContext.Provider value={value}>
{children}
</CanvasStreamContext.Provider>
);
}
export function useCanvasStreamContext() {
const ctx = useContext(CanvasStreamContext);
if (!ctx)
throw new Error(
"useCanvasStreamContext must be used within CanvasStreamProvider",
);
return ctx;
}
@@ -10,6 +10,7 @@ import type {
CanvasCodeSelection,
CanvasSelectionState,
CanvasTextSelection,
WritingSelection,
} from "../types/selection";
export interface CanvasUIContextValue {
@@ -17,6 +18,8 @@ export interface CanvasUIContextValue {
isEditing: boolean;
isWebSearchOpen: boolean;
selection: CanvasSelectionState;
/** Tiptap editor selection for AI rewrite flow */
writingSelection: WritingSelection | null;
setIsChatCollapsed: (v: boolean) => void;
setIsEditing: (v: boolean) => void;
setIsWebSearchOpen: (v: boolean) => void;
@@ -25,6 +28,7 @@ export interface CanvasUIContextValue {
/** Set code selection; clears any existing text selection. */
setCodeSelection: (s: CanvasCodeSelection | null) => void;
clearSelection: () => void;
setWritingSelection: (s: WritingSelection | null) => void;
}
const EMPTY_SELECTION: CanvasSelectionState = {
@@ -40,6 +44,8 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
const [isWebSearchOpen, setIsWebSearchOpen] = useState(false);
const [selection, setSelection] =
useState<CanvasSelectionState>(EMPTY_SELECTION);
const [writingSelection, setWritingSelection] =
useState<WritingSelection | null>(null);
const setTextSelection = useCallback((s: CanvasTextSelection | null) => {
setSelection({ textSelection: s, codeSelection: null });
@@ -56,12 +62,14 @@ export function CanvasUIProvider({ children }: { children: ReactNode }) {
isEditing,
isWebSearchOpen,
selection,
writingSelection,
setIsChatCollapsed,
setIsEditing,
setIsWebSearchOpen,
setTextSelection,
setCodeSelection,
clearSelection: () => setSelection(EMPTY_SELECTION),
setWritingSelection,
}}
>
{children}
@@ -0,0 +1,79 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
interface HighlightRange {
from: number;
to: number;
}
const aiHighlightKey = new PluginKey<HighlightRange | null>("aiHighlight");
// Augment Tiptap's Commands interface so TS knows about our custom commands
declare module "@tiptap/core" {
interface Commands<ReturnType> {
aiHighlight: {
setAiHighlight: (from: number, to: number) => ReturnType;
clearAiHighlight: () => ReturnType;
};
}
}
export const AiHighlightExtension = Extension.create({
name: "aiHighlight",
addCommands() {
return {
setAiHighlight:
(from: number, to: number) =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, { from, to }));
return true;
},
clearAiHighlight:
() =>
({ dispatch, tr }) => {
if (dispatch) dispatch(tr.setMeta(aiHighlightKey, null));
return true;
},
};
},
addProseMirrorPlugins() {
return [
new Plugin<HighlightRange | null>({
key: aiHighlightKey,
state: {
init: () => null,
apply(tr, prev) {
const meta = tr.getMeta(aiHighlightKey);
// explicit set / clear via command
if (meta !== undefined) return meta as HighlightRange | null;
// map positions through doc changes so highlight survives edits
if (tr.docChanged && prev) {
return {
from: tr.mapping.map(prev.from),
to: tr.mapping.map(prev.to),
};
}
return prev;
},
},
props: {
decorations(state) {
const range = aiHighlightKey.getState(state);
if (!range) return DecorationSet.empty;
const { from, to } = range;
const docSize = state.doc.content.size;
if (from >= to || from < 0 || to > docSize) return DecorationSet.empty;
return DecorationSet.create(state.doc, [
Decoration.inline(from, to, { class: "ai-rewrite-highlight" }),
]);
},
},
}),
];
},
});
@@ -0,0 +1,11 @@
import type { Editor } from "@tiptap/react";
import { EditorContent } from "@tiptap/react";
interface TiptapEditorProps {
editor: Editor | null;
className?: string;
}
export function TiptapEditor({ editor, className }: TiptapEditorProps) {
return <EditorContent editor={editor} className={className} />;
}
@@ -0,0 +1,23 @@
import StarterKit from "@tiptap/starter-kit";
import Underline from "@tiptap/extension-underline";
import Placeholder from "@tiptap/extension-placeholder";
import { Markdown } from "tiptap-markdown";
import { AiHighlightExtension } from "./AiHighlightExtension";
export const buildExtensions = (placeholder = "开始写作…") => [
StarterKit.configure({
// Disable hard break so Enter creates new paragraph
hardBreak: false,
}),
Underline,
Placeholder.configure({
placeholder,
emptyEditorClass: "is-editor-empty",
}),
Markdown.configure({
html: false,
transformCopiedText: true,
transformPastedText: true,
}),
AiHighlightExtension,
];
@@ -0,0 +1,423 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useCanvasGraphContext } from '../contexts/CanvasGraphContext';
import { startWritingSession, subscribeToProgress, resumeWriting as resumeWritingApi } from '../api/ai-writing';
import { saveAIWritingTranscript } from '../api/ai-writing-sessions';
import { countWords } from '../utils/wordCount';
import type {
AIWritingRequest,
AIWritingState,
AIMaterial,
AIOutline,
CompletedIntervention,
ProgressEvent,
ReviewResult,
StreamingDraftSection,
StreamingReviewItem,
UserInterventionPayload,
WritingStatus,
} from '../types/ai-writing';
const INITIAL_STATE: AIWritingState = {
sessionId: null,
status: 'idle',
request: null,
isPaused: false,
progressEvents: [],
materials: [],
outline: null,
currentDraftMarkdown: '',
currentDraftTitle: '',
reviewResult: null,
revisionCount: 0,
currentPause: null,
completedInterventions: [],
error: null,
streamingDraftTitle: '',
streamingDraftSections: [],
outlineStreamingText: '',
outlineThinkingText: '',
researchProgress: '',
streamingReviewItems: [],
revisionSummary: null,
pendingRevisionContext: null,
};
export function useAIWriting() {
const { setArtifact } = useCanvasGraphContext();
const [state, setState] = useState<AIWritingState>(INITIAL_STATE);
const abortRef = useRef<AbortController | null>(null);
const unsubRef = useRef<(() => void) | null>(null);
const handleEvent = useCallback(
(event: ProgressEvent) => {
setState((prev) => {
const base: AIWritingState = {
...prev,
progressEvents: [...prev.progressEvents, event],
};
switch (event.type) {
case 'step_start':
// 兜底:编辑节点完成时若没有流式事件,也切到审核态
if (event.agentName === '编辑' && prev.status !== 'reviewing') {
return { ...base, status: 'reviewing' };
}
return base;
case 'step_done':
return base;
case 'research_progress':
// 素材收集专家实时进度:仅更新最新提示文案
return { ...base, researchProgress: event.message ?? '' };
case 'resumed':
return { ...base, currentPause: null };
case 'materials_ready':
// 仅缓存素材数据,不弹干预卡(等 await_user 事件再弹);进度文案已完成可清空
return {
...base,
materials: (event.materials ?? []) as AIMaterial[],
researchProgress: '',
};
case 'outline_chunk': {
const chunkText = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
return {
...prev,
status: 'writing_outline',
outlineStreamingText: isThinking ? prev.outlineStreamingText : prev.outlineStreamingText + chunkText,
outlineThinkingText: isThinking ? prev.outlineThinkingText + chunkText : prev.outlineThinkingText,
};
}
case 'outline_ready':
// 仅缓存大纲数据,不弹干预卡(等 await_user 事件再弹);清空流式文本
return {
...base,
outline: (event.outline ?? null) as AIOutline | null,
outlineStreamingText: '',
outlineThinkingText: '',
};
case 'draft_chunk': {
const idx = (event as unknown as { sectionIndex: number }).sectionIndex ?? 0;
const title = (event as unknown as { sectionTitle: string }).sectionTitle ?? '';
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
const done = (event as unknown as { isSectionDone: boolean }).isSectionDone ?? false;
const sections = prev.streamingDraftSections.map((s) => ({ ...s }));
const existing = sections.find((s) => s.sectionIndex === idx);
if (existing) {
if (isThinking) existing.thinkingText += text;
else existing.text += text;
existing.isDone = done;
} else {
sections.push({
sectionIndex: idx,
sectionTitle: title,
text: isThinking ? '' : text,
thinkingText: isThinking ? text : '',
isDone: done,
});
}
return {
...prev,
status: 'writing_draft',
streamingDraftSections: sections,
streamingDraftTitle: prev.streamingDraftTitle || prev.outline?.title || '',
};
}
case 'review_chunk': {
const category = (event as unknown as { category: StreamingReviewItem['category'] }).category;
const text = (event as unknown as { text: string }).text ?? '';
const isThinking = (event as unknown as { is_thinking: boolean }).is_thinking ?? false;
// 首个 chunk 且非审核态 → 新一轮审核,清空上一轮残留
const isNewCycle = prev.status !== 'reviewing';
const items = (isNewCycle ? [] : prev.streamingReviewItems).map((it) => ({ ...it }));
let item = items.find((it) => it.category === category);
if (!item) {
item = {
category, isDone: false,
streamingText: '', thinkingText: '',
score: 0, issues: [],
};
items.push(item);
}
if (isThinking) item.thinkingText += text;
else item.streamingText += text;
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'review_item_done': {
const category = event.reviewItemCategory ?? 'fact';
const items = prev.streamingReviewItems.map((it) => ({ ...it }));
const item = items.find((it) => it.category === category);
if (item) {
item.isDone = true;
item.score = event.reviewItemScore ?? 80;
item.issues = event.reviewItemIssues ?? [];
} else {
// 兜底:没收到任何 chunk 直接来 done
items.push({
category, isDone: true,
streamingText: '', thinkingText: '',
score: event.reviewItemScore ?? 80,
issues: event.reviewItemIssues ?? [],
});
}
return { ...prev, status: 'reviewing', streamingReviewItems: items };
}
case 'draft_ready': {
const newMarkdown = event.draftMarkdown ?? '';
const newWordCount = countWords(newMarkdown);
const summary = prev.pendingRevisionContext
? {
revisedIssues: prev.pendingRevisionContext.submittedIssues,
skippedIssues: prev.pendingRevisionContext.skippedIssues,
oldWordCount: prev.pendingRevisionContext.oldWordCount,
newWordCount,
}
: null;
return {
...base,
currentDraftMarkdown: newMarkdown,
currentDraftTitle: event.draftTitle ?? '',
revisionCount: event.revisionCount ?? 0,
streamingDraftSections: [],
streamingDraftTitle: '',
revisionSummary: summary,
pendingRevisionContext: null,
};
}
case 'review_ready':
return {
...base,
reviewResult: (event.reviewResult ?? null) as ReviewResult | null,
};
case 'await_user': {
const pp = event.pausePoint;
// Skip duplicate on SSE reconnect: backend re-injects the saved await_user event
// when queue is empty. If we already have this pause set, nothing needs to change.
if (prev.currentPause?.pausePoint === pp) return prev;
const pauseStatus = pp
? (`awaiting_${pp}` as AIWritingState['status'])
: prev.status;
// 若 await_user 里携带了素材/大纲/审核数据,一并更新(兜底 materials_ready 丢失的情况)
const extra: Partial<AIWritingState> = {};
if (pp === 'material_confirm' && event.materials?.length) {
extra.materials = event.materials as AIMaterial[];
}
if (pp === 'outline_confirm' && event.outline) {
extra.outline = event.outline as AIOutline;
}
return { ...base, ...extra, status: pauseStatus, currentPause: event };
}
case 'done':
return { ...base, status: 'done', currentPause: null };
case 'error': {
// error 事件携带 title(如「模型错误」)+ detail(真实原因),拼成底部状态栏文案
const msg = event.detail
? `${event.title ?? '出错了'}:${event.detail}`
: (event.message ?? '未知错误');
return { ...base, status: 'error', error: msg };
}
// notice:非致命告警(ES 波动 / 模型兜底),仅追加到时间线,不改变写作状态
case 'notice':
return base;
default:
return base;
}
});
// 草稿实时同步到右侧 Artifact 面板
if (event.type === 'draft_ready' && event.draftMarkdown) {
setArtifact({
currentIndex: 0,
contents: [
{
index: 0,
type: 'text',
title: event.draftTitle ?? '草稿',
fullMarkdown: event.draftMarkdown,
},
],
});
}
},
[setArtifact],
);
const startWriting = useCallback(
async (request: AIWritingRequest) => {
if (
state.status !== 'idle' &&
state.status !== 'done' &&
state.status !== 'error'
)
return;
abortRef.current?.abort();
abortRef.current = new AbortController();
setState({ ...INITIAL_STATE, status: 'researching', request });
try {
const sessionId = await startWritingSession(request);
setState((prev) => ({ ...prev, sessionId }));
unsubRef.current = subscribeToProgress(
sessionId,
handleEvent,
abortRef.current.signal,
);
} catch (err) {
setState((prev) => ({
...prev,
status: 'error',
error: String(err),
}));
}
},
[state.status, handleEvent],
);
const submitIntervention = useCallback(
async (payload: UserInterventionPayload) => {
if (!state.sessionId || !state.currentPause) return;
// 若处于暂停状态,先重连 SSE 再提交,实现"干预即续接"
if (state.isPaused) {
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}
const pausePoint = state.currentPause.pausePoint;
const record: CompletedIntervention = {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
if (pausePoint === 'material_confirm') {
record.materials = state.materials;
} else if (pausePoint === 'outline_confirm') {
record.outline = state.outline ?? undefined;
} else if (pausePoint === 'draft_confirm') {
record.draftTitle = state.currentDraftTitle;
record.draftWordCount = countWords(state.currentDraftMarkdown);
record.reviewResult = state.reviewResult ?? undefined;
} else if (pausePoint === 'review_confirm') {
record.reviewResult = state.reviewResult ?? undefined;
}
// 提交编辑意见修改时,记录本次选中/跳过的意见用于后续生成摘要
let pendingRevisionContext: AIWritingState['pendingRevisionContext'] = null;
const isApplyingReview =
payload.selectedIssueIndices !== undefined &&
state.reviewResult != null;
if (isApplyingReview && state.reviewResult) {
const allIssues = state.reviewResult.revisionNotes;
const selectedSet = new Set(payload.selectedIssueIndices!);
const submittedIssues = allIssues.filter((_, i) => selectedSet.has(i));
const skippedIssues = allIssues.filter((_, i) => !selectedSet.has(i));
pendingRevisionContext = {
submittedIssues,
skippedIssues,
oldWordCount: countWords(state.currentDraftMarkdown),
};
}
// Optimistic next-status so the correct skeleton shows immediately after
// the user clicks confirm, without waiting for the first SSE chunk.
let nextStatus: WritingStatus = state.status;
if (pausePoint === 'material_confirm') {
nextStatus = payload.action === 're_search' ? 'researching' : 'writing_outline';
} else if (pausePoint === 'outline_confirm') {
nextStatus = payload.action === 're_outline' ? 'writing_outline' : 'writing_draft';
} else if (pausePoint === 'draft_confirm') {
if (payload.action === 'finalize') nextStatus = 'done';
else if (payload.action === 'to_editor') nextStatus = 'reviewing';
else nextStatus = 'revising'; // user_revise
} else if (pausePoint === 'review_confirm') {
nextStatus = payload.action === 'force_finalize' ? 'done' : 'revising'; // accept_review
}
try {
await resumeWritingApi(state.sessionId, payload);
setState((prev) => ({
...prev,
currentPause: null,
status: nextStatus,
// Clear stale streaming state so new-cycle detection in chunk handlers works correctly
...(nextStatus === 'researching' ? { researchProgress: '' } : {}),
...(nextStatus === 'writing_outline' ? { outlineStreamingText: '', outlineThinkingText: '' } : {}),
...(nextStatus === 'writing_draft' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
...(nextStatus === 'reviewing' ? { streamingReviewItems: [] } : {}),
...(nextStatus === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '' } : {}),
completedInterventions: [...prev.completedInterventions, record],
...(pendingRevisionContext ? { pendingRevisionContext } : {}),
}));
} catch (err) {
setState((prev) => ({ ...prev, error: String(err) }));
}
},
[state.sessionId, state.currentPause, state.isPaused, state.materials, state.outline,
state.currentDraftTitle, state.currentDraftMarkdown, state.reviewResult, handleEvent],
);
const pauseWriting = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setState((prev) => ({ ...prev, isPaused: true }));
}, []);
const resumeWriting = useCallback(() => {
if (!state.sessionId) return;
abortRef.current?.abort();
abortRef.current = new AbortController();
unsubRef.current = subscribeToProgress(
state.sessionId,
handleEvent,
abortRef.current.signal,
);
setState((prev) => ({ ...prev, isPaused: false }));
}, [state.sessionId, handleEvent]);
const reset = useCallback(() => {
abortRef.current?.abort();
unsubRef.current?.();
setArtifact(null);
setState(INITIAL_STATE);
}, [setArtifact]);
useEffect(() => () => { abortRef.current?.abort(); }, []);
// 在暂停点 / 完成 / 出错时,把完整对话时间线持久化到后端,供历史回显出与实时一致的内容
useEffect(() => {
const s = state.status;
const savable = s === 'done' || s === 'error' || s.startsWith('awaiting_');
if (!state.sessionId || !savable || state.progressEvents.length === 0) return;
saveAIWritingTranscript(state.sessionId, {
progressEvents: state.progressEvents,
completedInterventions: state.completedInterventions,
}).catch(() => {});
// 仅在状态进入可保存阶段时触发;events/interventions 取闭包最新值,无需进 deps
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.status, state.sessionId]);
return { state, startWriting, submitIntervention, pauseWriting, resumeWriting, reset };
}
@@ -0,0 +1,45 @@
import { useCallback, useState } from 'react';
import {
listAIWritingSessions,
renameAIWritingSession,
deleteAIWritingSession,
} from '../api/ai-writing-sessions';
import type { AIWritingSessionSummary } from '../api/ai-writing-sessions';
export function useAIWritingSessions() {
const [sessions, setSessions] = useState<AIWritingSessionSummary[]>([]);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await listAIWritingSessions();
setSessions(data);
} catch {
// silently ignore — history is non-critical
} finally {
setLoading(false);
}
}, []);
const rename = useCallback(async (id: string, title: string) => {
try {
const updated = await renameAIWritingSession(id, title);
setSessions((prev) => prev.map((s) => (s.id === id ? updated : s)));
} catch {
// ignore
}
}, []);
const remove = useCallback(async (id: string) => {
try {
await deleteAIWritingSession(id);
setSessions((prev) => prev.filter((s) => s.id !== id));
} catch {
// ignore
}
}, []);
return { sessions, loading, refresh, rename, remove };
}
@@ -0,0 +1,49 @@
import { useCallback, useState } from 'react';
import {
listArticleTypes,
createArticleType,
updateArticleType,
deleteArticleType,
} from '../api/ai-writing-article-types';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
export function useArticleTypes() {
const [articleTypes, setArticleTypes] = useState<ArticleType[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const refresh = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await listArticleTypes();
setArticleTypes(data);
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
}, []);
const create = useCallback(async (data: CreateArticleTypeRequest): Promise<ArticleType> => {
const created = await createArticleType(data);
setArticleTypes((prev) => [...prev, created].sort((a, b) => a.sort_order - b.sort_order));
return created;
}, []);
const update = useCallback(async (id: string, data: UpdateArticleTypeRequest): Promise<ArticleType> => {
const updated = await updateArticleType(id, data);
setArticleTypes((prev) =>
prev.map((t) => (t.id === id ? updated : t)).sort((a, b) => a.sort_order - b.sort_order),
);
return updated;
}, []);
const remove = useCallback(async (id: string): Promise<void> => {
await deleteArticleType(id);
setArticleTypes((prev) => prev.filter((t) => t.id !== id));
}, []);
return { articleTypes, loading, error, refresh, create, update, remove };
}
@@ -1,13 +1,13 @@
import { useCallback } from "react";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasStreamContext } from "../contexts/CanvasStreamContext";
import type { QuickActionId } from "../types/quick-action";
import { getCurrentArtifactContent } from "../utils/artifact";
import { buildQuickActionMessage } from "../utils/quick-actions";
import { useCanvasStream } from "./useCanvasStream";
export function useCanvasQuickActions() {
const { sendMessage } = useCanvasStream();
const { sendMessage } = useCanvasStreamContext();
const { artifact } = useCanvasGraphContext();
const triggerQuickAction = useCallback(
@@ -15,7 +15,7 @@ export function useCanvasQuickActions() {
const content = getCurrentArtifactContent(artifact);
if (!content) return;
const message = buildQuickActionMessage(actionId);
const message = buildQuickActionMessage(actionId, content);
await sendMessage(message, { quick_action_id: actionId });
},
[sendMessage, artifact],
@@ -0,0 +1,254 @@
// Canvas-specific variant of useThreadStream (core/threads/hooks.ts).
// Copied and adapted for canvas_agent so canvas writing-mode interactions
// can be customized independently from the main lead_agent flow.
import type { AIMessage, Message } from "@langchain/langgraph-sdk";
import { useStream } from "@langchain/langgraph-sdk/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
import { getCanvasAPIClient } from "@/core/api/canvas-client";
import { useUpdateSubtask } from "@/core/tasks/context";
import { CANVAS_AGENT_ID, CANVAS_THREAD_METADATA } from "../constants";
import { useCanvasGraphContext } from "../contexts/CanvasGraphContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
import type { StreamingArtifactPartial } from "../api/runs";
import type { CanvasArtifact } from "../types/artifact";
import type { CanvasThreadValues } from "../types/thread";
import { getCurrentArtifactContent } from "../utils/artifact";
function getStreamErrorMessage(error: unknown): string {
if (typeof error === "string" && error.trim()) return error;
if (error instanceof Error && error.message.trim()) return error.message;
if (typeof error === "object" && error !== null) {
const message = Reflect.get(error, "message");
if (typeof message === "string" && message.trim()) return message;
}
return "Request failed.";
}
/**
* Canvas-specific stream hook — a direct copy of the main agent's useThreadStream,
* configured for canvas_agent. Modify sendMessage here to inject canvas-specific
* context (writing_artifact_path, canvas_mode, highlighted text, etc.).
*/
export function useCanvasThreadStream() {
const navigate = useNavigate();
const { threadId, setThreadId, modelName } = useCanvasThreadContext();
const { artifact, setArtifact, setIsStreaming, setStreamingArtifact } = useCanvasGraphContext();
const updateSubtask = useUpdateSubtask();
const [optimisticMessages, setOptimisticMessages] = useState<Message[]>([]);
const sendInFlightRef = useRef(false);
const prevMsgCountRef = useRef(0);
const [onStreamThreadId, setOnStreamThreadId] = useState<string | null>(
() => threadId,
);
const threadIdRef = useRef<string | null>(threadId ?? null);
useEffect(() => {
const normalized = threadId ?? null;
setOnStreamThreadId(normalized);
threadIdRef.current = normalized;
if (!normalized) {
sendInFlightRef.current = false;
prevMsgCountRef.current = 0;
setOptimisticMessages([]);
}
}, [threadId]);
const thread = useStream<CanvasThreadValues>({
client: getCanvasAPIClient(),
assistantId: CANVAS_AGENT_ID,
threadId: onStreamThreadId,
fetchStateHistory: { limit: 1 },
onCreated(meta) {
setOnStreamThreadId(meta.thread_id);
threadIdRef.current = meta.thread_id;
setThreadId(meta.thread_id);
navigate(`/page/canvas/${meta.thread_id}`, { replace: true });
void getCanvasAPIClient()
.threads.update(meta.thread_id, { metadata: CANVAS_THREAD_METADATA })
.catch(() => {});
},
onUpdateEvent(data) {
const updates: Array<Partial<CanvasThreadValues> | null> = Object.values(
data || {},
);
for (const update of updates) {
if (update?.artifact) {
setArtifact(update.artifact as CanvasArtifact);
}
}
},
onCustomEvent(event: unknown) {
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "task_running"
) {
const e = event as {
type: "task_running";
task_id: string;
message: AIMessage;
};
updateSubtask({ id: e.task_id, latestMessage: e.message });
return;
}
if (
typeof event === "object" &&
event !== null &&
"type" in event &&
(event as { type: unknown }).type === "llm_retry" &&
"message" in event &&
typeof (event as { message: unknown }).message === "string"
) {
toast((event as { message: string }).message);
}
},
onError(error) {
setOptimisticMessages([]);
toast.error(getStreamErrorMessage(error));
},
});
// Keep CanvasGraphContext.isStreaming in sync so the artifact panel and
// quick-action bar can react to streaming state.
useEffect(() => {
setIsStreaming(thread.isLoading);
}, [thread.isLoading, setIsStreaming]);
// Detect update_artifact tool calls from streaming messages to populate
// streamingArtifact so the right panel shows content before the final
// state update fires via onUpdateEvent.
useEffect(() => {
if (!thread.isLoading) {
setStreamingArtifact(null);
return;
}
const msgs = thread.messages;
for (let i = msgs.length - 1; i >= 0; i--) {
const msg = msgs[i];
if (msg.type !== "ai") continue;
const toolCalls = (msg as AIMessage).tool_calls;
if (!toolCalls?.length) continue;
const call = toolCalls.find((tc) => tc.name === "update_artifact");
if (!call?.args) continue;
const args = call.args as Record<string, unknown>;
const partial: StreamingArtifactPartial = {
type: args.type as "text" | "code" | undefined,
title: typeof args.title === "string" ? args.title : undefined,
content: typeof args.content === "string" ? args.content : undefined,
language: typeof args.language === "string" ? args.language : undefined,
};
if (partial.title !== undefined || partial.content !== undefined) {
setStreamingArtifact(partial);
return;
}
}
}, [thread.messages, thread.isLoading, setStreamingArtifact]);
// Clear optimistic messages once the server stream begins delivering real ones.
useEffect(() => {
if (
optimisticMessages.length > 0 &&
thread.messages.length > prevMsgCountRef.current
) {
setOptimisticMessages([]);
}
}, [thread.messages.length, optimisticMessages.length]);
/**
* Canvas writing-mode sendMessage — copied from ChatPage's handleSubmit flow.
* Modify here to inject writing_artifact_path, canvas_mode, highlighted
* text/code, or any other canvas-specific context the agent needs.
*/
const sendMessage = useCallback(
async (text: string, extraContext?: Record<string, unknown>) => {
const trimmed = text.trim();
if (!trimmed || sendInFlightRef.current) return;
sendInFlightRef.current = true;
prevMsgCountRef.current = thread.messages.length;
setOptimisticMessages([
{
type: "human",
id: `opt-human-${Date.now()}`,
content: [{ type: "text", text: trimmed }],
additional_kwargs: {},
},
]);
// Inject current artifact content into the human message so the LLM can see
// what to edit. create_agent only passes state.messages to the LLM — state.artifact
// is NOT automatically visible. Skip injection when the caller already embedded
// the artifact (e.g. quick actions build the full message themselves).
const currentContent = artifact ? getCurrentArtifactContent(artifact) : null;
const artifactContext = currentContent
? {
canvas_artifact_title: currentContent.title,
canvas_artifact_type: currentContent.type,
}
: {};
const messageText =
currentContent && !trimmed.includes("<artifact")
? (() => {
const body =
currentContent.type === "code"
? currentContent.code
: currentContent.fullMarkdown;
const tag =
currentContent.type === "code"
? `language="${currentContent.language}"`
: "";
return `${trimmed}\n\n<artifact ${tag}>\n${body}\n</artifact>`;
})()
: trimmed;
try {
await thread.submit(
{
messages: [
{ type: "human", content: [{ type: "text", text: messageText }] },
],
},
{
threadId: threadIdRef.current ?? undefined,
streamSubgraphs: true,
streamResumable: true,
config: {
recursion_limit: 1000,
...(modelName ? { configurable: { model_name: modelName } } : {}),
},
context: {
...extraContext,
...artifactContext,
thread_id: threadIdRef.current ?? "",
writing_mode: true,
},
},
);
} catch {
setOptimisticMessages([]);
} finally {
sendInFlightRef.current = false;
}
},
[thread, modelName, artifact],
);
const mergedThread =
optimisticMessages.length > 0
? ({
...thread,
messages: [...thread.messages, ...optimisticMessages],
} as typeof thread)
: thread;
return { thread: mergedThread, sendMessage, threadId };
}
@@ -0,0 +1,53 @@
import { useEffect, useRef } from "react";
import { useEditor } from "@tiptap/react";
import { buildExtensions } from "../editor/TiptapExtensions";
interface UseWritingEditorOptions {
markdown: string;
onChange?: (markdown: string) => void;
editable?: boolean;
placeholder?: string;
}
export function useWritingEditor({
markdown,
onChange,
editable = true,
placeholder,
}: UseWritingEditorOptions) {
const prevMarkdownRef = useRef<string>(markdown);
const editor = useEditor({
extensions: buildExtensions(placeholder),
editable,
content: markdown,
onUpdate({ editor }) {
if (!onChange) return;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText();
onChange(md);
},
});
// Sync external markdown changes (e.g. after streaming completes)
useEffect(() => {
if (!editor) return;
if (markdown === prevMarkdownRef.current) return;
prevMarkdownRef.current = markdown;
const { from, to } = editor.state.selection;
editor.commands.setContent(markdown, { emitUpdate: false });
const docSize = editor.state.doc.content.size;
if (from <= docSize && to <= docSize) {
editor.commands.setTextSelection({ from, to });
}
}, [editor, markdown]);
// Sync editable flag
useEffect(() => {
if (!editor) return;
editor.setEditable(editable);
}, [editor, editable]);
return editor;
}
@@ -0,0 +1,206 @@
import { useCallback, useRef, useState } from "react";
import type { Editor } from "@tiptap/react";
import {
streamRewrite,
type RewriteAction,
type RewriteRequest,
type RewriteTone,
} from "../api/rewrite";
import { useCanvasUIContext } from "../contexts/CanvasUIContext";
import { useCanvasThreadContext } from "../contexts/CanvasThreadContext";
export type RewriteStatus = "idle" | "loading" | "success" | "error";
// ── <think>…</think> tag stripper ────────────────────────────────────────────
// Handles tags split across multiple streaming chunks.
class ThinkTagParser {
private buf = "";
private inside = false;
push(chunk: string): { thinking: string; text: string } {
this.buf += chunk;
let thinking = "";
let text = "";
// eslint-disable-next-line no-constant-condition
while (true) {
if (!this.inside) {
const idx = this.buf.indexOf("<think>");
if (idx !== -1) {
text += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 7);
this.inside = true;
} else {
// hold last 6 chars to guard against a partial opening tag
const safe = Math.max(0, this.buf.length - 6);
text += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
} else {
const idx = this.buf.indexOf("</think>");
if (idx !== -1) {
thinking += this.buf.slice(0, idx);
this.buf = this.buf.slice(idx + 8);
this.inside = false;
} else {
// hold last 7 chars to guard against a partial closing tag
const safe = Math.max(0, this.buf.length - 7);
thinking += this.buf.slice(0, safe);
this.buf = this.buf.slice(safe);
break;
}
}
}
return { thinking, text };
}
flush(): { thinking: string; text: string } {
const out = {
thinking: this.inside ? this.buf : "",
text: this.inside ? "" : this.buf,
};
this.buf = "";
return out;
}
}
// ─────────────────────────────────────────────────────────────────────────────
export function useWritingRewrite(editor: Editor | null) {
const { writingSelection } = useCanvasUIContext();
const { threadId, modelName } = useCanvasThreadContext();
const [status, setStatus] = useState<RewriteStatus>("idle");
const [result, setResult] = useState<string | null>(null);
const [thinking, setThinking] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(null);
// Shared streaming runner — state setters from useState are stable references
const runStream = useCallback(
async (req: RewriteRequest, abort: AbortController) => {
const parser = new ThinkTagParser();
const applyParsed = (parsed: { thinking: string; text: string }) => {
if (parsed.thinking) setThinking((p) => (p ?? "") + parsed.thinking);
if (parsed.text) setResult((p) => (p ?? "") + parsed.text);
};
try {
let terminated = false;
for await (const event of streamRewrite(req, abort.signal)) {
if (abort.signal.aborted) break;
if (event.type === "thinking") {
setThinking((p) => (p ?? "") + event.chunk);
} else if (event.type === "text") {
applyParsed(parser.push(event.chunk));
} else if (event.type === "done") {
applyParsed(parser.flush());
setStatus("success");
terminated = true;
break;
} else if (event.type === "error") {
setError(event.message ?? "改写失败,请重试");
setStatus("error");
terminated = true;
break;
}
}
if (!terminated && !abort.signal.aborted) {
applyParsed(parser.flush());
setStatus("success");
}
} catch (e) {
if (abort.signal.aborted) return;
setError(e instanceof Error ? e.message : "改写失败,请重试");
setStatus("error");
}
},
[], // eslint-disable-line react-hooks/exhaustive-deps
);
const rewrite = useCallback(
async (action: RewriteAction, tone?: RewriteTone, customInstruction?: string) => {
if (!writingSelection || !editor) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
customInstruction: customInstruction || undefined,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, modelName, editor, runStream],
);
const rewriteFollowUp = useCallback(
async (instruction: string, action: RewriteAction, tone?: RewriteTone) => {
if (!writingSelection || !editor) return;
const prevResult = result;
if (!prevResult) return;
abortRef.current?.abort();
const abort = new AbortController();
abortRef.current = abort;
const { from, to, text } = writingSelection;
const docText = editor.state.doc.textContent;
const docSize = editor.state.doc.content.size;
const req: RewriteRequest = {
threadId,
selection: { from, to, text },
action,
tone,
modelName: modelName ?? undefined,
surroundingContext: {
before: docText.substring(Math.max(0, from - 400), from),
after: docText.substring(to, Math.min(docSize, to + 400)),
},
followUpInstruction: instruction,
previousResult: prevResult,
};
setStatus("loading");
setResult(null);
setThinking(null);
setError(null);
await runStream(req, abort);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[writingSelection, modelName, editor, result, runStream],
);
const reset = useCallback(() => {
abortRef.current?.abort();
abortRef.current = null;
setStatus("idle");
setResult(null);
setThinking(null);
setError(null);
}, []);
return { status, result, thinking, error, rewrite, rewriteFollowUp, reset };
}
@@ -0,0 +1,74 @@
import { useRef } from 'react';
import { TooltipProvider } from '@/components/ui/tooltip';
import type { GroupImperativeHandle } from 'react-resizable-panels';
import { CanvasThreadProvider } from '../contexts/CanvasThreadContext';
import { CanvasGraphProvider } from '../contexts/CanvasGraphContext';
import { CanvasUIProvider } from '../contexts/CanvasUIContext';
import { AIWritingPageHeader } from '../components/ai-writing/AIWritingPageHeader';
import { AIWritingPanel } from '../components/ai-writing/AIWritingPanel';
import { AIWritingDraftPanel } from '../components/ai-writing/AIWritingDraftPanel';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
} from '@/components/ui/resizable';
// Panel IDs used as keys in the v4 Layout map
const WRITING_ID = 'aw-writing';
const DRAFT_ID = 'aw-draft';
// Match the original canvas page proportions (chat=35, artifact=65)
const DEFAULT_LAYOUT = { [WRITING_ID]: 35, [DRAFT_ID]: 65 };
export default function AIWritingPage() {
const layoutRef = useRef<GroupImperativeHandle>(null);
return (
<CanvasThreadProvider>
<CanvasGraphProvider>
<CanvasUIProvider>
<TooltipProvider delayDuration={300}>
<div className="h-full flex flex-col overflow-hidden bg-background">
<AIWritingPageHeader />
<div className="flex-1 min-h-0 overflow-hidden">
<ResizablePanelGroup
orientation="horizontal"
groupRef={layoutRef}
defaultLayout={DEFAULT_LAYOUT}
className="h-full"
>
{/* 左侧:AI 写作面板,与原画布页左侧等宽 */}
<ResizablePanel
id={WRITING_ID}
defaultSize={35}
minSize={20}
className="overflow-hidden flex flex-col"
>
{/* 与原布局保持相同的 flex-1 min-h-0 包裹,确保内部 h-full 正确解析 */}
<div className="flex-1 min-h-0 overflow-hidden">
<AIWritingPanel />
</div>
</ResizablePanel>
<ResizableHandle withHandle />
{/* 右侧:草稿预览面板 */}
<ResizablePanel
id={DRAFT_ID}
defaultSize={65}
minSize={40}
className="overflow-hidden"
>
<AIWritingDraftPanel />
</ResizablePanel>
</ResizablePanelGroup>
</div>
</div>
</TooltipProvider>
</CanvasUIProvider>
</CanvasGraphProvider>
</CanvasThreadProvider>
);
}
@@ -0,0 +1,414 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, FileText, Pencil, Plus, Settings2, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Separator } from '@/components/ui/separator';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useArticleTypes } from '../hooks/useArticleTypes';
import type { ArticleType, CreateArticleTypeRequest, UpdateArticleTypeRequest } from '../api/ai-writing-article-types';
// ── 样式常量 ──────────────────────────────────────────────────────────────────
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 disabled:bg-muted';
const TEXTAREA_CLASS =
'flex w-full rounded-md border border-input bg-background px-3 py-2 text-xs shadow-sm ' +
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
'focus-visible:ring-ring resize-none';
// ── 表单状态类型 ───────────────────────────────────────────────────────────────
interface FormState {
key: string;
label: string;
description: string;
prompt_hint: string;
default_word_count: string;
sort_order: string;
}
const EMPTY_FORM: FormState = {
key: '',
label: '',
description: '',
prompt_hint: '',
default_word_count: '800',
sort_order: '0',
};
// ── 主页面 ────────────────────────────────────────────────────────────────────
export default function ArticleTypesConfigPage() {
const navigate = useNavigate();
const { articleTypes, loading, error, refresh, create, update, remove } = useArticleTypes();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [deleting, setDeleting] = useState(false);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, [refresh]);
// ── 对话框操作 ──────────────────────────────────────────────────────────────
function openCreate() {
setEditingId(null);
setForm(EMPTY_FORM);
setFormError(null);
setDialogOpen(true);
}
function openEdit(t: ArticleType) {
setEditingId(t.id);
setForm({
key: t.key,
label: t.label,
description: t.description ?? '',
prompt_hint: t.prompt_hint ?? '',
default_word_count: String(t.default_word_count),
sort_order: String(t.sort_order),
});
setFormError(null);
setDialogOpen(true);
}
function setField<K extends keyof FormState>(key: K, value: FormState[K]) {
setForm((f) => ({ ...f, [key]: value }));
}
async function handleSave() {
const trimmedLabel = form.label.trim();
const wordCount = parseInt(form.default_word_count, 10);
if (!trimmedLabel) { setFormError('名称不能为空'); return; }
if (isNaN(wordCount) || wordCount < 100) { setFormError('默认字数不能小于 100'); return; }
setSaving(true);
setFormError(null);
try {
if (editingId) {
const req: UpdateArticleTypeRequest = {
label: trimmedLabel,
description: form.description.trim() || null,
prompt_hint: form.prompt_hint.trim() || null,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await update(editingId, req);
} else {
const autoKey = 't_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
const req: CreateArticleTypeRequest = {
key: autoKey,
label: trimmedLabel,
description: form.description.trim() || undefined,
prompt_hint: form.prompt_hint.trim() || undefined,
default_word_count: wordCount,
sort_order: parseInt(form.sort_order, 10) || 0,
};
await create(req);
}
setDialogOpen(false);
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
// ── 删除操作 ────────────────────────────────────────────────────────────────
async function handleDelete() {
if (!deleteTargetId) return;
setDeleting(true);
setDeleteError(null);
try {
await remove(deleteTargetId);
setDeleteTargetId(null);
} catch (e) {
setDeleteError(e instanceof Error ? e.message : '删除失败');
} finally {
setDeleting(false);
}
}
// ── 渲染 ────────────────────────────────────────────────────────────────────
return (
<div className="h-full flex flex-col overflow-hidden bg-background">
{/* 页面头部 */}
<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(-1)}
title="返回"
>
<ArrowLeft className="h-4 w-4" />
</Button>
<Separator orientation="vertical" className="h-4" />
<Settings2 className="h-4 w-4 text-primary shrink-0" />
<span className="text-sm font-semibold flex-1">文章类型配置</span>
<Button size="sm" className="h-7 px-3 text-xs gap-1.5" onClick={openCreate}>
<Plus className="size-3.5" />
新增类型
</Button>
</div>
{/* 内容区 */}
<div className="flex-1 overflow-y-auto p-4">
{loading && (
<p className="text-sm text-muted-foreground text-center py-12">加载中…</p>
)}
{!loading && error && (
<p className="text-sm text-destructive text-center py-12">{error}</p>
)}
{!loading && !error && articleTypes.length === 0 && (
<div className="text-center py-12 space-y-2">
<Settings2 className="h-8 w-8 mx-auto text-muted-foreground/40" />
<p className="text-sm text-muted-foreground">暂无文章类型</p>
<p className="text-xs text-muted-foreground">点击右上角"新增类型"创建第一个文章类型</p>
</div>
)}
{!loading && articleTypes.length > 0 && (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{articleTypes.map((t) => (
<ArticleTypeCard
key={t.id}
type={t}
onEdit={() => openEdit(t)}
onDelete={() => { setDeleteError(null); setDeleteTargetId(t.id); }}
/>
))}
</div>
)}
</div>
{/* 新增 / 编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={(open) => { if (!saving) setDialogOpen(open); }}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editingId ? '编辑文章类型' : '新增文章类型'}</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-1">
<FormField label="名称" required>
<input
className={INPUT_CLASS}
placeholder="如 新闻资讯、深度特稿"
value={form.label}
onChange={(e) => setField('label', e.target.value)}
/>
</FormField>
<FormField label="说明">
<input
className={INPUT_CLASS}
placeholder="简短描述,帮助用户了解该类型的适用场景"
value={form.description}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<FormField label="写作风格提示词">
<textarea
className={TEXTAREA_CLASS}
rows={3}
placeholder="传入 AI 的写作风格说明,如:以新闻报道风格写作,注重 5W1H 要素,语言简洁客观…"
value={form.prompt_hint}
onChange={(e) => setField('prompt_hint', e.target.value)}
/>
</FormField>
<div className="grid grid-cols-2 gap-3">
<FormField label="默认字数(字)" required>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
value={form.default_word_count}
onChange={(e) => setField('default_word_count', e.target.value)}
/>
</FormField>
<FormField label="排序权重">
<input
type="number"
min={0}
className={INPUT_CLASS}
placeholder="0"
value={form.sort_order}
onChange={(e) => setField('sort_order', e.target.value)}
/>
</FormField>
</div>
{formError && (
<p className="text-xs text-destructive">{formError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDialogOpen(false)}
disabled={saving}
>
取消
</Button>
<Button size="sm" onClick={handleSave} disabled={saving}>
{saving ? '保存中…' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认对话框 */}
<Dialog
open={deleteTargetId !== null}
onOpenChange={(open) => { if (!deleting && !open) setDeleteTargetId(null); }}
>
<DialogContent className="sm:max-w-sm">
<DialogHeader>
<DialogTitle>确认删除</DialogTitle>
</DialogHeader>
<div className="py-2 space-y-2">
<p className="text-sm text-muted-foreground">
删除后不可恢复。已使用该类型创建的历史写作不受影响,但新写作将无法选择此类型。
</p>
{deleteError && (
<p className="text-xs text-destructive">{deleteError}</p>
)}
</div>
<DialogFooter>
<Button
variant="outline"
size="sm"
onClick={() => setDeleteTargetId(null)}
disabled={deleting}
>
取消
</Button>
<Button
variant="destructive"
size="sm"
onClick={handleDelete}
disabled={deleting}
>
{deleting ? '删除中…' : '确认删除'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// ── 文章类型卡片 ──────────────────────────────────────────────────────────────
function ArticleTypeCard({
type,
onEdit,
onDelete,
}: {
type: ArticleType;
onEdit: () => void;
onDelete: () => void;
}) {
return (
<div className="rounded-lg border bg-card p-4 flex flex-col gap-2.5 hover:border-primary/30 transition-colors">
{/* 标题行 */}
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold">{type.label}</span>
</div>
{type.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{type.description}</p>
)}
</div>
<div className="flex items-center gap-0.5 shrink-0">
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={onEdit}
title="编辑"
>
<Pencil className="h-3 w-3" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 text-destructive/70 hover:text-destructive"
onClick={onDelete}
title="删除"
>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
{/* 写作提示词预览 */}
{type.prompt_hint && (
<div className="rounded-md bg-muted/50 px-2.5 py-2">
<p className="text-[11px] text-muted-foreground leading-relaxed line-clamp-2">
{type.prompt_hint}
</p>
</div>
)}
{/* 底部元信息 */}
<div className="flex items-center gap-3 pt-0.5 border-t">
<span className="text-[11px] text-muted-foreground flex items-center gap-1 pt-2">
<FileText className="h-3 w-3" />
默认 {type.default_word_count} 字
</span>
<span className="text-[11px] text-muted-foreground pt-2">
排序 {type.sort_order}
</span>
</div>
</div>
);
}
// ── 表单字段包装 ──────────────────────────────────────────────────────────────
function FormField({
label,
required,
children,
}: {
label: string;
required?: boolean;
children: React.ReactNode;
}) {
return (
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">
{label}
{required && <span className="text-destructive ml-0.5">*</span>}
</label>
{children}
</div>
);
}
@@ -1,6 +1,6 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { FileText, Code2, Sparkles, Loader2 } from "lucide-react";
import { FileText, Code2, Sparkles, Loader2, PenLine } from "lucide-react";
import { Button } from "@/components/ui/button";
import { createCanvasThread } from "../api/threads";
@@ -86,6 +86,23 @@ export default function CanvasHomePage() {
))}
</div>
{/* AI 智能写作入口 */}
<button
onClick={() => navigate('ai-writing')}
className="group w-full flex items-center gap-4 rounded-xl border border-orange-200 dark:border-orange-900/50 bg-orange-50/50 dark:bg-orange-950/20 p-5 text-left transition-all hover:border-orange-400/60 hover:shadow-md hover:bg-orange-50 dark:hover:bg-orange-950/30 focus:outline-none focus-visible:ring-2 focus-visible:ring-orange-400"
>
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-orange-100 dark:bg-orange-900/40 shrink-0 group-hover:bg-orange-200/80 dark:group-hover:bg-orange-800/40 transition-colors">
<PenLine className="w-5 h-5 text-orange-500 dark:text-orange-400" />
</div>
<div className="min-w-0 flex-1">
<p className="font-semibold text-sm text-orange-700 dark:text-orange-300">AI 智能写作</p>
<p className="text-xs text-orange-600/70 dark:text-orange-400/60 leading-relaxed mt-0.5">
多智能体协作——搜集素材、规划大纲、起草正文、编辑审核,全流程自动完成
</p>
</div>
<span className="text-orange-400/60 shrink-0 text-xs">→</span>
</button>
<div className="text-center">
<Button
variant="ghost"
@@ -2,11 +2,15 @@ import { Route, Routes } from "react-router-dom";
import CanvasHomePage from "../pages/CanvasHomePage";
import CanvasThreadPage from "../pages/CanvasThreadPage";
import AIWritingPage from "../pages/AIWritingPage";
import ArticleTypesConfigPage from "../pages/ArticleTypesConfigPage";
export default function OpenCanvasRoutes() {
return (
<Routes>
<Route index element={<CanvasHomePage />} />
<Route path="ai-writing" element={<AIWritingPage />} />
<Route path="article-types" element={<ArticleTypesConfigPage />} />
<Route path=":thread_id" element={<CanvasThreadPage />} />
</Routes>
);
@@ -0,0 +1,224 @@
// ── 素材 ─────────────────────────────────────────────────────────────────────
export interface AIMaterial {
id: string;
title: string;
content: string;
source: string;
publishedAt: string;
relevanceScore: number;
sourceType: 'internet' | 'intranet';
url?: string;
recUuid?: string;
}
// ── 大纲 ─────────────────────────────────────────────────────────────────────
export interface OutlineSection {
sectionTitle: string;
keyPoints: string[];
materialIds: string[];
}
export interface AIOutline {
title: string;
sections: OutlineSection[];
}
// ── 编辑审核 ──────────────────────────────────────────────────────────────────
export interface ReviewIssue {
category: 'fact' | 'logic' | 'language';
severity: 'major' | 'minor';
location: string;
issue: string;
suggestion: string;
}
export interface ReviewResult {
verdict: 'pass' | 'reject';
factScore: number;
logicScore: number;
languageScore: number;
overallScore: number;
passThreshold: number;
revisionNotes: ReviewIssue[];
}
// ── 写作状态 ──────────────────────────────────────────────────────────────────
export type WritingStatus =
| 'idle'
| 'researching'
| 'awaiting_material_confirm'
| 'writing_outline'
| 'awaiting_outline_confirm'
| 'writing_draft'
| 'awaiting_draft_confirm'
| 'reviewing'
| 'awaiting_review_confirm'
| 'revising'
| 'done'
| 'error';
// ── SSE 进度事件 ──────────────────────────────────────────────────────────────
export type 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';
export interface ProgressEvent {
type: ProgressEventType;
sessionId: string;
agentName?: string;
message: string;
// notice(非致命告警)/ error:标题 + 真实原因
title?: string;
detail?: string;
// 接口解析失败时的原始错误响应体,前端默认折叠展示
raw?: string;
// materials_ready
materials?: AIMaterial[];
keywords?: string[];
materialSummary?: string;
// outline_ready
outline?: AIOutline;
// draft_ready / draft_chunk
draftMarkdown?: string;
draftTitle?: string;
revisionCount?: number;
// review_item_done (streaming per-category result)
reviewItemCategory?: 'fact' | 'logic' | 'language';
reviewItemScore?: number;
reviewItemIssues?: ReviewIssue[];
// review_ready
reviewResult?: ReviewResult;
// await_user
pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
pauseContext?: Record<string, unknown>;
}
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
export interface UserInterventionPayload {
action: string;
approvedMaterialIds?: string[];
extraKeywords?: string[];
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
overrideVerdict?: boolean;
// 用户勾选的审核意见索引(基于 reviewResult.revisionNotes),未传则默认全选
selectedIssueIndices?: number[];
}
// ── 写作请求 ──────────────────────────────────────────────────────────────────
export interface AIWritingRequest {
userIntent: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
modelName?: string;
}
// ── 流式审核条目 ──────────────────────────────────────────────────────────────
export interface StreamingReviewItem {
category: 'fact' | 'logic' | 'language';
// 是否已生成完毕(true 时展示结构化评分/意见,false 时展示流式打字机文本)
isDone: boolean;
// 流式过程文本(正文 / 思考分开)
streamingText: string;
thinkingText: string;
// 完成后的结构化结果
score: number;
issues: ReviewIssue[];
}
// ── 修改摘要 ──────────────────────────────────────────────────────────────────
export interface RevisionSummary {
revisedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
newWordCount: number;
}
// ── 流式草稿章节 ──────────────────────────────────────────────────────────────
export interface StreamingDraftSection {
sectionIndex: number;
sectionTitle: string;
text: string; // 正文内容(非思考)
thinkingText: string; // 思考内容(<think>...</think> 内)
isDone: boolean;
}
// ── 已完成干预记录 ────────────────────────────────────────────────────────────
export interface CompletedIntervention {
pausePoint: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm';
action: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
outline?: AIOutline;
// draft_confirm
draftTitle?: string;
draftWordCount?: number;
reviewResult?: ReviewResult;
// review_confirm
}
// ── Hook 状态 ─────────────────────────────────────────────────────────────────
export interface AIWritingState {
sessionId: string | null;
status: WritingStatus;
request: AIWritingRequest | null;
isPaused: boolean;
progressEvents: ProgressEvent[];
materials: AIMaterial[];
outline: AIOutline | null;
currentDraftMarkdown: string;
currentDraftTitle: string;
reviewResult: ReviewResult | null;
revisionCount: number;
currentPause: ProgressEvent | null;
completedInterventions: CompletedIntervention[];
error: string | null;
// 流式草稿
streamingDraftTitle: string;
streamingDraftSections: StreamingDraftSection[];
// 流式大纲(正文 / 思考分开)
outlineStreamingText: string;
outlineThinkingText: string;
// 素材收集实时进度(最新一条提示文案)
researchProgress: string;
// 流式审核(逐条到达)
streamingReviewItems: StreamingReviewItem[];
// 修改摘要
revisionSummary: RevisionSummary | null;
// 待修改上下文(用于生成摘要)
pendingRevisionContext: {
submittedIssues: ReviewIssue[];
skippedIssues: ReviewIssue[];
oldWordCount: number;
} | null;
}
@@ -13,3 +13,10 @@ export type CanvasSelectionState = {
textSelection: CanvasTextSelection | null;
codeSelection: CanvasCodeSelection | null;
};
/** Tiptap editor selection — used by AI rewrite flow */
export type WritingSelection = {
from: number;
to: number;
text: string;
};
@@ -0,0 +1,51 @@
import type { Editor } from "@tiptap/react";
import type { WritingSelection } from "../types/selection";
/** Convert plain text (possibly multi-paragraph) to a list of Tiptap paragraph nodes. */
function textToNodes(schema: Editor["schema"], text: string) {
const paragraphs = text.split(/\n{2,}/);
return paragraphs
.map((p) => p.trim())
.filter(Boolean)
.map((p) => ({
type: "paragraph",
content: p ? [{ type: "text", text: p }] : [],
}));
}
/**
* Replace the selection range with the new text.
* Restores focus and places cursor after the inserted content.
*/
export function applyReplace(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { from, to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor
.chain()
.focus()
.deleteRange({ from, to })
.insertContentAt(from, nodes)
.run();
}
/**
* Insert the new text as new paragraph(s) after the selection end.
* Original text is preserved.
*/
export function applyInsert(
editor: Editor,
selection: WritingSelection,
newText: string,
): void {
const { to } = selection;
const nodes = textToNodes(editor.schema, newText);
if (nodes.length === 0) return;
editor.chain().focus().insertContentAt(to, nodes).run();
}
@@ -1,16 +1,34 @@
import type { CanvasArtifactContent } from "../types/artifact";
import type { QuickActionId } from "../types/quick-action";
import { isCodeArtifact } from "./artifact";
const QUICK_ACTION_MESSAGES: Record<string, string> = {
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
expand: "请对当前内容进行扩写,增加深度和细节,保持原有结构不变",
shorten: "请对当前内容进行精简,提炼核心要点,去除冗余",
formalize: "请将当前内容改写为正式、专业的语气",
casualize: "请将当前内容改写为口语化、友好的语气",
add_comments: "请为当前代码添加清晰实用的注释",
add_logs: "请为当前代码在关键位置添加适当的日志语句",
fix_bugs: "请检查并修复当前代码中的所有 Bug",
optimize_code: "请对当前代码进行性能和可读性优化",
};
export function buildQuickActionMessage(id: QuickActionId): string {
return QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
/**
* Builds the human message for a quick action.
* The current artifact content is always included so the agent knows what to edit —
* LangGraph's create_agent only passes state.messages to the LLM, not state.artifact.
*/
export function buildQuickActionMessage(
id: QuickActionId,
content: CanvasArtifactContent,
): string {
const instruction = QUICK_ACTION_MESSAGES[id] ?? `请对当前内容执行操作:${id}`;
const artifactContent = isCodeArtifact(content) ? content.code : content.fullMarkdown;
const tag = isCodeArtifact(content) ? `language="${content.language}"` : "";
return `${instruction}
<artifact ${tag}>
${artifactContent}
</artifact>`;
}
@@ -0,0 +1,12 @@
/**
* 中文语境下的「字数」统计,与后端 `nodes/_utils.py:count_words` 保持同一口径:
* 汉字逐字计 + 中文标点逐个计 + 连续数字/英文各算 1 字。
* 不计入空白、markdown 语法符号(# * - 等)与 ASCII 标点。
*/
export function countWords(text: string): number {
if (!text) return 0;
const cjk = (text.match(/[一-鿿]/g) ?? []).length;
const cjkPunct = (text.match(/[,。、;:?!“”‘’()《》【】「」『』〈〉—…·~]/g) ?? []).length;
const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length;
return cjk + cjkPunct + alnumRuns;
}
@@ -0,0 +1,491 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import {
Download,
Edit3,
Eye,
Pause,
Play,
RefreshCw,
Search,
Trash2,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Textarea } from "@/components/ui/textarea";
import {
WorkspaceBody,
WorkspaceContainer,
WorkspaceHeader,
} from "@/components/workspace/workspace-container";
import {
buildCron,
DEFAULT_SCHEDULE_CONFIG,
parseCron,
ScheduleBuilder,
type ScheduleConfig,
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import {
downloadScheduledTaskRun,
type ScheduledTask,
useAdminScheduledTaskActions,
useAdminScheduledTaskRuns,
useAllScheduledTasks,
} from "@/core/scheduled-tasks";
import { cn } from "@/lib/utils";
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
/** Render a creator-like identifier without the email domain part. */
function displayUsername(value?: string | null): string {
const raw = (value ?? "").trim();
if (!raw) return "";
const at = raw.indexOf("@");
return at === -1 ? raw : raw.slice(0, at);
}
const emptyForm = {
name: "",
timezone: "Asia/Shanghai",
prompt: "",
};
export default function AdminScheduledTasksPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// ?task= carries the task to re-select when returning from a run detail.
const initialTaskId = searchParams.get("task");
const [searchInput, setSearchInput] = useState("");
const [search, setSearch] = useState("");
const { data: tasks = [], isLoading } = useAllScheduledTasks({ search });
const actions = useAdminScheduledTaskActions();
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(
initialTaskId,
);
// One-shot scroll target after a back-navigation.
const pendingScrollRef = useRef<string | null>(initialTaskId);
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [form, setForm] = useState(emptyForm);
const [scheduleConfig, setScheduleConfig] = useState<ScheduleConfig>(
DEFAULT_SCHEDULE_CONFIG,
);
const sortedTasks = useMemo(
() =>
[...tasks].sort((a, b) =>
String(a.next_run_at ?? "").localeCompare(String(b.next_run_at ?? "")),
),
[tasks],
);
const selectedTask =
sortedTasks.find((task) => task.task_id === selectedTaskId) ?? null;
const { data: runs = [], isLoading: runsLoading } =
useAdminScheduledTaskRuns(selectedTaskId);
useEffect(() => {
if (!selectedTaskId && sortedTasks[0]) {
setSelectedTaskId(sortedTasks[0].task_id);
}
if (
selectedTaskId &&
!sortedTasks.some((task) => task.task_id === selectedTaskId)
) {
setSelectedTaskId(sortedTasks[0]?.task_id ?? null);
}
}, [selectedTaskId, sortedTasks]);
const openEdit = (task: ScheduledTask) => {
setEditing(task);
setForm({
name: task.name,
timezone: task.timezone,
prompt: task.prompt,
});
setScheduleConfig(
parseCron(task.cron_expr) ??
parseCron(task.schedule_text) ??
DEFAULT_SCHEDULE_CONFIG,
);
};
const closeDialog = () => {
setEditing(null);
setForm(emptyForm);
setScheduleConfig(DEFAULT_SCHEDULE_CONFIG);
};
const saveTask = async () => {
if (!editing) return;
try {
await actions.update.mutateAsync({
taskId: editing.task_id,
data: {
name: form.name,
schedule: buildCron(scheduleConfig),
timezone: form.timezone,
prompt: form.prompt,
},
});
toast.success("任务已更新");
closeDialog();
} catch (error) {
toast.error(error instanceof Error ? error.message : "保存失败");
}
};
const toggleEnabled = async (task: ScheduledTask) => {
try {
await actions.update.mutateAsync({
taskId: task.task_id,
data: { enabled: !task.enabled },
});
toast.success(task.enabled ? "任务已暂停" : "任务已启用");
} catch (error) {
toast.error(error instanceof Error ? error.message : "操作失败");
}
};
const removeTask = async (task: ScheduledTask) => {
if (!window.confirm(`确认删除任务「${task.name}」?该操作不可撤销。`)) return;
try {
await actions.remove.mutateAsync(task.task_id);
toast.success("任务已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
const triggerTask = async (task: ScheduledTask) => {
try {
await actions.trigger.mutateAsync(task.task_id);
toast.success("已触发执行");
} catch (error) {
toast.error(error instanceof Error ? error.message : "触发失败");
}
};
const downloadRun = async (runId: string) => {
try {
await downloadScheduledTaskRun(runId);
} catch (error) {
toast.error(error instanceof Error ? error.message : "下载失败");
}
};
const deleteRun = async (runId: string) => {
if (!window.confirm("确认删除这条执行记录?该操作不可撤销。")) return;
try {
await actions.removeRun.mutateAsync(runId);
toast.success("执行记录已删除");
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
};
return (
<WorkspaceContainer>
<WorkspaceHeader />
<WorkspaceBody>
<div className="grid size-full min-h-0 grid-cols-1 gap-4 p-4 xl:grid-cols-[460px_minmax(0,1fr)]">
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-3">
<div>
<CardTitle>定时任务管理</CardTitle>
<p className="text-muted-foreground mt-1 text-sm">
管理员视图:查看、编辑、删除全部用户的定时任务
</p>
</div>
<form
className="flex gap-2"
onSubmit={(event) => {
event.preventDefault();
setSearch(searchInput.trim());
}}
>
<Input
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="按任务名称搜索"
/>
<Button type="submit" variant="outline">
<Search className="size-4" />
搜索
</Button>
</form>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-3 p-4 pt-0">
{isLoading && (
<div className="text-muted-foreground text-sm">
正在加载任务...
</div>
)}
{!isLoading && sortedTasks.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
没有匹配的定时任务。
</div>
)}
{sortedTasks.map((task) => (
<button
key={task.task_id}
ref={(el) => {
if (el && pendingScrollRef.current === task.task_id) {
pendingScrollRef.current = null;
el.scrollIntoView({ block: "center" });
}
}}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id &&
"border-primary bg-accent",
)}
onClick={() => setSelectedTaskId(task.task_id)}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{task.name}
</div>
<div className="text-muted-foreground mt-1 truncate text-xs">
{task.schedule_text} · {task.timezone}
</div>
</div>
<Badge variant={task.enabled ? "default" : "secondary"}>
{task.enabled ? "启用" : "暂停"}
</Badge>
</div>
<div className="text-muted-foreground mt-3 grid gap-1 text-xs">
<div>
创建者:
{displayUsername(task.creator_email) ||
task.creator_user_id ||
"未知"}
</div>
<div>下次执行:{formatDate(task.next_run_at)}</div>
<div>上次执行:{formatDate(task.last_run_at)}</div>
<div>最近状态:{statusLabel(task.last_status)}</div>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button
size="icon"
variant="outline"
title="编辑"
onClick={(event) => {
event.stopPropagation();
openEdit(task);
}}
>
<Edit3 className="size-4" />
</Button>
<Button
size="icon"
variant="outline"
title={task.enabled ? "暂停" : "启用"}
onClick={(event) => {
event.stopPropagation();
void toggleEnabled(task);
}}
>
{task.enabled ? (
<Pause className="size-4" />
) : (
<Play className="size-4" />
)}
</Button>
<Button
size="icon"
variant="outline"
title="立即执行"
onClick={(event) => {
event.stopPropagation();
void triggerTask(task);
}}
>
<RefreshCw className="size-4" />
</Button>
<Button
size="icon"
variant="destructive"
title="删除"
onClick={(event) => {
event.stopPropagation();
void removeTask(task);
}}
>
<Trash2 className="size-4" />
</Button>
</div>
</button>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
<Card className="flex min-h-0 flex-col">
<CardHeader className="gap-2">
<CardTitle>执行记录</CardTitle>
<p className="text-muted-foreground text-sm">
{selectedTask ? selectedTask.name : "请选择左侧任务"}
</p>
</CardHeader>
<CardContent className="min-h-0 flex-1 p-0">
<ScrollArea className="size-full">
<div className="flex flex-col gap-3 p-4 pt-0">
{runsLoading && (
<div className="text-muted-foreground text-sm">
正在加载执行记录...
</div>
)}
{!selectedTask && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
请先选择一个定时任务。
</div>
)}
{!runsLoading && selectedTask && runs.length === 0 && (
<div className="text-muted-foreground rounded-md border p-4 text-sm">
这个任务还没有执行记录。
</div>
)}
{runs.map((run) => (
<div
key={run.id}
className="rounded-md border p-4 transition-colors hover:bg-accent/40"
>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<div className="truncate font-medium">
{run.result?.title || formatDate(run.scheduled_for)}
</div>
<div className="text-muted-foreground mt-1 text-xs">
计划:{formatDate(run.scheduled_for)} · 开始:
{formatDate(run.started_at)}
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{(run.retry_count ?? 0) > 0 && (
<Badge variant="outline" className="text-xs">
已重试 {run.retry_count}
</Badge>
)}
<Badge variant={statusVariant(run.status)}>
{statusLabel(run.status)}
</Badge>
</div>
</div>
{run.error && (
<div className="text-destructive mt-3 line-clamp-2 text-sm">
{run.error}
</div>
)}
<div className="mt-4 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() =>
navigate(
`/page/workspace/scheduled/runs/${run.id}?from=admin&task=${encodeURIComponent(run.task_id)}`,
)
}
>
<Eye className="size-4" />
查看结果
</Button>
<Button
size="sm"
variant="outline"
disabled={!run.result}
onClick={() => void downloadRun(run.id)}
>
<Download className="size-4" />
下载结果
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => void deleteRun(run.id)}
>
<Trash2 className="size-4" />
删除
</Button>
</div>
</div>
))}
</div>
</ScrollArea>
</CardContent>
</Card>
</div>
</WorkspaceBody>
<Dialog open={!!editing} onOpenChange={(open) => !open && closeDialog()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>编辑定时任务</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3">
<Input
value={form.name}
onChange={(event) =>
setForm((value) => ({ ...value, name: event.target.value }))
}
placeholder="任务名称"
/>
<div className="flex flex-col gap-1">
<p className="text-muted-foreground text-xs">执行频率</p>
<ScheduleBuilder
value={scheduleConfig}
onChange={setScheduleConfig}
/>
</div>
<Input
value={form.timezone}
onChange={(event) =>
setForm((value) => ({
...value,
timezone: event.target.value,
}))
}
placeholder="时区,例如:Asia/Shanghai"
/>
<Textarea
className="min-h-40"
value={form.prompt}
onChange={(event) =>
setForm((value) => ({ ...value, prompt: event.target.value }))
}
placeholder="任务 Prompt"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
取消
</Button>
<Button onClick={saveTask}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkspaceContainer>
);
}
+19 -1
View File
@@ -1,10 +1,13 @@
"use client";
import { DownloadIcon } from "lucide-react";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scroll-area";
import { ImportConversationsDialog } from "@/components/workspace/thread-share-dialogs";
import {
WorkspaceBody,
WorkspaceContainer,
@@ -20,6 +23,7 @@ export default function ChatsPage() {
const { t } = useI18n();
const { data: threads } = useThreads();
const [search, setSearch] = useState("");
const [importOpen, setImportOpen] = useState(false);
useEffect(() => {
document.title = `${t.pages.chats} - ${t.pages.appName}`;
@@ -38,7 +42,17 @@ export default function ChatsPage() {
<WorkspaceHeader></WorkspaceHeader>
<WorkspaceBody>
<div className="flex size-full flex-col">
<header className="flex shrink-0 items-center justify-center pt-8">
<header className="flex shrink-0 flex-col items-center gap-3 pt-8">
<div className="flex w-full max-w-(--container-width-md) items-center justify-end">
<Button
variant="outline"
size="sm"
onClick={() => setImportOpen(true)}
>
<DownloadIcon className="size-4" />
{t.threadShare.importChats}
</Button>
</div>
<Input
type="search"
className="h-12 w-full max-w-(--container-width-md) text-xl"
@@ -70,6 +84,10 @@ export default function ChatsPage() {
</main>
</div>
</WorkspaceBody>
<ImportConversationsDialog
open={importOpen}
onOpenChange={setImportOpen}
/>
</WorkspaceContainer>
);
}
+105
View File
@@ -0,0 +1,105 @@
"use client";
import type { BaseStream } from "@langchain/langgraph-sdk/react";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useMemo } from "react";
import { useParams } from "react-router-dom";
import { ArtifactsProvider } from "@/components/workspace/artifacts";
import { MessageList } from "@/components/workspace/messages";
import { ThreadContext } from "@/components/workspace/messages/context";
import { SubtasksProvider } from "@/core/tasks/context";
import { getPublicShareSnapshot } from "@/core/thread-shares/api";
import type { AgentThreadState } from "@/core/threads";
/**
* Public, read-only conversation viewer.
*
* Reachable at `#/share/<code>` with NO authentication — anyone with the link
* can open it. It renders a frozen snapshot of a `view`-mode shared
* conversation; there is no input box, so viewers cannot send new messages.
*/
export default function PublicSharePage() {
const { shareCode } = useParams<{ shareCode: string }>();
const { data, isLoading, isError } = useQuery({
queryKey: ["public-share", shareCode],
queryFn: () => getPublicShareSnapshot(shareCode!),
enabled: !!shareCode,
retry: false,
refetchOnWindowFocus: false,
});
// A minimal static stand-in for the live LangGraph stream. MessageList only
// reads `messages`, `values.artifacts`, `isLoading` and `isThreadLoading`.
const thread = useMemo(() => {
const messages = data?.messages ?? [];
return {
messages,
values: {
title: data?.title ?? "",
messages,
artifacts: data?.artifacts ?? [],
todos: [],
},
isLoading: false,
isThreadLoading: false,
error: undefined,
interrupt: undefined,
} as unknown as BaseStream<AgentThreadState>;
}, [data]);
if (isLoading) {
return (
<div className="bg-background text-muted-foreground flex h-screen items-center justify-center gap-2 text-sm">
<Loader2 className="size-4 animate-spin" />
正在加载分享内容…
</div>
);
}
if (isError || !data) {
return (
<div className="bg-background flex h-screen flex-col items-center justify-center gap-2 px-6 text-center">
<p className="text-lg font-medium">分享不可用</p>
<p className="text-muted-foreground text-sm">
该分享链接不存在、已被撤销,或不是只读分享。
</p>
</div>
);
}
return (
<div className="bg-background flex h-screen flex-col">
<header className="flex shrink-0 flex-col gap-1 border-b px-6 py-3">
<div className="flex items-center gap-2">
<h1 className="truncate text-base font-semibold">
{data.title || "未命名对话"}
</h1>
<span className="bg-muted text-muted-foreground shrink-0 rounded-full px-2 py-0.5 text-xs">
只读分享
</span>
</div>
<p className="text-muted-foreground text-xs">
这是一个只读对话快照,你可以查看全部内容,但无法继续提问。
</p>
</header>
<main className="min-h-0 flex-1">
<SubtasksProvider>
<ArtifactsProvider>
<ThreadContext.Provider
value={{ thread, isMock: true, publicShareCode: shareCode }}
>
<MessageList
threadId={shareCode ?? "public-share"}
thread={thread}
paddingBottom={48}
/>
</ThreadContext.Provider>
</ArtifactsProvider>
</SubtasksProvider>
</main>
</div>
);
}
@@ -1,8 +1,8 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { ChevronDown, Download, Edit3, FileText, ListChecks, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams } from "react-router-dom";
import { Activity, ChevronDown, ChevronLeft, Download, Edit3, FileText, Loader2, Save, Trash2, X } from "lucide-react";
import { useParams, useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -24,9 +24,11 @@ import {
getScheduledTaskRunFileContent,
type ScheduledTaskResultFileContent,
type ScheduledTaskRun,
type ScheduledTaskRunMessage,
updateScheduledTaskRunFile,
useScheduledTaskActions,
useScheduledTaskRun,
useScheduledTaskRunMessages,
} from "@/core/scheduled-tasks";
import { downloadMarkdownAsDocx } from "@/core/scheduled-tasks/markdown-docx";
import { cn } from "@/lib/utils";
@@ -97,12 +99,117 @@ function RenderPreview({ file }: { file: ScheduledTaskResultFileContent }) {
type EditState = { draft: string; saving: boolean };
/** Flatten a LangChain-style message content (string | parts | nested) to text. */
function messageText(content: unknown): string {
if (content == null) return "";
if (typeof content === "string") return content;
if (Array.isArray(content)) {
return content
.map((part) => {
if (typeof part === "string") return part;
if (part && typeof part === "object" && "text" in part) {
return String((part as { text?: unknown }).text ?? "");
}
return "";
})
.filter(Boolean)
.join("\n");
}
if (typeof content === "object" && "content" in content) {
return messageText((content as { content?: unknown }).content);
}
return JSON.stringify(content, null, 2);
}
const EXECUTION_ROLE: Record<string, { label: string; tone: string }> = {
"llm.human.input": { label: "触发输入", tone: "bg-muted text-foreground" },
"llm.ai.response": { label: "智能体", tone: "bg-primary/10 text-primary" },
"llm.tool.result": { label: "工具结果", tone: "bg-amber-500/10 text-amber-600 dark:text-amber-400" },
};
/** One agent message rendered inside the execution-process tab. */
function ExecutionMessage({ message }: { message: ScheduledTaskRunMessage }) {
const role = EXECUTION_ROLE[message.event_type] ?? {
label: message.event_type,
tone: "bg-muted text-foreground",
};
const raw = message.content;
const obj = raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
const text = messageText(raw);
const toolName = typeof obj?.name === "string" ? obj.name : null;
const toolCalls = Array.isArray(obj?.tool_calls)
? (obj!.tool_calls as Array<Record<string, unknown>>)
: [];
const isAi = message.event_type === "llm.ai.response";
return (
<div className="rounded-md border">
<div className="flex items-center gap-2 border-b px-3 py-1.5">
<span className={cn("rounded px-1.5 py-0.5 text-xs font-medium", role.tone)}>
{role.label}
{toolName ? ` · ${toolName}` : ""}
</span>
{message.created_at && (
<span className="text-muted-foreground text-xs">
{new Date(message.created_at).toLocaleString()}
</span>
)}
</div>
<div className="p-3">
{text ? (
isAi ? (
<MarkdownContent className="max-w-none" content={text} isLoading={false} rehypePlugins={[]} />
) : (
<pre className="whitespace-pre-wrap break-words text-xs">{text}</pre>
)
) : (
!toolCalls.length && <div className="text-muted-foreground text-xs">(无文本内容)</div>
)}
{toolCalls.length > 0 && (
<div className="mt-2 flex flex-col gap-1">
{toolCalls.map((call, index) => (
<div key={index} className="bg-muted rounded px-2 py-1 text-xs">
<span className="font-medium">调用工具:{String(call.name ?? "未知")}</span>
{call.args != null && (
<pre className="mt-1 whitespace-pre-wrap break-words">
{typeof call.args === "string" ? call.args : JSON.stringify(call.args, null, 2)}
</pre>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}
export default function ScheduledTaskRunDetailPage() {
const navigate = useNavigate();
const { run_id } = useParams();
const [searchParams] = useSearchParams();
const actions = useScheduledTaskActions();
const { data: run, isLoading } = useScheduledTaskRun(run_id);
const result = run?.result ?? null;
// A run still executing — drives live polling of its agent messages.
const runLive = run?.status === "running" || run?.status === "pending";
const hasExecution = !!run?.agent_run_id;
const { data: messagesData, isLoading: messagesLoading } = useScheduledTaskRunMessages(
hasExecution ? run_id : null,
{ live: runLive },
);
const runMessages = messagesData?.messages ?? [];
// Back navigation returns to the originating list (admin vs. mine) and
// re-selects the task the user came from (carried in ?task=).
const handleBack = useCallback(() => {
const base =
searchParams.get("from") === "admin"
? "/page/workspace/admin/scheduled-tasks"
: "/page/workspace/scheduled/tasks";
const taskId = run?.task_id ?? searchParams.get("task");
navigate(taskId ? `${base}?task=${encodeURIComponent(taskId)}` : base);
}, [navigate, run?.task_id, searchParams]);
const [fileContents, setFileContents] = useState<ScheduledTaskResultFileContent[]>([]);
const [filesLoading, setFilesLoading] = useState(false);
const [showMeta, setShowMeta] = useState(false);
@@ -257,7 +364,7 @@ export default function ScheduledTaskRunDetailPage() {
try {
await actions.removeRun.mutateAsync(run.id);
toast.success("执行记录已删除");
navigate("/page/workspace/scheduled/tasks");
handleBack();
} catch (error) {
toast.error(error instanceof Error ? error.message : "删除失败");
}
@@ -281,9 +388,9 @@ export default function ScheduledTaskRunDetailPage() {
<ChevronDown className={cn("size-3 transition-transform", showMeta && "rotate-180")} />
执行信息
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={() => navigate("/page/workspace/scheduled/tasks")}>
<ListChecks className="size-3" />
返回记录
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleBack}>
<ChevronLeft className="size-3" />
返回
</Button>
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs" onClick={handleDownloadResult} disabled={!result}>
<Download className="size-3" />
@@ -330,28 +437,45 @@ export default function ScheduledTaskRunDetailPage() {
</div>
)}
{/* 内容区 */}
{!result && run && (
{run && !result && !hasExecution && (
<div className="text-muted-foreground p-4 text-sm">
这条记录暂时还没有结果,任务可能仍在执行中。
</div>
)}
{result && (
<Tabs defaultValue={fileCount > 0 ? "files" : "body"} className="flex min-h-0 flex-1 flex-col">
{run && (result || hasExecution) && (
<Tabs
defaultValue={result ? (fileCount > 0 ? "files" : "body") : "execution"}
className="flex min-h-0 flex-1 flex-col"
>
<div className="border-b px-3 pt-1">
<TabsList className="h-8">
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
{result && (
<TabsTrigger value="body" className="text-xs">
<FileText className="mr-1 size-3" />
正文
</TabsTrigger>
)}
{result && (
<TabsTrigger value="files" className="text-xs">
结果文件{fileCount > 0 && `(${fileCount})`}
</TabsTrigger>
)}
{hasExecution && (
<TabsTrigger value="execution" className="text-xs">
<Activity className="mr-1 size-3" />
执行过程{runMessages.length > 0 && `(${runMessages.length})`}
</TabsTrigger>
)}
{result && (
<TabsTrigger value="meta" className="text-xs">
元数据
</TabsTrigger>
)}
</TabsList>
</div>
<ScrollArea className="min-h-0 flex-1">
{result && (
<>
<TabsContent value="body" className="m-0 p-4">
{result.format === "markdown" || result.format === "mixed" ? (
<MarkdownContent
@@ -471,6 +595,31 @@ export default function ScheduledTaskRunDetailPage() {
{resultMetadata}
</pre>
</TabsContent>
</>
)}
{hasExecution && (
<TabsContent value="execution" className="m-0 p-3">
{runLive && (
<div className="text-primary border-primary/30 bg-primary/5 mb-3 flex items-center gap-2 rounded-md border p-2 text-xs">
<Loader2 className="size-3 animate-spin" />
任务执行中,下方消息每 4 秒自动刷新…
</div>
)}
{messagesLoading && runMessages.length === 0 && (
<div className="text-muted-foreground text-sm">正在加载执行消息...</div>
)}
{!messagesLoading && runMessages.length === 0 && (
<div className="text-muted-foreground rounded-md border p-3 text-sm">
暂无执行消息,任务可能还未开始执行。
</div>
)}
<div className="flex flex-col gap-3">
{runMessages.map((message) => (
<ExecutionMessage key={message.seq} message={message} />
))}
</div>
</TabsContent>
)}
</ScrollArea>
</Tabs>
)}
+29 -216
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import {
BellOff,
BellRing,
@@ -18,6 +18,7 @@ import {
TagsIcon,
Trash2,
} from "lucide-react";
import { useSearchParams } from "react-router-dom";
import { useRouteNavigate as useNavigate } from "@/core/route-base";
import { toast } from "sonner";
@@ -51,199 +52,17 @@ import {
useScheduledTasks,
usePublishedScheduledTasks,
} from "@/core/scheduled-tasks";
import {
buildCron,
DEFAULT_SCHEDULE_CONFIG,
parseCron,
ScheduleBuilder,
type ScheduleConfig,
statusLabel,
statusVariant,
} from "@/components/workspace/scheduled-tasks/schedule-builder";
import { cn } from "@/lib/utils";
// ---- 时间配置 ----
type ScheduleFreq = "hourly" | "daily" | "weekly" | "monthly";
interface ScheduleConfig {
freq: ScheduleFreq;
minute: number;
hour: number;
weekday: number;
date: number;
}
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);
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 as "every day at 09:00" — or, when natural-language
// resolved to "0 * * * *" on the backend, the picker honestly reflected
// that as "每小时 0 分". Either way the user lost their original intent.
//
// Caller now prefers `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 [STAR]/3 * * *") we sometimes generate.
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;
}
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>
);
}
function formatDate(value?: string | null) {
return value ? new Date(value).toLocaleString() : "暂无";
}
@@ -256,28 +75,6 @@ function displayUsername(value?: string | null): string {
return at === -1 ? raw : raw.slice(0, at);
}
function statusLabel(status?: string | null) {
switch (status) {
case "succeeded":
return "成功";
case "failed":
return "失败";
case "running":
return "执行中";
case "pending":
return "等待中";
default:
return status || "未知";
}
}
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";
}
const emptyForm = {
name: "",
schedule: "",
@@ -288,6 +85,10 @@ const emptyForm = {
export default function ScheduledTasksPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
// When returning from a run-detail page the task id is carried in ?task=,
// so the list re-selects (and scrolls to) the task the user was viewing.
const initialTaskId = searchParams.get("task");
const [search, setSearch] = useState("");
const [tagId, setTagId] = useState<string | null>(null);
// Name + tag filtering is resolved server-side via the /api/scheduled-tasks query.
@@ -298,7 +99,9 @@ export default function ScheduledTasksPage() {
const [activeTab, setActiveTab] = useState<"mine" | "plaza">("mine");
const { data: published = [], isLoading: publishedLoading } =
usePublishedScheduledTasks(activeTab === "plaza");
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(initialTaskId);
// One-shot scroll target: scroll the task into view after a back-navigation.
const pendingScrollRef = useRef<string | null>(initialTaskId);
const [elementUserId, setElementUserId] = useState("");
const [editing, setEditing] = useState<ScheduledTask | null>(null);
const [creating, setCreating] = useState(false);
@@ -504,6 +307,12 @@ export default function ScheduledTasksPage() {
return (
<button
key={task.task_id}
ref={(el) => {
if (el && pendingScrollRef.current === task.task_id) {
pendingScrollRef.current = null;
el.scrollIntoView({ block: "center" });
}
}}
className={cn(
"bg-card text-card-foreground rounded-md border p-4 text-left transition-colors hover:bg-accent/60",
selectedTaskId === task.task_id && "border-primary bg-accent",
@@ -802,7 +611,11 @@ export default function ScheduledTasksPage() {
<Button
size="sm"
variant="outline"
onClick={() => navigate(`/page/workspace/scheduled/runs/${run.id}`)}
onClick={() =>
navigate(
`/page/workspace/scheduled/runs/${run.id}?from=mine&task=${encodeURIComponent(run.task_id)}`,
)
}
>
<Eye className="size-4" />
查看结果
@@ -18,6 +18,7 @@ import ChatPage from "./ChatPage";
import AgentsPage from "./AgentsPage";
import AgentChatPage from "./AgentChatPage";
import NewAgentPage from "./NewAgentPage";
import AdminScheduledTasksPage from "./AdminScheduledTasksPage";
import ScheduledTaskRunDetailPage from "./ScheduledTaskRunDetailPage";
import ScheduledTasksPage from "./ScheduledTasksPage";
import LlmMetricsPage from "./LlmMetricsPage";
@@ -193,6 +194,18 @@ export default function WorkspaceRoutes({
</WorkspaceLayout>
}
/>
<Route
path="admin/scheduled-tasks"
element={
getStoredAuth()?.system_role === "admin" ? (
<WorkspaceLayout {...layoutProps}>
<AdminScheduledTasksPage />
</WorkspaceLayout>
) : (
<Navigate to="/page/workspace/chats/new" replace />
)
}
/>
<Route
path="admin/tags"
element={
+180
View File
@@ -566,6 +566,186 @@
}
}
/* ─── Tiptap writing editor ─────────────────────────────────────────────── */
.tiptap-writing-editor .ProseMirror {
outline: none;
min-height: 320px;
caret-color: var(--primary);
padding-bottom: 6rem; /* ensure last paragraph isn't flush against the bottom */
}
.tiptap-writing-editor .ProseMirror p {
margin-bottom: 1rem;
line-height: 1.8;
font-size: 0.9375rem; /* 15px */
color: var(--foreground);
text-indent: 2em; /* 段落首行缩进 */
}
.tiptap-writing-editor .ProseMirror h1 {
font-size: 1.625rem;
font-weight: 700;
letter-spacing: -0.025em;
margin-top: 2rem;
margin-bottom: 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
color: var(--foreground);
line-height: 1.25;
}
.tiptap-writing-editor .ProseMirror h1:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror h2 {
font-size: 1.25rem;
font-weight: 600;
letter-spacing: -0.015em;
margin-top: 1.75rem;
margin-bottom: 0.625rem;
color: var(--foreground);
line-height: 1.35;
}
.tiptap-writing-editor .ProseMirror h3 {
font-size: 1.0625rem;
font-weight: 600;
letter-spacing: -0.01em;
margin-top: 1.25rem;
margin-bottom: 0.5rem;
color: var(--foreground);
line-height: 1.4;
}
.tiptap-writing-editor .ProseMirror h2:first-child,
.tiptap-writing-editor .ProseMirror h3:first-child {
margin-top: 0;
}
.tiptap-writing-editor .ProseMirror strong {
font-weight: 600;
}
.tiptap-writing-editor .ProseMirror em {
font-style: italic;
}
.tiptap-writing-editor .ProseMirror s {
text-decoration: line-through;
opacity: 0.6;
}
.tiptap-writing-editor .ProseMirror ul {
list-style-type: disc;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror ol {
list-style-type: decimal;
margin-left: 1.5rem;
margin-bottom: 1rem;
font-size: 0.9375rem;
color: var(--foreground);
}
.tiptap-writing-editor .ProseMirror li {
line-height: 1.8;
margin-bottom: 0.25rem;
}
.tiptap-writing-editor .ProseMirror li > p {
margin-bottom: 0.25rem;
text-indent: 0; /* 列表项内段落不缩进 */
}
.tiptap-writing-editor .ProseMirror blockquote {
border-left: 3px solid var(--border);
padding-left: 1rem;
margin: 1.25rem 0;
font-style: italic;
font-size: 0.9375rem;
color: var(--muted-foreground);
}
.tiptap-writing-editor .ProseMirror pre {
background: var(--muted);
border-radius: 0.5rem;
padding: 1rem 1.25rem;
margin: 1rem 0 1.25rem;
overflow-x: auto;
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror pre code {
background: transparent;
padding: 0;
font-size: 0.8125rem; /* 13px */
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
line-height: 1.7;
}
.tiptap-writing-editor .ProseMirror code {
background: var(--muted);
padding: 0.15rem 0.4rem;
border-radius: 0.25rem;
font-size: 0.8125rem;
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
color: var(--foreground);
border: 1px solid var(--border);
}
.tiptap-writing-editor .ProseMirror hr {
border: none;
border-top: 1px solid var(--border);
margin: 2rem 0;
}
.tiptap-writing-editor .ProseMirror a {
color: var(--primary);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-thickness: 1px;
transition: opacity 0.15s;
}
.tiptap-writing-editor .ProseMirror a:hover {
opacity: 0.75;
}
/* Placeholder */
.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child {
text-indent: 0; /* 占位文案不缩进 */
}
.tiptap-writing-editor .ProseMirror p.is-editor-empty:first-child::before {
content: attr(data-placeholder);
color: var(--muted-foreground);
pointer-events: none;
float: left;
height: 0;
opacity: 0.6;
}
/* Selection highlight — use color-mix to avoid the background+opacity mismatch */
.tiptap-writing-editor .ProseMirror ::selection {
background: color-mix(in srgb, #f87171 35%, transparent);
}
/* AI rewrite — highlight the selected range while the menu / preview is open */
.ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 30%, transparent);
border-radius: 2px;
transition: background 0.2s ease;
}
.dark .ai-rewrite-highlight {
background: color-mix(in srgb, #fbbf24 18%, transparent);
}
:root {
--container-width-xs: calc(var(--spacing) * 72);
--container-width-sm: calc(var(--spacing) * 144);
+7 -8
View File
@@ -1,14 +1,13 @@
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import sourceIdentifierPlugin from "vite-plugin-source-identifier";
import { defineConfig } from "vite";
import path from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
const rootDir = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
plugins: [react(), sourceIdentifierPlugin()],
export default defineConfig({
plugins: [react()],
resolve: {
alias: [
{
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff. Show more