diff --git a/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md b/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md new file mode 100644 index 00000000..d44e3f78 --- /dev/null +++ b/frontend-web/docs/ai-writing-backend-orchestrator-blueprint.md @@ -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。 diff --git a/frontend-web/docs/ai-writing-final-execution-plan.md b/frontend-web/docs/ai-writing-final-execution-plan.md new file mode 100644 index 00000000..cd8805cd --- /dev/null +++ b/frontend-web/docs/ai-writing-final-execution-plan.md @@ -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,把研究、提纲、初稿、审稿重写这条最小闭环做稳。” diff --git a/frontend-web/docs/ai-writing-frontend-refactor-checklist.md b/frontend-web/docs/ai-writing-frontend-refactor-checklist.md new file mode 100644 index 00000000..b7119431 --- /dev/null +++ b/frontend-web/docs/ai-writing-frontend-refactor-checklist.md @@ -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. 右侧能稳定收到初稿和终稿 + +只要这三件事稳定,后续再继续加章节重写、评分展示、素材卡片增强,风险会小很多。 diff --git a/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md b/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md new file mode 100644 index 00000000..e2293119 --- /dev/null +++ b/frontend-web/docs/ai-writing-weak-model-multi-agent-prd.md @@ -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) diff --git a/frontend-web/docs/aixiezuo.md b/frontend-web/docs/aixiezuo.md new file mode 100644 index 00000000..52048f12 --- /dev/null +++ b/frontend-web/docs/aixiezuo.md @@ -0,0 +1,1896 @@ +# AI 智能写作 —— 杂志社多 Agent 协作系统(含用户干预 & 详细进度) + +> **目标**:通过角色扮演 + 流程编排,让性能较弱的本地大模型产出高质量文章; +> 同时在每个关键节点暂停等待用户确认,确保最终结果符合预期。 + +--- + +## 目录 + +1. [整体架构](#1-整体架构) +2. [用户干预节点设计(核心)](#2-用户干预节点设计核心) +3. [开源参考项目](#3-开源参考项目) +4. [弱模型增强策略](#4-弱模型增强策略) +5. [三个 Agent 的角色设计](#5-三个-agent-的角色设计) +6. [数据结构定义](#6-数据结构定义) +7. [后端实现(分步)](#7-后端实现分步) +8. [前端实现(分步)](#8-前端实现分步) +9. [内网接口预留设计](#9-内网接口预留设计) +10. [LangGraph 状态机流转图](#10-langgraph-状态机流转图) +11. [提示词模板(弱模型专用)](#11-提示词模板弱模型专用) +12. [SSE 协议 & 干预协议设计](#12-sse-协议--干预协议设计) +13. [前端 UI 组件详细设计](#13-前端-ui-组件详细设计) +14. [文件目录结构](#14-文件目录结构) +15. [分步开发检查清单](#15-分步开发检查清单) + +--- + +## 1. 整体架构 + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ 前端(现有页面) │ +│ frontend-web/src/open-canvas/ │ +│ │ +│ ┌───────────────────────────┐ ┌──────────────────────────────────┐ │ +│ │ AI写作左侧面板 │ │ CanvasArtifactPanel(右侧) │ │ +│ │ AIWritingPanel.tsx │ │ (右侧保持不变,实时展示文章草稿) │ │ +│ │ ┌─────────────────────┐ │ │ │ │ +│ │ │ 1. 写作意图输入区 │ │ │ ┌────────────────────────────┐ │ │ +│ │ ├─────────────────────┤ │ │ │ 实时文章草稿(Markdown) │ │ │ +│ │ │ 2. 进度时间线 │ │ │ │ 每次修改都保留版本历史 │ │ │ +│ │ │ ┌───────────────┐ │ │ │ └────────────────────────────┘ │ │ +│ │ │ │ ✅ 研究院完成 │ │ │ │ │ │ +│ │ │ │ 素材卡片列表▼ │ │ │ │ │ │ +│ │ │ │ [可收起展开] │ │ │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ │ │ +│ │ │ │ ⏸ 等待确认 │ │ │ │ │ │ +│ │ │ │ [用户干预卡片]│ │ │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ │ │ +│ │ │ │ ✅ 大纲已生成 │ │ │ │ │ │ +│ │ │ │ 大纲预览▼ │ │ │ │ │ │ +│ │ │ │ [可收起展开] │ │ │ │ │ │ +│ │ │ ├───────────────┤ │ │ │ │ │ +│ │ │ │ ⏸ 等待确认 │ │ │ │ │ │ +│ │ │ └───────────────┘ │ │ │ │ │ +│ └───────────────────────────┘ └──────────────────────────────────┘ │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ SSE 流式推送(进度/干预/草稿) + │ REST 干预响应(用户决策提交) + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ 后端新路由(独立,URL 前缀 /api/ai-writing/) │ +│ app/gateway/routers/ai_writing.py │ +│ POST /api/ai-writing/start ← 启动写作流程,返回 session_id │ +│ GET /api/ai-writing/{id}/stream ← SSE 流(进度/干预/草稿事件) │ +│ POST /api/ai-writing/{id}/resume ← 用户干预响应,恢复流程 │ +│ GET /api/ai-writing/{id}/state ← 查询当前流程状态 │ +└───────────────────────────────┬──────────────────────────────────────┘ + │ + ▼ +┌──────────────────────────────────────────────────────────────────────┐ +│ AI Writing LangGraph Graph(独立于原有 Graph) │ +│ packages/harness/deerflow/agents/ai_writing/graph.py │ +│ │ +│ intent_parser → researcher → [PAUSE:材料确认] → writer(outline) │ +│ → [PAUSE:大纲确认] → writer(draft) → editor │ +│ → [PAUSE:终稿确认] → END │ +│ ↑__________________________|(打回重写,最多3次) │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. 用户干预节点设计(核心) + +整个流程共有 **4 个用户干预点**,每个干预点都会暂停流程,向前端推送 `await_user` 事件,等待用户操作后再恢复。 + +### 干预点总览 + +``` +流程步骤 干预点 用户可执行的操作 +───────────────────────── ────── ─────────────────────────────────────── +① 研究院搜集素材完成 PAUSE-1 确认素材 / 补充关键词重搜 / 手动删除素材 +② 作家生成大纲完成 PAUSE-2 确认大纲 / 编辑大纲内容 / 要求重新规划 +③ 作家完成初稿(每次修改) PAUSE-3 确认定稿 / 给出修改意见 / 直接编辑文本 +④ 编辑审核完成(打回时) PAUSE-4 采纳编辑意见 / 忽略编辑意见强制定稿 +``` + +### PAUSE-1:素材确认 + +**触发**:研究院搜集完素材后暂停 + +前端展示: +- 素材卡片列表(默认收起,点击展开) +- 每张卡片:标题、来源、时间、相关度色条、正文摘要(可展开) +- 每张卡片有勾选框,支持删除不相关素材 +- "补充关键词"输入框,可追加搜索 + +用户操作选项: +``` +[确认素材,开始写大纲] ← 主操作,使用当前勾选素材继续 +[重新搜索] ← 清空素材重新来 +[补充搜索: ___________] ← 追加关键词,合并到现有素材 +``` + +### PAUSE-2:大纲确认 + +**触发**:作家生成大纲后暂停 + +前端展示: +- 大纲树形视图(默认展开) +- 每个章节可直接点击编辑标题和要点 +- 可拖拽调整章节顺序(可选) + +用户操作选项: +``` +[确认大纲,开始写作] ← 使用当前大纲(含用户修改)继续 +[重新规划大纲] ← 清空大纲重新生成,可附加意见: ___ +``` + +### PAUSE-3:草稿确认 + +**触发**:每次作家完成草稿后暂停(包括修改稿) + +前端展示: +- 右侧实时显示完整草稿 +- 左侧显示"编辑打分 + 问题清单"(如已经过编辑) +- 版本切换导航(第1稿 / 第2稿 …) + +用户操作选项: +``` +[确认定稿] ← 以此为最终文章 +[给出修改意见: ____________] ← 用户自己写修改意见,绕过编辑流程直接指导重写 +[让编辑继续审核] ← 走编辑流程(默认) +``` + +### PAUSE-4:编辑审核结果确认(仅在打回时) + +**触发**:编辑判断"打回"且未达到最大循环次数 + +前端展示: +- 编辑评分 + 分项得分(事实/逻辑/语言) +- 问题清单(按严重程度排序) +- 每个问题条目:位置 + 问题描述 + 修改建议 + +用户操作选项: +``` +[采纳编辑意见,让作家重写] ← 走编辑反馈重写 +[强制定稿(忽略编辑意见)] ← 用当前草稿结束 +[补充修改指令: ____________] ← 用户自己的意见覆盖编辑意见 +``` + +--- + +## 3. 开源参考项目 + +### Stanford STORM(最核心参考) +- **项目**:https://github.com/stanford-oval/storm +- **借鉴**:Researcher + Writer + Editor 完整协作流程;素材 → 大纲 → 分段写作的两步写法;短 prompt + JSON 输出模式(弱模型友好) + +### GPT-Researcher +- **项目**:https://github.com/assafelovic/gpt-researcher +- **借鉴**:统一搜索接口抽象(`context/retriever.py`);素材字段定义(标题/来源/时间/相关度) + +### CrewAI +- **项目**:https://github.com/crewAIInc/crewAI +- **借鉴**:`role + goal + backstory` 三段式角色锁定 Prompt;`expected_output` 强制 JSON 输出;任务链传参方式 + +### LangGraph Human-in-the-loop(项目已有,重点参考) +- **文档**:https://langchain-ai.github.io/langgraph/concepts/human_in_the_loop/ +- **借鉴**:`interrupt()` 实现流程暂停等待用户输入;`Command(resume=...)` 恢复流程;`MemorySaver` checkpointing 持久化状态 + +--- + +## 4. 弱模型增强策略 + +| 策略 | 实现方式 | 效果 | +|------|----------|------| +| **任务最小化** | 每次 LLM 调用只做一件事,禁止混合任务 | 减少跑偏概率 | +| **角色锁定** | System Prompt 开头:「你是X,你只做Y,不做其他」 | 防止角色混淆 | +| **强制 JSON** | `response_format=json_object` + Prompt 末尾提供 JSON 模板 | 输出可解析,有 fallback | +| **上下文压缩** | 传给下游的素材截断到 200 字摘要,不传原文 | 防止 context 溢出 | +| **两步写作** | 先写大纲(JSON),确认后再逐段写正文 | 防止弱模型一次生成长文失控 | +| **分项审查** | 编辑三项独立打分(事实/逻辑/语言),不混在一起 | 每次调用任务极简 | +| **Few-Shot 示例** | 每个 Agent 的 Prompt 内置 1 个期望输出的完整 JSON 示例 | 模型直接模仿,输出更稳定 | +| **迭代上限保护** | 编辑打回最多 3 次,超出强制定稿 | 防死循环 | +| **用户干预兜底** | 每个关键节点人工确认 | 彻底避免方向跑偏 | + +--- + +## 5. 三个 Agent 的角色设计 + +### Agent A —— 研究院(Researcher) + +``` +角色:专业情报研究员 +单次调用任务:① 只拆关键词 ② 只排序素材(两次独立调用) +输入:用户写作意图 +输出:MaterialPackage(结构化素材包) +工具:web_search(当前) / intranet_search(预留) +``` + +**两步拆分(弱模型关键)**: +- Step 1:`extract_keywords(intent)` → `{"keywords": [...]}`(仅此一件事) +- Step 2:对每个关键词独立搜索(并发),合并去重,截断到 500 字 + +**输出字段**: +```json +{ + "keywords": ["关键词1", "关键词2"], + "materials": [ + { + "id": "m001", + "title": "文章标题", + "content": "正文摘要(最多500字)", + "source": "来源域名或系统名称", + "published_at": "2024-01-15", + "relevance_score": 0.92, + "source_type": "internet", + "url": "https://...", + "recUuid": "", + } + ], + "summary": "共搜索到X条素材,精选Y条" +} +``` + +### Agent B —— 作家(Writer) + +``` +角色:专业内容创作者 +Phase 1:write_outline → 输出大纲 JSON(暂停等用户确认) +Phase 2:write_draft → 按段落逐段写正文(逐段 SSE 推送) +输入:MaterialPackage + 用户意图 + 确认后的大纲 + (修改稿时额外附加:编辑意见 or 用户修改意见) +输出:DraftArticle(含 Markdown 全文) +``` + +### Agent C —— 编辑(Editor) + +``` +角色:资深主编,标准极高 +任务:三项独立审查 → 综合评分 → 通过/打回决定 + 每项审查是独立的 LLM 调用(弱模型关键) +输入:DraftArticle + MaterialPackage(用于核实事实) +输出:ReviewResult(评分 + 分项问题清单) +``` + +**三次独立 LLM 调用**: +1. `review_facts(draft, materials)` → `{"score": 85, "issues": [...]}` +2. `review_logic(draft)` → `{"score": 80, "issues": [...]}` +3. `review_language(draft)` → `{"score": 90, "issues": [...]}` + +--- + +## 6. 数据结构定义 + +### 6.1 后端 LangGraph State(Python) + +**文件**:`packages/harness/deerflow/agents/ai_writing/state.py` + +```python +from typing import TypedDict, List, Optional, Annotated, Literal +import operator + +# ── 素材 ────────────────────────────────────────────────────────────── +class Material(TypedDict): + id: str + title: str + content: str # 截断到 500 字 + source: str + published_at: str + relevance_score: float + source_type: str # "internet" | "intranet" + url: str + recUuid: str # 知识库ID + +class MaterialPackage(TypedDict): + keywords: List[str] + materials: List[Material] + summary: str + +# ── 大纲 ────────────────────────────────────────────────────────────── +class OutlineSection(TypedDict): + section_title: str + key_points: List[str] + material_ids: List[str] + +class Outline(TypedDict): + title: str + sections: List[OutlineSection] + +# ── 草稿 ────────────────────────────────────────────────────────────── +class DraftArticle(TypedDict): + title: str + outline: Outline + full_markdown: str + word_count: int + revision_count: int + +# ── 编辑审核 ────────────────────────────────────────────────────────── +class ReviewIssue(TypedDict): + category: str # "fact" | "logic" | "language" + severity: str # "major" | "minor" + location: str + issue: str + suggestion: str + +class ReviewResult(TypedDict): + verdict: str # "pass" | "reject" + fact_score: int + logic_score: int + language_score: int + overall_score: int + pass_threshold: int + revision_notes: List[ReviewIssue] + +# ── 用户干预 ────────────────────────────────────────────────────────── +class UserInterventionPayload(TypedDict): + """用户在干预点提交的数据""" + action: str # 见各干预点定义 + # PAUSE-1 素材确认 + approved_material_ids: List[str] # 用户勾选保留的素材 ID + extra_keywords: List[str] # 补充搜索关键词 + # PAUSE-2 大纲确认 + edited_outline: Optional[Outline] # 用户编辑后的大纲(None 表示未修改) + outline_feedback: str # 要求重新规划的意见 + # PAUSE-3 草稿确认 + user_revision_notes: str # 用户自己的修改意见 + # PAUSE-4 编辑审核确认 + override_verdict: bool # True = 强制定稿,False = 采纳编辑意见 + +# ── 主状态 ──────────────────────────────────────────────────────────── +WritingStatus = Literal[ + "researching", "awaiting_material_confirm", + "writing_outline", "awaiting_outline_confirm", + "writing_draft", "awaiting_draft_confirm", + "reviewing", "awaiting_review_confirm", + "revising", "done", "error" +] + +class AIWritingState(TypedDict): + # 写作配置 + user_intent: str + article_type: str + target_audience: str + word_count_target: int + + # Agent A 输出 + material_package: Optional[MaterialPackage] + + # Agent B 输出 + current_outline: Optional[Outline] + drafts: Annotated[List[DraftArticle], operator.add] + current_draft: Optional[DraftArticle] + + # Agent C 输出 + review_result: Optional[ReviewResult] + + # 用户干预数据(每个干预点会覆盖) + last_user_intervention: Optional[UserInterventionPayload] + + # 流程控制 + revision_count: int + max_revisions: int # 默认 3 + status: WritingStatus + + # SSE 进度推送队列(每个节点追加) + progress_events: Annotated[List[dict], operator.add] +``` + +### 6.2 前端类型(TypeScript) + +**文件**:`frontend-web/src/open-canvas/types/ai-writing.ts` + +```typescript +// ── 素材 ───────────────────────────────────────────────────────────── +export interface AIMaterial { + id: string; + title: string; + content: string; + source: string; + publishedAt: string; + relevanceScore: number; + sourceType: 'internet' | 'intranet'; + url?: string; + docId?: string; + department?: string; + classification?: 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 = + | 'step_start' // Agent 开始执行某步骤 + | 'step_done' // Agent 完成某步骤 + | 'materials_ready' // 素材搜集完毕 + | 'outline_ready' // 大纲生成完毕 + | 'draft_chunk' // 草稿文字流式片段(逐段) + | 'draft_ready' // 草稿完整版本 + | 'review_ready' // 编辑审核完毕 + | 'await_user' // 暂停,等待用户干预 + | 'resumed' // 用户干预已提交,流程恢复 + | 'done' // 全部完成 + | 'error'; // 出错 + +export interface ProgressEvent { + type: ProgressEventType; + sessionId: string; + agentName?: string; // '研究院' | '作家' | '编辑' | '系统' + message: string; + detail?: string; + // type===materials_ready 时 + materials?: AIMaterial[]; + keywords?: string[]; + materialSummary?: string; + // type===outline_ready 时 + outline?: AIOutline; + // type===draft_chunk / draft_ready 时 + draftMarkdown?: string; + draftTitle?: string; + revisionCount?: number; + // type===review_ready 时 + reviewResult?: ReviewResult; + // type===await_user 时 + pausePoint?: 'material_confirm' | 'outline_confirm' | 'draft_confirm' | 'review_confirm'; + pauseContext?: Record; // 干预点所需的上下文数据 +} + +// ── 用户干预提交体 ──────────────────────────────────────────────────── +export interface UserInterventionPayload { + action: string; + // PAUSE-1 + approvedMaterialIds?: string[]; + extraKeywords?: string[]; + // PAUSE-2 + editedOutline?: AIOutline; + outlineFeedback?: string; + // PAUSE-3 + userRevisionNotes?: string; + // PAUSE-4 + overrideVerdict?: boolean; +} + +// ── 写作请求 ────────────────────────────────────────────────────────── +export interface AIWritingRequest { + userIntent: string; + articleType: 'news' | 'analysis' | 'feature' | 'report'; + targetAudience: string; + wordCountTarget: number; + extraContext?: string; +} + +// ── Hook 返回值 ─────────────────────────────────────────────────────── +export interface AIWritingState { + sessionId: string | null; + status: WritingStatus; + progressEvents: ProgressEvent[]; + materials: AIMaterial[]; + outline: AIOutline | null; + currentDraftMarkdown: string; + currentDraftTitle: string; + reviewResult: ReviewResult | null; + revisionCount: number; + currentPause: ProgressEvent | null; // 当前干预点事件(null 表示不在暂停状态) + error: string | null; +} +``` + +--- + +## 7. 后端实现(分步) + +### 步骤 7.1:目录结构 + +``` +packages/harness/deerflow/agents/ai_writing/ +├── __init__.py +├── state.py +├── graph.py +├── nodes/ +│ ├── __init__.py +│ ├── intent_parser.py +│ ├── researcher.py +│ ├── writer_outline.py # 只写大纲(独立节点) +│ ├── writer_draft.py # 只写正文(独立节点) +│ └── editor.py +├── search/ +│ ├── __init__.py +│ ├── base.py # 抽象接口(内网预留) +│ ├── internet_search.py +│ └── intranet_stub.py +└── prompts/ + ├── researcher_prompts.py + ├── writer_prompts.py + └── editor_prompts.py + +app/gateway/routers/ +└── ai_writing.py # 新建,不修改其他路由文件 +``` + +### 步骤 7.2:搜索接口抽象(内网预留) + +**文件**:`packages/harness/deerflow/agents/ai_writing/search/base.py` + +```python +from abc import ABC, abstractmethod +from typing import List +from dataclasses import dataclass, field + +@dataclass +class SearchResult: + """统一搜索结果格式 —— 内外网共用,对接内网只需映射到此结构""" + title: str + content: str + source: str + published_at: str + url: str = "" + relevance_score: float = 0.0 + source_type: str = "internet" # "internet" | "intranet" + # ── 内网扩展字段(按需填充)────────────────────────────────────── + recUuid: str = "" # 知识库 ID + extra_metadata: dict = field(default_factory=dict) + +class BaseSearchProvider(ABC): + """ + 搜索提供者抽象基类 + + 对接内网步骤: + 1. 继承此类,创建 IntranetSearchProvider + 2. 实现 search() —— 调用内网 API,将响应映射到 SearchResult + 3. 在 config.yaml 中设置 ai_writing.search_provider: intranet + 4. 无需修改任何 Agent 代码 + """ + + @abstractmethod + async def search( + self, + query: str, + max_results: int = 5, + date_from: str = "", + date_to: str = "", + ) -> List[SearchResult]: ... + + @abstractmethod + async def health_check(self) -> bool: ... +``` + +**内网接口桩**:`packages/harness/deerflow/agents/ai_writing/search/intranet_stub.py` + +```python +from typing import List +from .base import BaseSearchProvider, SearchResult + +class IntranetSearchProvider(BaseSearchProvider): + """ + 内网搜索接口适配器 + ═══════════════════════════════════════════════════════════ + 内网系统需提供 HTTP 接口,推荐格式: + + POST /api/search + Content-Type: application/json + Authorization: Bearer {api_key} (若需认证) + + 请求体: + { + "query": "搜索关键词", + "page_size": 5, + "date_from": "2024-01-01", (可选) + "date_to": "2024-12-31" (可选) + } + + 响应体: + { + "total": 100, + "results": [ + { + "title": "文档标题", + "content": "摘要(建议500字以内)", + "source": "来源系统名称", + "published_at": "2024-01-15", + "recUuid": "DOC-2024-001", + "url": "intranet://...", (可选) + "relevance_score": 0.92 (可选,无则传0) + } + ] + } + + 字段名称不同时,在 config.yaml 的 intranet_field_map 中配置映射。 + ═══════════════════════════════════════════════════════════ + """ + + def __init__(self, base_url: str, api_key: str = "", timeout: int = 10, + field_map: dict = None): + self.base_url = base_url + self.api_key = api_key + self.timeout = timeout + # 字段映射:内网字段名 → 标准字段名 + self.field_map = field_map or {} + + def _map_field(self, item: dict, standard_field: str) -> str: + """将内网字段名映射到标准字段名""" + intranet_field = self.field_map.get(standard_field, standard_field) + return item.get(intranet_field, item.get(standard_field, "")) + + async def search(self, query: str, max_results: int = 5, **kwargs) -> List[SearchResult]: + # ── TODO:对接内网时,取消以下注释并调整 ────────────────────── + # import httpx + # headers = {"Authorization": f"Bearer {self.api_key}"} if self.api_key else {} + # async with httpx.AsyncClient(timeout=self.timeout) as client: + # resp = await client.post( + # f"{self.base_url}/api/search", + # json={"query": query, "page_size": max_results}, + # headers=headers, + # ) + # resp.raise_for_status() + # items = resp.json().get("results", []) + # return [ + # SearchResult( + # title=self._map_field(it, "title"), + # content=self._map_field(it, "content")[:500], + # source=self._map_field(it, "source"), + # published_at=self._map_field(it, "published_at"), + # relevance_score=float(it.get( + # self.field_map.get("relevance_score", "relevance_score"), 0)), + # recUuid=self._map_field(it, "recUuid"), + # source_type="intranet", + # ) + # for it in items + # ] + # ───────────────────────────────────────────────────────────── + return [] # 未配置时返回空,不影响互联网模式 + + async def health_check(self) -> bool: + return False +``` + +**搜索工厂**:`packages/harness/deerflow/agents/ai_writing/search/__init__.py` + +```python +from .base import BaseSearchProvider, SearchResult +from .internet_search import InternetSearchProvider +from .intranet_stub import IntranetSearchProvider + +def get_search_provider(ai_writing_config: dict) -> BaseSearchProvider: + """ + config.yaml 中配置: + ai_writing: + search_provider: internet # 或 intranet + intranet_search_url: http://... + intranet_search_api_key: "" + intranet_field_map: + content: "abstract" + source: "system_name" + """ + provider = ai_writing_config.get("search_provider", "internet") + if provider == "intranet": + return IntranetSearchProvider( + base_url=ai_writing_config.get("intranet_search_url", ""), + api_key=ai_writing_config.get("intranet_search_api_key", ""), + field_map=ai_writing_config.get("intranet_field_map", {}), + ) + return InternetSearchProvider() +``` + +### 步骤 7.3:LangGraph Graph 与 Interrupt 机制 + +**文件**:`packages/harness/deerflow/agents/ai_writing/graph.py` + +```python +import asyncio +from langgraph.graph import StateGraph, END +from langgraph.checkpoint.memory import MemorySaver +from langgraph.types import interrupt, Command +from .state import AIWritingState +from .nodes.intent_parser import intent_parser_node +from .nodes.researcher import researcher_node +from .nodes.writer_outline import writer_outline_node +from .nodes.writer_draft import writer_draft_node +from .nodes.editor import editor_node + +# ── 干预节点:暂停并等待用户输入 ───────────────────────────────────────── + +async def pause_material_confirm(state: AIWritingState) -> dict: + """PAUSE-1:素材确认干预点""" + user_input = interrupt({ + "pause_point": "material_confirm", + "message": "研究院已完成素材搜集,请确认素材后继续。", + "materials": state["material_package"]["materials"], + "keywords": state["material_package"]["keywords"], + "summary": state["material_package"]["summary"], + }) + # 用户提交后,user_input 是 UserInterventionPayload + # 根据用户操作过滤素材 + action = user_input.get("action", "confirm") + if action == "confirm": + approved_ids = set(user_input.get("approvedMaterialIds", [ + m["id"] for m in state["material_package"]["materials"] + ])) + filtered_materials = [ + m for m in state["material_package"]["materials"] + if m["id"] in approved_ids + ] + updated_pkg = {**state["material_package"], "materials": filtered_materials} + return { + "material_package": updated_pkg, + "last_user_intervention": user_input, + "status": "writing_outline", + "progress_events": [{"type": "resumed", "message": "用户已确认素材,开始规划大纲"}] + } + # action == "re_search":重新搜索(返回 researcher 节点,通过路由控制) + extra_keywords = user_input.get("extraKeywords", []) + return { + "last_user_intervention": {**user_input, "_extra_keywords": extra_keywords}, + "status": "researching", + "progress_events": [{"type": "resumed", "message": "重新搜索中..."}] + } + +async def pause_outline_confirm(state: AIWritingState) -> dict: + """PAUSE-2:大纲确认干预点""" + user_input = interrupt({ + "pause_point": "outline_confirm", + "message": "作家已完成大纲规划,请确认或编辑大纲后继续。", + "outline": state["current_outline"], + }) + action = user_input.get("action", "confirm") + if action == "confirm": + edited_outline = user_input.get("editedOutline") or state["current_outline"] + return { + "current_outline": edited_outline, + "last_user_intervention": user_input, + "status": "writing_draft", + "progress_events": [{"type": "resumed", "message": "用户已确认大纲,开始写作"}] + } + # action == "re_outline" + feedback = user_input.get("outlineFeedback", "") + return { + "last_user_intervention": {**user_input, "_feedback": feedback}, + "status": "writing_outline", + "progress_events": [{"type": "resumed", "message": f"重新规划大纲(意见:{feedback[:50]})"}] + } + +async def pause_draft_confirm(state: AIWritingState) -> dict: + """PAUSE-3:草稿确认干预点""" + user_input = interrupt({ + "pause_point": "draft_confirm", + "message": "草稿已完成,请确认或给出修改意见。", + "draft_title": state["current_draft"]["title"], + "revision_count": state["current_draft"]["revision_count"], + "review_result": state.get("review_result"), + }) + action = user_input.get("action", "to_editor") + if action == "finalize": + return { + "last_user_intervention": user_input, + "status": "done", + "progress_events": [{"type": "resumed", "message": "用户确认定稿"}] + } + if action == "user_revise": + notes = user_input.get("userRevisionNotes", "") + return { + "last_user_intervention": {**user_input, "_user_notes": notes}, + "revision_count": state.get("revision_count", 0) + 1, + "status": "revising", + "progress_events": [{"type": "resumed", "message": f"按用户意见重写({notes[:50]})"}] + } + # action == "to_editor":走编辑流程 + return { + "last_user_intervention": user_input, + "status": "reviewing", + "progress_events": [{"type": "resumed", "message": "提交编辑审核"}] + } + +async def pause_review_confirm(state: AIWritingState) -> dict: + """PAUSE-4:编辑打回确认(仅 verdict==reject 时触发)""" + user_input = interrupt({ + "pause_point": "review_confirm", + "message": "编辑认为文章需要修改,请决定下一步操作。", + "review_result": state["review_result"], + "revision_count": state.get("revision_count", 0), + "max_revisions": state.get("max_revisions", 3), + }) + if user_input.get("overrideVerdict"): + return { + "last_user_intervention": user_input, + "status": "done", + "progress_events": [{"type": "resumed", "message": "用户强制定稿"}] + } + # 采纳编辑意见 or 附加用户意见 + extra_notes = user_input.get("userRevisionNotes", "") + return { + "last_user_intervention": {**user_input, "_extra_notes": extra_notes}, + "revision_count": state.get("revision_count", 0) + 1, + "status": "revising", + "progress_events": [{"type": "resumed", "message": "按编辑意见重写"}] + } + +# ── 条件路由 ────────────────────────────────────────────────────────── + +def after_material_pause(state: AIWritingState) -> str: + if state["status"] == "researching": + return "researcher" # 重新搜索 + return "writer_outline" + +def after_outline_pause(state: AIWritingState) -> str: + if state["status"] == "writing_outline": + return "writer_outline" # 重新规划 + return "writer_draft" + +def after_draft_pause(state: AIWritingState) -> str: + status = state["status"] + if status == "done": + return "end" + if status == "revising": + return "writer_draft" # 按用户意见重写 + return "editor" # 走编辑流程 + +def after_review(state: AIWritingState) -> str: + review = state.get("review_result", {}) + if review.get("verdict") == "pass": + return "pause_draft_confirm" # 编辑通过 → 草稿确认 + rev_count = state.get("revision_count", 0) + max_rev = state.get("max_revisions", 3) + if rev_count >= max_rev: + return "pause_draft_confirm" # 超出次数强制到草稿确认 + return "pause_review_confirm" # 编辑打回 → 审核确认 + +def after_review_pause(state: AIWritingState) -> str: + if state["status"] == "done": + return "end" + return "writer_draft" # 重写 + +# ── 组装 Graph ──────────────────────────────────────────────────────── + +def build_ai_writing_graph(): + graph = StateGraph(AIWritingState) + + # 节点 + graph.add_node("intent_parser", intent_parser_node) + graph.add_node("researcher", researcher_node) + graph.add_node("pause_material", pause_material_confirm) # PAUSE-1 + graph.add_node("writer_outline", writer_outline_node) + graph.add_node("pause_outline", pause_outline_confirm) # PAUSE-2 + graph.add_node("writer_draft", writer_draft_node) + graph.add_node("pause_draft", pause_draft_confirm) # PAUSE-3 + graph.add_node("editor", editor_node) + graph.add_node("pause_review", pause_review_confirm) # PAUSE-4 + + # 边 + graph.set_entry_point("intent_parser") + graph.add_edge("intent_parser", "researcher") + graph.add_edge("researcher", "pause_material") + + graph.add_conditional_edges("pause_material", after_material_pause, + {"researcher": "researcher", "writer_outline": "writer_outline"}) + + graph.add_edge("writer_outline", "pause_outline") + graph.add_conditional_edges("pause_outline", after_outline_pause, + {"writer_outline": "writer_outline", "writer_draft": "writer_draft"}) + + graph.add_edge("writer_draft", "pause_draft") + graph.add_conditional_edges("pause_draft", after_draft_pause, + {"end": END, "writer_draft": "writer_draft", "editor": "editor"}) + + graph.add_conditional_edges("editor", after_review, + {"pause_draft_confirm": "pause_draft", + "pause_review_confirm": "pause_review"}) + + graph.add_conditional_edges("pause_review", after_review_pause, + {"end": END, "writer_draft": "writer_draft"}) + + # 使用 MemorySaver 支持 interrupt/resume(生产环境换成 PostgresSaver) + checkpointer = MemorySaver() + return graph.compile(checkpointer=checkpointer, interrupt_before=None) + +AI_WRITING_GRAPH = build_ai_writing_graph() +``` + +### 步骤 7.4:后端 HTTP 路由 + +**文件**:`app/gateway/routers/ai_writing.py` + +```python +import asyncio +import json +import uuid +from typing import AsyncGenerator, Optional +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +from langgraph.types import Command +from deerflow.agents.ai_writing.graph import AI_WRITING_GRAPH + +router = APIRouter() + +# ── 请求/响应模型 ───────────────────────────────────────────────────── + +class StartWritingRequest(BaseModel): + user_intent: str + article_type: str = "analysis" + target_audience: str = "普通读者" + word_count_target: int = 800 + model_name: str = "" + +class StartWritingResponse(BaseModel): + session_id: str + message: str = "写作流程已启动" + +class ResumeRequest(BaseModel): + action: str + approved_material_ids: list[str] = Field(default_factory=list) + extra_keywords: list[str] = Field(default_factory=list) + edited_outline: Optional[dict] = None + outline_feedback: str = "" + user_revision_notes: str = "" + override_verdict: bool = False + +# ── 工具函数 ────────────────────────────────────────────────────────── + +def sse(event: str, data: dict) -> str: + return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n" + +def _make_thread_config(session_id: str, model_name: str = ""): + from deerflow.models.factory import get_default_llm + llm = get_default_llm(model_name=model_name or None) + return { + "configurable": { + "thread_id": session_id, + "llm": llm, + "ai_writing_config": {}, # 从全局 config 读取 search_provider 等 + } + } + +# ── 路由 ────────────────────────────────────────────────────────────── + +@router.post("/start", response_model=StartWritingResponse) +async def start_writing(body: StartWritingRequest): + """启动写作流程,返回 session_id(后续用此 ID 监听 SSE 和提交干预)""" + session_id = str(uuid.uuid4()) + initial_state = { + "user_intent": body.user_intent, + "article_type": body.article_type, + "target_audience": body.target_audience, + "word_count_target": body.word_count_target, + "revision_count": 0, + "max_revisions": 3, + "status": "researching", + "drafts": [], + "progress_events": [], + } + config = _make_thread_config(session_id, body.model_name) + + # 异步启动 Graph(不等待完成,通过 SSE 推送进度) + asyncio.create_task(_run_graph(session_id, initial_state, config)) + return StartWritingResponse(session_id=session_id) + + +@router.get("/{session_id}/stream") +async def stream_progress(session_id: str): + """SSE 流:推送进度事件、干预请求、草稿内容""" + return StreamingResponse( + _sse_generator(session_id), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +@router.post("/{session_id}/resume") +async def resume_writing(session_id: str, body: ResumeRequest): + """用户提交干预决策,恢复暂停的流程""" + config = _make_thread_config(session_id) + intervention = { + "action": body.action, + "approvedMaterialIds": body.approved_material_ids, + "extraKeywords": body.extra_keywords, + "editedOutline": body.edited_outline, + "outlineFeedback": body.outline_feedback, + "userRevisionNotes": body.user_revision_notes, + "overrideVerdict": body.override_verdict, + } + # LangGraph Command(resume=...) 恢复被 interrupt() 暂停的流程 + asyncio.create_task( + AI_WRITING_GRAPH.ainvoke(Command(resume=intervention), config=config) + ) + return {"message": "已提交,流程恢复中"} + + +@router.get("/{session_id}/state") +async def get_state(session_id: str): + """查询当前流程状态快照""" + config = _make_thread_config(session_id) + state = await AI_WRITING_GRAPH.aget_state(config) + if not state: + raise HTTPException(status_code=404, detail="Session not found") + return state.values + + +# ── 内部辅助 ────────────────────────────────────────────────────────── + +# 每个 session 的事件队列 +_event_queues: dict[str, asyncio.Queue] = {} + +async def _run_graph(session_id: str, initial_state: dict, config: dict): + """后台运行 Graph,将节点输出推入事件队列""" + queue = asyncio.Queue() + _event_queues[session_id] = queue + + try: + async for chunk in AI_WRITING_GRAPH.astream( + initial_state, config=config, stream_mode="updates" + ): + node_name = list(chunk.keys())[0] + node_output = chunk[node_name] + + # 推送 progress_events + for evt in node_output.get("progress_events", []): + await queue.put(("progress", evt)) + + # 素材就绪 + if pkg := node_output.get("material_package"): + await queue.put(("materials_ready", { + "keywords": pkg["keywords"], + "materials": pkg["materials"], + "summary": pkg["summary"], + })) + + # 大纲就绪 + if outline := node_output.get("current_outline"): + await queue.put(("outline_ready", {"outline": outline})) + + # 草稿就绪 + if draft := node_output.get("current_draft"): + await queue.put(("draft_ready", { + "title": draft["title"], + "markdown": draft["full_markdown"], + "revision_count": draft["revision_count"], + })) + + # 编辑审核就绪 + if review := node_output.get("review_result"): + await queue.put(("review_ready", {"review_result": review})) + + # interrupt() 触发 → 推送 await_user 事件 + # LangGraph 在 interrupt 时会抛出特殊异常, + # 通过 stream_mode="updates" 时以 "__interrupt__" 节点出现 + if node_name == "__interrupt__": + interrupt_data = node_output.get("value", {}) + await queue.put(("await_user", interrupt_data)) + + await queue.put(("done", {"message": "写作完成"})) + + except Exception as e: + await queue.put(("error", {"message": str(e)})) + finally: + await queue.put(None) # 哨兵,关闭流 + + +async def _sse_generator(session_id: str) -> AsyncGenerator[str, None]: + """从队列消费事件,推送为 SSE""" + # 等待队列创建(最多 5 秒) + for _ in range(50): + if session_id in _event_queues: + break + await asyncio.sleep(0.1) + else: + yield sse("error", {"message": "Session not found"}) + return + + queue = _event_queues[session_id] + yield sse("connected", {"session_id": session_id}) + + while True: + item = await queue.get() + if item is None: + break + event_type, data = item + yield sse(event_type, {"session_id": session_id, **data}) + await asyncio.sleep(0) + + _event_queues.pop(session_id, None) +``` + +**注册路由**(在 `app/gateway/app.py` 末尾追加,不修改已有代码): + +```python +from app.gateway.routers import ai_writing +app.include_router(ai_writing.router, prefix="/api/ai-writing", tags=["ai-writing"]) +``` + +--- + +## 8. 前端实现(分步) + +### 步骤 8.1:API 客户端 + +**文件**:`frontend-web/src/open-canvas/api/ai-writing.ts` + +```typescript +import type { AIWritingRequest, ProgressEvent, UserInterventionPayload } from '../types/ai-writing'; + +const BASE = '/api/ai-writing'; + +/** 启动写作流程,返回 session_id */ +export async function startWritingSession(req: AIWritingRequest): Promise { + const resp = await fetch(`${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, + }), + }); + if (!resp.ok) throw new Error(`启动失败: ${resp.status}`); + const data = await resp.json(); + return data.session_id; +} + +/** 监听 SSE 进度流 */ +export function subscribeToProgress( + sessionId: string, + onEvent: (event: ProgressEvent) => void, + signal?: AbortSignal, +): () => void { + const es = new EventSource(`${BASE}/${sessionId}/stream`); + + const eventTypes = [ + 'progress', 'materials_ready', 'outline_ready', + 'draft_ready', 'review_ready', 'await_user', + 'resumed', 'done', 'error', + ] as const; + + eventTypes.forEach((type) => { + es.addEventListener(type, (e: MessageEvent) => { + try { + const data = JSON.parse(e.data); + onEvent({ type, sessionId, ...data } as ProgressEvent); + } catch { + // 忽略 + } + }); + }); + + signal?.addEventListener('abort', () => es.close()); + return () => es.close(); +} + +/** 提交用户干预决策 */ +export async function resumeWriting( + sessionId: string, + payload: UserInterventionPayload, +): Promise { + const resp = await fetch(`${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, + }), + }); + if (!resp.ok) throw new Error(`提交干预失败: ${resp.status}`); +} +``` + +### 步骤 8.2:核心 Hook + +**文件**:`frontend-web/src/open-canvas/hooks/useAIWriting.ts` + +```typescript +import { useState, useCallback, useRef, useEffect } from 'react'; +import { startWritingSession, subscribeToProgress, resumeWriting } from '../api/ai-writing'; +import type { + AIWritingRequest, AIWritingState, ProgressEvent, + UserInterventionPayload, AIMaterial, AIOutline, ReviewResult, +} from '../types/ai-writing'; +import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; + +const INITIAL_STATE: AIWritingState = { + sessionId: null, + status: 'idle', + progressEvents: [], + materials: [], + outline: null, + currentDraftMarkdown: '', + currentDraftTitle: '', + reviewResult: null, + revisionCount: 0, + currentPause: null, + error: null, +}; + +export function useAIWriting() { + const { setArtifact } = useCanvasGraphContext(); + const [state, setState] = useState(INITIAL_STATE); + const abortRef = useRef(null); + const unsubscribeRef = useRef<(() => void) | null>(null); + + const handleEvent = useCallback((event: ProgressEvent) => { + setState((prev) => { + const base = { ...prev, progressEvents: [...prev.progressEvents, event] }; + + switch (event.type) { + case 'materials_ready': + return { ...base, materials: event.materials ?? [], status: 'awaiting_material_confirm', currentPause: event }; + + case 'outline_ready': + return { ...base, outline: event.outline ?? null, status: 'awaiting_outline_confirm', currentPause: event }; + + case 'draft_ready': + return { + ...base, + currentDraftMarkdown: event.draftMarkdown ?? '', + currentDraftTitle: event.draftTitle ?? '', + revisionCount: event.revisionCount ?? 0, + status: 'awaiting_draft_confirm', + currentPause: event, + }; + + case 'review_ready': + return { ...base, reviewResult: event.reviewResult ?? null }; + + case 'await_user': + return { ...base, status: `awaiting_${event.pausePoint ?? 'unknown'}` as AIWritingState['status'], currentPause: event }; + + case 'resumed': + return { ...base, currentPause: null }; + + case 'done': + return { ...base, status: 'done', currentPause: null }; + + case 'error': + return { ...base, status: 'error', error: event.message ?? '未知错误' }; + + 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' }); + + try { + const sessionId = await startWritingSession(request); + setState((prev) => ({ ...prev, sessionId })); + const unsubscribe = subscribeToProgress(sessionId, handleEvent, abortRef.current.signal); + unsubscribeRef.current = unsubscribe; + } catch (err) { + setState((prev) => ({ ...prev, status: 'error', error: String(err) })); + } + }, [state.status, handleEvent]); + + const submitIntervention = useCallback(async (payload: UserInterventionPayload) => { + if (!state.sessionId) return; + try { + await resumeWriting(state.sessionId, payload); + setState((prev) => ({ ...prev, currentPause: null })); + } catch (err) { + setState((prev) => ({ ...prev, error: String(err) })); + } + }, [state.sessionId]); + + const stopWriting = useCallback(() => { + abortRef.current?.abort(); + unsubscribeRef.current?.(); + setState((prev) => ({ ...prev, status: 'idle' })); + }, []); + + const reset = useCallback(() => { + abortRef.current?.abort(); + unsubscribeRef.current?.(); + setState(INITIAL_STATE); + }, []); + + useEffect(() => () => { abortRef.current?.abort(); }, []); + + return { state, startWriting, submitIntervention, stopWriting, reset }; +} +``` + +--- + +## 9. 内网接口预留设计 + +### 9.1 配置切换(config.yaml) + +```yaml +ai_writing: + search_provider: internet # ← 切换为 intranet 即启用内网模式 + intranet_search_url: "" # 内网搜索服务地址 + intranet_search_api_key: "" # 认证 Token(若需要) + intranet_search_timeout: 10 + intranet_field_map: # 内网字段名映射(与标准字段不同时填写) + title: "title" + content: "abstract" # 示例:内网叫 abstract + source: "system_name" # 示例:内网叫 system_name + published_at: "create_time" + relevance_score: "score" + recUuid: "id" +``` + +### 9.2 内网素材标准字段 + +| 标准字段 | 类型 | 必须 | 说明 | +|----------|------|:----:|------| +| `title` | string | ✅ | 文章/文档标题 | +| `content` | string | ✅ | 正文摘要,500 字以内 | +| `source` | string | ✅ | 来源系统名(如"研发知识库") | +| `published_at` | string | ✅ | 发布日期 YYYY-MM-DD | +| `relevance_score` | float 0-1 | ⬜ | 无则传 0 | +| `recUuid` | string | ⬜ | 知识库文档 ID | + +### 9.3 对接内网步骤清单 + +- [ ] 确认内网搜索 API 地址和认证方式 +- [ ] 在 `config.yaml` 的 `intranet_field_map` 中配置字段映射 +- [ ] 取消 `intranet_stub.py` 中的注释代码,调整字段映射 +- [ ] 将 `config.yaml` 中 `search_provider` 改为 `intranet` +- [ ] 调用 `GET /api/ai-writing/health` 验证连通性 +- [ ] 验证内网素材卡片在前端正常展示(包括 `department`、`classification` 字段) + +--- + +## 10. LangGraph 状态机流转图 + +``` +START + │ + ▼ +intent_parser ──── 意图解析(结构化写作配置) + │ + ▼ +researcher ──────── Agent A:关键词拆解 → 并发搜索 → 去重排序 + │ + ▼ +pause_material ──── ⏸ PAUSE-1:推送 await_user 事件,等待用户确认素材 + │ (interrupt) + ├── action=confirm ──────────────────────────▶ writer_outline + └── action=re_search ──────────────────────────▶ researcher(重搜) + +writer_outline ─── Agent B Phase1:写大纲(JSON) + │ + ▼ +pause_outline ───── ⏸ PAUSE-2:推送 await_user 事件,等待用户确认大纲 + │ (interrupt) + ├── action=confirm(含用户可能的编辑)─────────▶ writer_draft + └── action=re_outline ─────────────────────────▶ writer_outline(重规划) + +writer_draft ────── Agent B Phase2:按大纲逐段写正文 + │ + ▼ +pause_draft ─────── ⏸ PAUSE-3:推送 await_user 事件,等待用户确认草稿 + │ (interrupt) + ├── action=finalize ────────────────────────────▶ END(用户直接定稿) + ├── action=user_revise ─────────────────────────▶ writer_draft(按用户意见重写) + └── action=to_editor ────────────────────────────▶ editor + +editor ─────────── Agent C:三项独立审查,综合评分 + │ + ├── verdict=pass ────────────────────────────────▶ pause_draft(草稿确认) + ├── verdict=reject & count < max ───────────────▶ pause_review(PAUSE-4) + └── verdict=reject & count >= max ──────────────▶ pause_draft(强制到草稿确认) + +pause_review ────── ⏸ PAUSE-4:推送编辑意见,等待用户决策 + │ (interrupt) + ├── override_verdict=true ──────────────────────▶ END(强制定稿) + └── override_verdict=false ─────────────────────▶ writer_draft(按编辑意见重写) + +END +``` + +--- + +## 11. 提示词模板(弱模型专用) + +以下是针对弱模型优化的三段式提示词框架(借鉴 CrewAI 的 role+goal+backstory 结构): + +### 研究院 System Prompt + +``` +你是一名专业情报研究员(研究院)。 +你的目标:为写作任务搜集高质量素材。 +你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。 + +你只能做以下事情: +1. 从写作意图中提取搜索关键词 +2. 对搜索结果按相关度排序 +你不能写文章,不能评价内容好坏,只做信息收集。 + +所有输出必须是合法的 JSON 格式,参考示例: +{"keywords": ["关键词1", "关键词2", "关键词3"]} +``` + +### 作家 System Prompt(大纲阶段) + +``` +你是一名专业内容创作者(作家)。 +你的目标:根据素材和意图,规划清晰的文章大纲。 +你的工作背景:你有十年内容创作经验,擅长将零散素材组织成有逻辑的文章结构。 + +你只能做以下事情: +1. 分析素材,提取核心观点 +2. 规划文章章节结构和每节要点 +你不能写正文,只写大纲。 + +所有输出必须是合法的 JSON,严格参考以下示例格式: +{ + "title": "文章标题", + "sections": [ + { + "section_title": "第一章标题", + "key_points": ["要点一", "要点二"], + "material_ids": ["m001"] + } + ] +} +``` + +### 作家 System Prompt(写作阶段) + +``` +你是一名专业内容创作者(作家)。 +你的目标:根据大纲和素材,写出一段高质量的文章内容。 +你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。 + +你只能做以下事情: +1. 根据给定的章节要点和素材写这一段内容 +2. 观点必须来自素材,不编造数据 +你不能修改大纲,不能写其他章节,只写当前这一段。 + +直接返回段落文字,不要 JSON,不要标题,不要解释。 +``` + +### 编辑 System Prompt + +``` +你是一名资深主编(编辑),标准极高,要求严格。 +你的目标:审查文章质量,给出客观评分和具体修改意见。 +你的工作背景:你在一家权威媒体担任主编十五年,见过各种质量的文章。 + +你只能做以下事情: +1. 从 [fact/logic/language] 某一个维度审查文章 +2. 给出该维度的评分(0-100)和具体问题列表 +你不能改写文章,不能评价其他维度,只做当前指定维度的审查。 + +所有输出必须是合法的 JSON,严格参考以下示例: +{ + "score": 82, + "issues": [ + { + "location": "第二段", + "issue": "数据来源不明确", + "suggestion": "引用素材 m001 中的具体数据,并注明来源" + } + ] +} +``` + +--- + +## 12. SSE 协议 & 干预协议设计 + +### SSE 事件类型 + +| event | 触发时机 | data 关键字段 | +|-------|----------|---------------| +| `connected` | 连接建立 | `session_id` | +| `step_start` | Agent 开始某步骤 | `agent_name`, `message` | +| `step_done` | Agent 完成某步骤 | `agent_name`, `message` | +| `materials_ready` | 研究院搜索完成 | `keywords`, `materials[]`, `summary` | +| `outline_ready` | 大纲生成完成 | `outline{title, sections[]}` | +| `draft_ready` | 草稿完成 | `title`, `markdown`, `revision_count` | +| `review_ready` | 编辑审核完成 | `review_result{verdict, scores, notes[]}` | +| `await_user` | 流程暂停,等待干预 | `pause_point`, 干预所需上下文数据 | +| `resumed` | 用户干预已提交,流程恢复 | `message` | +| `done` | 全部完成 | `message` | +| `error` | 出错 | `message` | + +### 干预 API + +``` +POST /api/ai-writing/{session_id}/resume +Content-Type: application/json + +{ + "action": "confirm", // 操作类型(见各干预点定义) + "approved_material_ids": [...], // PAUSE-1 + "extra_keywords": [...], // PAUSE-1 + "edited_outline": {...}, // PAUSE-2(null=不修改) + "outline_feedback": "", // PAUSE-2(重规划时的意见) + "user_revision_notes": "", // PAUSE-3 或 PAUSE-4 + "override_verdict": false // PAUSE-4 +} +``` + +**各干预点的 action 值**: + +| 干预点 | action 值 | 说明 | +|--------|-----------|------| +| PAUSE-1 | `confirm` | 确认当前素材继续 | +| PAUSE-1 | `re_search` | 重新搜索(可附 extra_keywords) | +| PAUSE-2 | `confirm` | 确认大纲(可附 edited_outline) | +| PAUSE-2 | `re_outline` | 重新规划(可附 outline_feedback) | +| PAUSE-3 | `finalize` | 直接定稿 | +| PAUSE-3 | `user_revise` | 按用户意见重写(附 user_revision_notes) | +| PAUSE-3 | `to_editor` | 提交编辑审核 | +| PAUSE-4 | `accept_review` | 采纳编辑意见重写 | +| PAUSE-4 | `force_finalize` | 强制定稿(override_verdict=true) | +| PAUSE-4 | `custom_revise` | 附加用户意见重写(user_revision_notes) | + +--- + +## 13. 前端 UI 组件详细设计 + +### 13.1 组件树 + +``` +AIWritingPanel.tsx (左侧面板根组件) +├── AIWritingForm.tsx (写作意图输入表单) +├── AIWritingTimeline.tsx (进度时间线,纵向排列) +│ ├── TimelineStep.tsx (单个步骤卡片) +│ │ ├── StepHeader.tsx (Agent名 + 状态图标 + 时间) +│ │ └── StepContent.tsx (步骤详情,可收起展开) +│ │ +│ ├── MaterialsCard.tsx (素材卡片区,PAUSE-1 触发展示) +│ │ ├── MaterialItem.tsx (单条素材卡片,默认收起) +│ │ │ ├── 标题 + 来源 + 时间 +│ │ │ ├── 相关度色条 +│ │ │ └── 正文摘要(可展开) +│ │ └── MaterialsToolbar.tsx (全选/全取消/补充搜索输入) +│ │ +│ ├── OutlineCard.tsx (大纲展示卡,PAUSE-2 触发展示) +│ │ ├── OutlineSection.tsx (可编辑的章节行) +│ │ └── OutlineToolbar.tsx (确认/重规划按钮) +│ │ +│ ├── ReviewCard.tsx (编辑审核结果,PAUSE-4 触发展示) +│ │ ├── ScoreBar.tsx (三项评分进度条) +│ │ └── IssueList.tsx (问题清单,按严重程度分组) +│ │ +│ └── InterventionCard.tsx (用户干预操作区,每个 PAUSE 点渲染) +│ ├── 操作按钮组 +│ └── 补充意见输入框 +│ +└── AIWritingStatus.tsx (底部状态栏:当前 Agent + 进度) +``` + +### 13.2 MaterialItem 组件规格 + +``` +┌─────────────────────────────────────────────────────────┐ +│ ☑ [标题(最多两行截断)] ▶ │ ← 点击展开 +│ 来源网站 · 2024-01-15 · ████████░░ 82% │ +└─────────────────────────────────────────────────────────┘ + +展开后: +┌─────────────────────────────────────────────────────────┐ +│ ☑ [标题] ▼ │ +│ 来源网站 · 2024-01-15 · ████████░░ 82% │ +│ ───────────────────────────────────────────────── │ +│ [正文摘要,最多 200 字,超出显示"...展开"] │ +│ │ +│ [查看原文 →](若有 url) │ +└─────────────────────────────────────────────────────────┘ +``` + +**相关度色条颜色规则**: +- `>= 0.8`:绿色(高相关) +- `0.5 ~ 0.8`:橙色(中相关) +- `< 0.5`:红色(低相关,建议取消勾选) + +### 13.3 OutlineCard 组件规格 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 大纲(可编辑) [全部展开▼] │ +├──────────────────────────────────────────────────────────┤ +│ 文章标题:[可内联编辑] │ +│ │ +│ ▶ 第一部分:引言与背景(点击展开要点) │ +│ ▶ 第二部分:核心分析 │ +│ ▶ 第三部分:结论与建议 │ +│ │ +│ (展开后) │ +│ ▼ 第二部分:核心分析 │ +│ • 要点一(可编辑) │ +│ • 要点二(可编辑) │ +│ + 添加要点 │ +│ 引用素材:m001 m002(Tag 样式) │ +├──────────────────────────────────────────────────────────┤ +│ [确认大纲,开始写作] [重新规划大纲...] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 13.4 InterventionCard 组件规格 + +``` +┌──────────────────────────────────────────────────────────┐ +│ ⏸ 需要您的确认才能继续 │ +│ ─────────────────────────────────────────────────────── │ +│ [PAUSE-1 素材确认时] │ +│ 已搜集到 8 条素材(已为您默认全选) │ +│ 您可以取消勾选不相关的素材,或补充关键词 │ +│ │ +│ 补充关键词:[___________________] [+追加搜索] │ +│ │ +│ [✓ 确认素材,开始规划大纲] [↺ 重新搜索] │ +└──────────────────────────────────────────────────────────┘ + +┌──────────────────────────────────────────────────────────┐ +│ ⏸ 需要您的确认才能继续 │ +│ ─────────────────────────────────────────────────────── │ +│ [PAUSE-3 草稿确认时] │ +│ 初稿已完成(共 856 字) │ +│ 编辑评分:81分(通过线 80 分) │ +│ │ +│ 您的修改意见(可选): │ +│ [___________________________________] │ +│ │ +│ [✓ 确认定稿] [✎ 按我的意见修改] [→ 让编辑审核] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 13.5 ReviewCard 组件规格 + +``` +┌──────────────────────────────────────────────────────────┐ +│ 编辑审核结果(第 1 次修改) 综合评分:78 / 100 │ +│ │ +│ 事实准确性 ████████░░ 82 (通过) │ +│ 逻辑结构 ██████░░░░ 72 (需改进) │ +│ 语言文字 █████████░ 88 (优秀) │ +│ │ +│ ── 修改意见 ────────────────────────────────────────── │ +│ │ +│ [major] 逻辑 · 第三段:论点跳跃,缺少过渡 │ +│ → 建议:在第三段开头增加连接上下文的过渡句 │ +│ │ +│ [minor] 事实 · 第二段:引用数据未标注来源 │ +│ → 建议:补充"据素材 m002 显示" │ +│ │ +│ 您的补充意见(可选): │ +│ [___________________________________] │ +│ │ +│ [↺ 采纳编辑意见,让作家重写] [✓ 忽略编辑,强制定稿] │ +└──────────────────────────────────────────────────────────┘ +``` + +### 13.6 进度时间线整体效果 + +``` +左侧面板时间线(纵向) + +○ ── 研究院 ← 完成(绿色圆点) +│ 搜集到 8 条素材 +│ ┌───────────────────────────┐ +│ │ 素材列表(默认收起) ▶ │ ← 点击展开素材卡片 +│ └───────────────────────────┘ +│ ┌──────────────────────────────────────────────┐ +│ │ ⏸ 需要您确认素材,才能继续 │ +│ │ [✓ 确认] [↺ 重新搜索] │ +│ └──────────────────────────────────────────────┘ + +○ ── 作家(大纲) ← 完成(绿色圆点) +│ 已规划 3 个章节 +│ ┌───────────────────────────┐ +│ │ 大纲预览(默认收起) ▶ │ +│ └───────────────────────────┘ +│ ┌──────────────────────────────────────────────┐ +│ │ ⏸ 请确认大纲后继续写作 │ +│ │ [✓ 确认] [✎ 编辑大纲] [↺ 重规划] │ +│ └──────────────────────────────────────────────┘ + +◌ ── 作家(写作) ← 进行中(动画圆点) +│ 正在写作第二章... + +◌ ── 编辑 ← 等待中(灰色圆点) + +◌ ── 定稿 ← 等待中 +``` + +--- + +## 14. 文件目录结构 + +``` +# 后端新增(不修改任何已有文件) +offline-backend-20260512/backend/ +├── app/gateway/routers/ +│ └── ai_writing.py ← 新建 +└── packages/harness/deerflow/agents/ + └── ai_writing/ ← 新建目录 + ├── __init__.py + ├── state.py + ├── graph.py + ├── nodes/ + │ ├── __init__.py + │ ├── intent_parser.py + │ ├── researcher.py + │ ├── writer_outline.py + │ ├── writer_draft.py + │ └── editor.py + ├── search/ + │ ├── __init__.py + │ ├── base.py ← 抽象接口(内网预留) + │ ├── internet_search.py + │ └── intranet_stub.py ← 内网桩 + └── prompts/ + ├── researcher_prompts.py + ├── writer_prompts.py + └── editor_prompts.py + +# 前端新增(不修改已有组件) +frontend-web/src/open-canvas/ +├── api/ +│ └── ai-writing.ts ← 新建 +├── types/ +│ └── ai-writing.ts ← 新建 +├── hooks/ +│ └── useAIWriting.ts ← 新建 +└── components/ + └── ai-writing/ ← 新建目录 + ├── AIWritingPanel.tsx ← 左侧面板根组件 + ├── AIWritingForm.tsx ← 写作意图输入表单 + ├── AIWritingTimeline.tsx ← 进度时间线 + ├── TimelineStep.tsx ← 单步骤卡片 + ├── MaterialsCard.tsx ← 素材卡片区 + ├── MaterialItem.tsx ← 单条素材 + ├── MaterialsToolbar.tsx ← 素材操作工具栏 + ├── OutlineCard.tsx ← 大纲展示与编辑 + ├── OutlineSection.tsx ← 单章节行 + ├── ReviewCard.tsx ← 编辑审核结果 + ├── ScoreBar.tsx ← 评分进度条 + ├── IssueList.tsx ← 问题清单 + └── InterventionCard.tsx ← 用户干预操作区 +``` + +--- + +## 15. 分步开发检查清单 + +### 阶段一:后端骨架(2 天) + +- [ ] 创建 `ai_writing/` 目录结构(所有 `__init__.py`) +- [ ] 实现 `state.py`(完整 AIWritingState 定义) +- [ ] 实现 `search/base.py`(抽象接口) +- [ ] 实现 `search/internet_search.py`(接入项目已有搜索工具) +- [ ] 实现 `search/intranet_stub.py`(桩,返回空列表) +- [ ] 实现 `nodes/intent_parser.py`(意图解析 → 结构化配置) +- [ ] 实现 `nodes/researcher.py`(Agent A,两步:关键词+搜索) +- [ ] 实现 `nodes/writer_outline.py`(Agent B Phase1,只写大纲) +- [ ] 实现 `nodes/writer_draft.py`(Agent B Phase2,逐段写正文) +- [ ] 实现 `nodes/editor.py`(Agent C,三次独立调用) +- [ ] 实现 `graph.py`(含 4 个 interrupt 干预节点) +- [ ] 实现 `app/gateway/routers/ai_writing.py`(start + stream + resume + state) +- [ ] 在 `app.py` 注册路由(追加一行) +- [ ] `curl` 测试 `/api/ai-writing/start` 和 `/api/ai-writing/{id}/stream` + +### 阶段二:后端干预验证(1 天) + +- [ ] 测试 PAUSE-1(素材确认):验证 `interrupt()` 暂停 + `resume` 恢复 +- [ ] 测试 PAUSE-2(大纲确认):验证编辑大纲后继续写作 +- [ ] 测试 PAUSE-3(草稿确认):验证三种 action(定稿/重写/编辑) +- [ ] 测试 PAUSE-4(审核确认):验证打回循环和强制定稿 +- [ ] 测试最大循环上限(revision_count >= max_revisions) + +### 阶段三:前端进度展示(2 天) + +- [ ] 创建 `types/ai-writing.ts`(完整类型定义) +- [ ] 创建 `api/ai-writing.ts`(start + SSE subscribe + resume) +- [ ] 创建 `hooks/useAIWriting.ts`(状态管理 + 事件处理) +- [ ] 创建 `AIWritingTimeline.tsx` + `TimelineStep.tsx`(时间线骨架) +- [ ] 创建 `ScoreBar.tsx`(评分进度条) +- [ ] 在 `CanvasResizableLayout.tsx` 引入 `AIWritingPanel`(新增 import,不修改现有逻辑) + +### 阶段四:素材卡片(1 天) + +- [ ] 创建 `MaterialItem.tsx`(收起/展开,相关度色条,勾选框) +- [ ] 创建 `MaterialsCard.tsx`(素材列表,默认收起,显示素材数量) +- [ ] 创建 `MaterialsToolbar.tsx`(全选/取消,补充关键词输入) +- [ ] 创建 `InterventionCard.tsx` for PAUSE-1(确认/重搜按钮) +- [ ] 联调测试:素材 SSE 事件 → 素材列表渲染 → 用户确认 → resume 请求 + +### 阶段五:大纲交互(1 天) + +- [ ] 创建 `OutlineSection.tsx`(可内联编辑章节标题和要点) +- [ ] 创建 `OutlineCard.tsx`(大纲树形展示,默认收起) +- [ ] 创建 `InterventionCard.tsx` for PAUSE-2(确认/重规划按钮 + 反馈输入) +- [ ] 测试:编辑大纲后提交 → 后端收到 `edited_outline` → 按修改后大纲写作 + +### 阶段六:草稿确认与编辑审核(1 天) + +- [ ] 创建 `IssueList.tsx`(问题清单,按 major/minor 分组) +- [ ] 创建 `ReviewCard.tsx`(评分 + 问题清单) +- [ ] 创建 `InterventionCard.tsx` for PAUSE-3 和 PAUSE-4 +- [ ] 测试完整流程(搜索 → 大纲确认 → 写作 → 编辑 → 打回 → 重写 → 定稿) + +### 阶段七:弱模型调优(2 天) + +- [ ] 为每个 Agent 添加 Few-Shot JSON 示例 +- [ ] 添加完善的 JSON 解析容错(try/except + fallback 策略) +- [ ] 调整各 Agent 的 prompt,确保弱模型返回合法 JSON +- [ ] 调整 `pass_threshold`(从 80 开始,根据实际测试调整) +- [ ] 测试素材截断逻辑(确保不超过 token 限制) + +### 阶段八:内网对接(未来) + +- [ ] 确认内网搜索 API 文档和认证方式 +- [ ] 在 `config.yaml` 配置 `intranet_field_map` 字段映射 +- [ ] 取消 `intranet_stub.py` 中的注释代码并调整 +- [ ] 切换 `search_provider: intranet` 测试连通性 +- [ ] 验证内网素材卡片(包含 `department`、`classification` 字段)正常展示 + +--- + +> **架构原则**:后端新路由用 `/api/ai-writing/` 前缀完全隔离;前端新建独立组件目录, +> 通过在 `CanvasResizableLayout.tsx` 中新增导入接入,不修改任何已有组件。 +> 参考:Stanford STORM 多 Agent 写作流程、LangGraph HITL interrupt 机制、 +> CrewAI 角色锁定 Prompt 设计、GPT-Researcher 搜索接口抽象。 diff --git a/frontend-web/docs/openCanvas-progress.md b/frontend-web/docs/openCanvas-progress.md index 012bb8ea..5e74c279 100644 --- a/frontend-web/docs/openCanvas-progress.md +++ b/frontend-web/docs/openCanvas-progress.md @@ -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 内部添加: -} /> -``` - -子路由: -- `/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 的装配顺序 - - - - - - - +### 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(null); - - - // ← 必须有 id - // ← 必须有 id - - -// 切换折叠 -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 内容以 `` 标签嵌入人类消息末尾。 -```tsx -useEffect(() => { - layoutRef.current?.setLayout( - isChatCollapsed ? { chat: 0, artifact: 100 } : { chat: 35, artifact: 65 } - ); -}, [isChatCollapsed]); +- 普通聊天:`sendMessage` 内部自动追加(仅当消息里还没有 `` 时) +- 快捷动作:`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\n${body}\n`; +} + +// core/messages/utils.ts +export function stripArtifactTag(content: string): string { + return content.replace(/]*>[\s\S]*?<\/artifact>/g, "").trim(); +} ``` +### 3.3 ThinkTagParser(AI 改写思考过程解析) + +后端支持两种思考格式: +- Anthropic:独立 thinking block,以 `event: thinking` SSE 事件传输 +- DeepSeek:把思考过程内联在文本里:`...content` + +前端 `useWritingRewrite.ts` 的 `ThinkTagParser` 是流式状态机,处理跨 chunk 边界的 `` 标签。 + +### 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 在文本流里混入 `` 标签 | `ThinkTagParser` 状态机,保留最后 6/7 字节防止跨 chunk 分割 | +| loading→success 状态转换检测(stale closure) | 用 `prevStatusRef` + `useEffect` 检测 status 变化,取代直接读 state | +| 快捷动作响应不出现在聊天面板 | `CanvasStreamContext` 统一 `sendMessage` 入口 | +| LLM 看不到 artifact 内容(只有 state.messages) | 消息里直接嵌入 `` 标签,显示时剥离 | +| `` 标签在聊天面板里显示 | `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 内容注入(消息里嵌入 标签,显示时剥离) + +请根据进度文档第七节"潜在开发方向"或我指定的任务继续开发。 ``` diff --git a/frontend-web/docs/测试.md b/frontend-web/docs/测试.md new file mode 100644 index 00000000..1f05bed6 --- /dev/null +++ b/frontend-web/docs/测试.md @@ -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 \ No newline at end of file diff --git a/frontend-web/docs/素材专家检索.md b/frontend-web/docs/素材专家检索.md new file mode 100644 index 00000000..d4ddb29b --- /dev/null +++ b/frontend-web/docs/素材专家检索.md @@ -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-内网检索返回空--调不通) 排查。 diff --git a/frontend-web/package-lock.json b/frontend-web/package-lock.json index 2edad9b5..0437d4af 100644 --- a/frontend-web/package-lock.json +++ b/frontend-web/package-lock.json @@ -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", diff --git a/frontend-web/package.json b/frontend-web/package.json index a9319e1d..8c9b705a 100644 --- a/frontend-web/package.json +++ b/frontend-web/package.json @@ -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" +} diff --git a/frontend-web/src/components/workspace/artifacts/context.tsx b/frontend-web/src/components/workspace/artifacts/context.tsx index 8d8a1b93..e15481af 100644 --- a/frontend-web/src/components/workspace/artifacts/context.tsx +++ b/frontend-web/src/components/workspace/artifacts/context.tsx @@ -94,3 +94,7 @@ export function useArtifacts() { } return context; } + +export function useArtifactsOptional(): ArtifactsContextType | null { + return useContext(ArtifactsContext) ?? null; +} diff --git a/frontend-web/src/components/workspace/messages/message-group.tsx b/frontend-web/src/components/workspace/messages/message-group.tsx index e995fb02..cf820620 100644 --- a/frontend-web/src/components/workspace/messages/message-group.tsx +++ b/frontend-web/src/components/workspace/messages/message-group.tsx @@ -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({ )} ); - } 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 ( { - if (!artifactPath) { - return; - } - select(artifactPath); - setOpen(true); - }} - > + label={description} + icon={NotebookPenIcon} + onClick={() => { + if (!artifactPath || !artifacts) { + return; + } + artifacts.select(artifactPath); + artifacts.setOpen(true); + }} + > {path && ( {path} @@ -412,6 +411,17 @@ function ToolCall({ icon={ListTodoIcon} > ); + } 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 ( + + + {type === "code" ? "代码文件" : "Markdown 文档"} + + + ); } else { const description: string | undefined = (args as { description: string }) ?.description; diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index da3acecf..62836ca9 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -28,6 +28,7 @@ import { extractContentFromMessage, extractReasoningContentFromMessage, parseUploadedFiles, + stripArtifactTag, stripUploadedFilesTag, type FileInMessage, } from "@/core/messages/utils"; @@ -173,7 +174,7 @@ function MessageContent_({ const contentToDisplay = useMemo(() => { if (isHuman) { - return rawContent ? stripUploadedFilesTag(rawContent) : ""; + return rawContent ? stripArtifactTag(stripUploadedFilesTag(rawContent)) : ""; } return rawContent ?? ""; }, [rawContent, isHuman]); diff --git a/frontend-web/src/core/api/canvas-client.ts b/frontend-web/src/core/api/canvas-client.ts new file mode 100644 index 00000000..3c1c0f5c --- /dev/null +++ b/frontend-web/src/core/api/canvas-client.ts @@ -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; +} diff --git a/frontend-web/src/core/config/index.ts b/frontend-web/src/core/config/index.ts index 4dc61576..928a1339 100644 --- a/frontend-web/src/core/config/index.ts +++ b/frontend-web/src/core/config/index.ts @@ -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( diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index e1c1a478..6929f0a7 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -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( messages: Message[], @@ -44,11 +44,11 @@ export function groupMessages( 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( } 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 ... blocks from human message display. */ +export function stripArtifactTag(content: string): string { + return content + .replace(/]*>[\s\S]*?<\/artifact>/g, "") + .trim(); +} + export function parseUploadedFiles(content: string): FileInMessage[] { // Match ... tag const uploadedFilesRegex = /([\s\S]*?)<\/uploaded_files>/; diff --git a/frontend-web/src/env.ts b/frontend-web/src/env.ts index 7e7b7254..e387c0f2 100644 --- a/frontend-web/src/env.ts +++ b/frontend-web/src/env.ts @@ -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"), }; diff --git a/frontend-web/src/open-canvas/api/ai-writing-article-types.ts b/frontend-web/src/open-canvas/api/ai-writing-article-types.ts new file mode 100644 index 00000000..68bddd13 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-article-types.ts @@ -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 { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取文章类型失败: ${resp.status}`); + return resp.json(); +} + +export async function createArticleType(data: CreateArticleTypeRequest): Promise { + 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 { + 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 { + const resp = await apiFetch(`${BASE()}/${id}`, { method: 'DELETE' }); + if (resp.status === 404) throw new Error(`文章类型不存在`); + if (!resp.ok) throw new Error(`删除文章类型失败: ${resp.status}`); +} diff --git a/frontend-web/src/open-canvas/api/ai-writing-sessions.ts b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts new file mode 100644 index 00000000..eae5cad8 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-sessions.ts @@ -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 | 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 { + const resp = await apiFetch(BASE()); + if (!resp.ok) throw new Error(`获取历史记录失败: ${resp.status}`); + return resp.json(); +} + +export async function getAIWritingSession(id: string): Promise { + 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 { + 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 { + 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 { + 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}`); +} diff --git a/frontend-web/src/open-canvas/api/ai-writing.ts b/frontend-web/src/open-canvas/api/ai-writing.ts new file mode 100644 index 00000000..0305eecb --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing.ts @@ -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 { + 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): Record { + 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): Record { + 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; + const sections = Array.isArray(obj.sections) + ? obj.sections.map((s: unknown) => normalizeOutlineSection(s as Record)) + : []; + return { ...obj, sections }; +} + +function normalizeReviewIssue(i: Record): Record { + return { ...i }; +} + +function normalizeReviewResult(r: unknown): unknown { + if (!r || typeof r !== 'object') return r; + const obj = r as Record; + 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)) + : Array.isArray(obj.revisionNotes) + ? obj.revisionNotes + : [], + }; +} + +function normalizeEvent(parsed: Record): Partial { + const out: Record = { ...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) + ); + } + + // 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) + ); + } + + return out as Partial; +} + +/** 监听 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 { + 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}`); +} diff --git a/frontend-web/src/open-canvas/api/rewrite.ts b/frontend-web/src/open-canvas/api/rewrite.ts new file mode 100644 index 00000000..cae36bdd --- /dev/null +++ b/frontend-web/src/open-canvas/api/rewrite.ts @@ -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): 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 { + 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; + 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; + 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 + } + } +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx new file mode 100644 index 00000000..dc39da2e --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingDraftPanel.tsx @@ -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 ( +
+
+
+ +
+
+ +
+
+
+

草稿将在此处显示

+

+ 完成写作流程后,AI 生成的内容将实时出现在这里 +

+
+
+ ); +} + +export function AIWritingDraftPanel() { + const { artifact } = useCanvasGraphContext(); + + if (!artifact) { + return ( +
+ +
+ ); + } + + const content = getCurrentArtifactContent(artifact); + + return ( +
+ {/* 标题栏 */} +
+ + + {content?.title ?? '草稿预览'} + +
+ +
+ {content && !isCodeArtifact(content) ? ( + + ) : null} +
+
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx new file mode 100644 index 00000000..3beecdcd --- /dev/null +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -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([]); + + 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 ( +
+
+ +