diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
index 9241bd54..15666c54 100644
--- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
+++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx
@@ -259,6 +259,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
await stream.submit(
{
user_intent: request.userIntent,
+ // 用户自带大纲原文(留空则后端走自动规划)
+ user_outline_raw: request.userOutline?.trim() || '',
article_type: request.articleType,
target_audience: request.targetAudience,
word_count_target: request.wordCountTarget,
diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
index 0f855879..234d7729 100644
--- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
+++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts
@@ -623,6 +623,7 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
export type AIWritingValues = {
user_intent?: string;
+ user_outline_raw?: string;
article_type?: string;
status?: string;
material_package?: unknown;
diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts
index 3d48aaec..f286962d 100644
--- a/frontend-web/src/open-canvas/types/ai-writing.ts
+++ b/frontend-web/src/open-canvas/types/ai-writing.ts
@@ -220,6 +220,8 @@ export interface UserInterventionPayload {
export interface AIWritingRequest {
userIntent: string;
+ /** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
+ userOutline?: string;
articleType: string;
targetAudience: string;
wordCountTarget: number;
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
index 566011b2..5f843661 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_outline.py
@@ -7,11 +7,19 @@ import logging
from langchain_core.messages import HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
-from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
+from ..outline_parser import parse_user_outline
from ..prompts.writer_prompts import (
- OUTLINE_SYSTEM, OUTLINE_USER, OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
+ OUTLINE_MATERIAL_ASSIGN_SYSTEM,
+ OUTLINE_MATERIAL_ASSIGN_USER,
+ OUTLINE_NORMALIZE_SYSTEM,
+ OUTLINE_NORMALIZE_USER,
+ OUTLINE_REVISE_SYSTEM,
+ OUTLINE_REVISE_USER,
+ OUTLINE_SYSTEM,
+ OUTLINE_USER,
)
-from ._utils import get_writing_model, parse_json, _content_to_text, reasoning_delta, ThinkTagParser, safe_get_stream_writer
+from ..state import AIWritingState, KeyPoint, Outline, OutlineSection
+from ._utils import ThinkTagParser, _content_to_text, get_writing_model, llm_json, parse_json, reasoning_delta, safe_get_stream_writer
logger = logging.getLogger(__name__)
@@ -80,6 +88,108 @@ def _parse_outline(raw: dict) -> Outline:
return Outline(title=raw.get("title", "未命名文章"), sections=sections)
+def _derive_title(state: AIWritingState) -> str:
+ """用户大纲没给总标题时,用写作意图兜底一个标题。"""
+ intent = (state.get("user_intent") or "").strip()
+ if not intent:
+ return "未命名文章"
+ return intent[:30].rstrip() + ("…" if len(intent) > 30 else "")
+
+
+async def _normalize_user_outline_via_llm(state: AIWritingState, config: RunnableConfig, raw: str) -> Outline:
+ """确定性解析失败时,调 LLM 把用户大纲原文归一成三级结构(严格保真、不改写)。
+
+ 再失败则把每个非空行当作一个章节,保证永远产出一份可用大纲、不丢用户内容。
+ """
+ model_name = (state.get("model_name") or "").strip() or None
+ line_fallback = {
+ "title": "",
+ "sections": [
+ {"section_title": ln.strip(), "key_points": [], "material_ids": []}
+ for ln in raw.splitlines() if ln.strip()
+ ] or [{"section_title": "正文", "key_points": [], "material_ids": []}],
+ }
+ result = await llm_json(
+ OUTLINE_NORMALIZE_SYSTEM,
+ OUTLINE_NORMALIZE_USER.format(raw_outline=raw),
+ line_fallback,
+ config,
+ model_name=model_name,
+ )
+ return _parse_outline(result)
+
+
+async def _assign_materials_to_outline(
+ state: AIWritingState, config: RunnableConfig, outline: Outline, pkg: dict
+) -> Outline:
+ """为用户大纲的各章节回填 material_ids(结构锁定:只取编号、按章节序号嫁接)。
+
+ LLM 只负责「哪个章节配哪些素材」,标题与层级一律以解析结果为准,杜绝改写。失败则
+ 保持 material_ids 为空(writer_draft 自带「取前 2 条素材」兜底)。
+ """
+ sections = outline.get("sections", [])
+ materials = pkg.get("materials") or []
+ if not sections or not materials:
+ return outline
+
+ valid_ids = {m["id"] for m in materials}
+ sections_list = "\n".join(
+ f"{i + 1}. {s.get('section_title', '')}" for i, s in enumerate(sections)
+ )
+ model_name = (state.get("model_name") or "").strip() or None
+ result = await llm_json(
+ OUTLINE_MATERIAL_ASSIGN_SYSTEM,
+ OUTLINE_MATERIAL_ASSIGN_USER.format(
+ user_intent=state.get("user_intent", ""),
+ sections_list=sections_list,
+ materials_summary=_materials_summary(pkg),
+ ),
+ {"assignments": []},
+ config,
+ model_name=model_name,
+ )
+
+ assignments = result.get("assignments")
+ if not isinstance(assignments, list):
+ return outline
+ for i, section in enumerate(sections):
+ if i >= len(assignments):
+ break
+ ids = assignments[i]
+ if isinstance(ids, list):
+ section["material_ids"] = [mid for mid in ids if mid in valid_ids]
+ return outline
+
+
+async def _build_outline_from_user(
+ state: AIWritingState, config: RunnableConfig, pkg: dict, raw: str
+) -> dict:
+ """用户自带大纲分支:解析用户大纲 → 关联素材 → 直接进确认,不调 LLM 重新规划。"""
+ writer = safe_get_stream_writer()
+ writer({"type": "outline_chunk", "text": "正在整理你提供的大纲…\n", "is_thinking": True})
+
+ outline = parse_user_outline(raw)
+ if outline is None:
+ # 确定性解析拿不准(散文化/格式混乱)→ LLM 兜底归一
+ outline = await _normalize_user_outline_via_llm(state, config, raw)
+
+ if not outline.get("title"):
+ outline = {**outline, "title": _derive_title(state)}
+
+ # 仍走了检索 → 把已检索素材关联到用户章节(结构锁定)
+ outline = await _assign_materials_to_outline(state, config, outline, pkg)
+
+ section_count = len(outline.get("sections", []))
+ return {
+ "current_outline": outline,
+ "status": "awaiting_outline_confirm",
+ "progress_events": [
+ {"type": "step_start", "agent_name": "作家", "message": "采用你提供的大纲"},
+ {"type": "outline_ready", "agent_name": "作家", "message": f"已整理你的大纲,共 {section_count} 个章节"},
+ ],
+ }
+
+
async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> dict:
pkg = state.get("material_package") or {"keywords": [], "materials": [], "summary": ""}
intervention = state.get("last_user_intervention") or {}
@@ -95,6 +205,13 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
current_outline = state.get("current_outline")
is_revise = bool(feedback) and bool(current_outline)
+ # 用户自带大纲:首轮(尚无大纲、非重规划)且表单粘了大纲原文 → 走「采用用户大纲」分支,
+ # 跳过 LLM 重新规划。后续若用户在确认卡里点「重新规划」,current_outline 已存在 → 落到
+ # 下方 is_revise 编辑模式,按反馈调整用户大纲(符合预期)。
+ user_outline_raw = (state.get("user_outline_raw") or "").strip()
+ if user_outline_raw and not current_outline and not is_revise:
+ return await _build_outline_from_user(state, config, pkg, user_outline_raw)
+
if is_revise:
system_prompt = OUTLINE_REVISE_SYSTEM
user_prompt = OUTLINE_REVISE_USER.format(
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/outline_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/outline_parser.py
new file mode 100644
index 00000000..f5b52dd0
--- /dev/null
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/outline_parser.py
@@ -0,0 +1,220 @@
+"""把用户粘贴的大纲文本解析成三级 ``Outline`` 结构(确定性解析,不依赖 LLM)。
+
+用户在写作表单里可能直接粘贴一份已有大纲,要求「完全按我的大纲生成」。本模块负责
+把各种常见的大纲文本格式严格、保真地转成后端三级结构:
+
+ Outline.sections[] = Level 1 章节(section_title)
+ OutlineSection.key_points[] = Level 2 小节(point_title)
+ KeyPoint.sub_points[] = Level 3 子要点
+
+兼容的输入格式:
+ - Markdown 标题:``#`` / ``##`` / ``###`` / ``####``(首个孤立 ``#`` 视为文章标题)
+ - 中文编号:``一、`` / ``(一)`` / ``第一章`` / ``第一节``
+ - 阿拉伯编号:``1.`` / ``1、`` / ``1.1`` / ``1.1.1``
+ - 缩进 bullet:``-`` / ``*`` / ``•`` / ``·``(用缩进深度表示层级)
+ - 纯文本平铺:每行一个一级标题(短标题、无句末标点)
+
+设计原则:
+ - **保真不改写**:只识别层级、原样保留标题文本,绝不增删或改写用户内容。
+ - **多级混排自适应**:把文本里实际出现的各种「层级标记」收集起来排序,按相对外层
+ 顺序映射到 1/2/3 级——所以不依赖固定的标记搭配,``##``+``-`` 或 ``一、``+缩进
+ 都能正确分层。
+ - **拿不准就交给上层**:识别不出可靠层级(如整段散文)时返回 ``None``,由调用方走
+ LLM 兜底归一,避免硬把散文拆成错误结构。
+"""
+from __future__ import annotations
+
+import re
+
+from .state import KeyPoint, Outline, OutlineSection
+
+# ── 行首标记识别 ──────────────────────────────────────────────────────────────
+
+_CN_NUM = "一二三四五六七八九十百千零〇两"
+
+# Markdown 标题:## 标题
+_MD_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*\S)\s*$")
+# 第一章 / 第1节 / 第一部分
+_CN_CHAPTER_RE = re.compile(rf"^第\s*[{_CN_NUM}\d]+\s*[章节部分篇讲回卷](?:[\s::、..))]\s*|\s+)?(.*)$")
+# 一、 / 十二、 / 一. / 一.
+_CN_DUN_RE = re.compile(rf"^[{_CN_NUM}]+\s*[、..]\s*(.*)$")
+# (一) / (一) / (1) / (1)
+_PAREN_RE = re.compile(rf"^[((]\s*[{_CN_NUM}\d]+\s*[))]\s*(.*)$")
+# 1.1 / 1.1.1(带至少一个点的多级阿拉伯编号)
+_ARABIC_DOTTED_RE = re.compile(r"^(\d+(?:\.\d+)+)\s*[、..))]?\s*(.*)$")
+# 1. / 1、 / 1) / 1.(单级阿拉伯编号)
+_ARABIC_SINGLE_RE = re.compile(r"^\d+\s*[、..))]\s*(.*)$")
+# 带圈数字 ①②③ / 罗马 ⅰ
+_CIRCLED_RE = re.compile(r"^[①②③④⑤⑥⑦⑧⑨⑩⑪⑫⑬⑭⑮]\s*(.*)$")
+# bullet:- / * / • / ·(要求标记后有空白,避免误吞「*强调*」之类)
+_BULLET_RE = re.compile(r"^[-*•·]\s+(.*\S)\s*$")
+
+# 标记分组的「外层度」基准值(越小越外层)。group: 0=标题 1=编号 2=缩进。
+# 同组内 order 决定相对层级;不同组之间靠 group 排序(标题 < 编号 < 缩进)。
+# 编号顺序遵循中文公文惯例:第一章 > 一、 > (一) > 1. > 1.1 > ①。
+_NUM_ORDER = {
+ "cn_chapter": 0.0, # 第一章
+ "cn_dun": 1.0, # 一、
+ "paren": 2.0, # (一)
+ "arabic_single": 3.0, # 1.
+ "circled": 8.0, # ①
+}
+
+
+def _indent_width(line: str) -> int:
+ """计算行首缩进宽度(tab 记 4 列)。"""
+ width = 0
+ for ch in line:
+ if ch == " ":
+ width += 1
+ elif ch == "\t":
+ width += 4
+ else:
+ break
+ return width
+
+
+def _classify(stripped: str, indent: int) -> tuple[tuple[int, float], str] | None:
+ """识别一行的层级标记,返回 ``((group, order), 纯文本)``;无法识别返回 None。
+
+ ``(group, order)`` 是该行的「层级键」:把全文出现过的不同键排序后,按位置映射到
+ 1/2/3 级。这样不必预设标记搭配,相对顺序对了层级就对。
+ """
+ if not stripped:
+ return None
+
+ # 1) Markdown 标题(group 0,order = 井号数)
+ m = _MD_HEADING_RE.match(stripped)
+ if m:
+ depth = len(m.group(1))
+ text = m.group(2).strip()
+ return (0, float(depth)), text
+
+ # 2) 各类编号(group 1)
+ m = _CN_CHAPTER_RE.match(stripped)
+ if m and stripped.startswith("第"):
+ return (1, _NUM_ORDER["cn_chapter"]), (m.group(1) or "").strip()
+ m = _CN_DUN_RE.match(stripped)
+ if m:
+ return (1, _NUM_ORDER["cn_dun"]), m.group(1).strip()
+ m = _ARABIC_DOTTED_RE.match(stripped)
+ if m:
+ depth = m.group(1).count(".") + 1 # 1.1 → 2, 1.1.1 → 3
+ # 多级阿拉伯:order 随层级递增,落在单级(3.0)与圈号(8.0)之间
+ return (1, 3.0 + depth), m.group(2).strip()
+ m = _PAREN_RE.match(stripped)
+ if m:
+ return (1, _NUM_ORDER["paren"]), m.group(1).strip()
+ m = _ARABIC_SINGLE_RE.match(stripped)
+ if m:
+ return (1, _NUM_ORDER["arabic_single"]), m.group(1).strip()
+ m = _CIRCLED_RE.match(stripped)
+ if m:
+ return (1, _NUM_ORDER["circled"]), m.group(1).strip()
+
+ # 3) bullet:用缩进深度区分层级(group 2)
+ m = _BULLET_RE.match(stripped)
+ if m:
+ return (2, float(indent)), m.group(1).strip()
+
+ # 4) 纯文本:用缩进深度区分层级(group 3,最内层基准)
+ return (3, float(indent)), stripped
+
+
+def _looks_like_prose(text: str) -> bool:
+ """判断一行是否更像散文句子而非大纲标题(长、或带句末标点)。"""
+ if len(text) > 50:
+ return True
+ return text.rstrip()[-1:] in "。!?!?;;"
+
+
+def parse_user_outline(raw: str) -> Outline | None:
+ """把用户粘贴的大纲文本解析成三级 ``Outline``;无法可靠解析时返回 ``None``。
+
+ 返回 ``None`` 的情形:空白输入、只有一行且无任何标记、或整体更像散文段落——这些交给
+ 上层用 LLM 兜底归一,避免误拆。
+ """
+ if not raw or not raw.strip():
+ return None
+
+ rows: list[tuple[tuple[int, float], str]] = []
+ for line in raw.splitlines():
+ if not line.strip():
+ continue
+ indent = _indent_width(line)
+ classified = _classify(line.strip(), indent)
+ if classified is None:
+ continue
+ key, text = classified
+ if text:
+ rows.append((key, text))
+
+ if not rows:
+ return None
+
+ marked = any(key[0] != 3 for key, _ in rows) # 是否出现过标题/编号/bullet 标记
+
+ # 纯文本且无任何标记:可能是「平铺标题列表」也可能是散文。
+ # 单行、或任一行像句子 → 判为散文,返回 None 交给 LLM。
+ if not marked:
+ if len(rows) < 2:
+ return None
+ if any(_looks_like_prose(text) for _, text in rows):
+ return None
+
+ # 文章标题:仅在「Markdown 首行是孤立的最浅井号、且存在更深井号」时认定。
+ title = ""
+ body = rows
+ heading_depths = [key[1] for key, _ in rows if key[0] == 0]
+ if heading_depths:
+ min_depth = min(heading_depths)
+ first_key = rows[0][0]
+ is_first_top_heading = first_key == (0, min_depth)
+ only_one_at_top = sum(1 for d in heading_depths if d == min_depth) == 1
+ has_deeper = any(d > min_depth for d in heading_depths)
+ if is_first_top_heading and only_one_at_top and has_deeper:
+ title = rows[0][1]
+ body = rows[1:]
+
+ if not body:
+ return None
+
+ # 把 body 里出现的不同「层级键」排序,按位置映射到 1/2/3 级(超过 3 级压到 3)。
+ distinct_keys = sorted({key for key, _ in body})
+ level_of: dict[tuple[int, float], int] = {
+ key: min(idx + 1, 3) for idx, key in enumerate(distinct_keys)
+ }
+
+ sections: list[OutlineSection] = []
+ cur_section: OutlineSection | None = None
+ cur_point: KeyPoint | None = None
+
+ for key, text in body:
+ level = level_of[key]
+ if level <= 1:
+ cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
+ sections.append(cur_section)
+ cur_point = None
+ elif level == 2:
+ if cur_section is None: # 孤立 L2:提升为章节,避免空标题
+ cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
+ sections.append(cur_section)
+ cur_point = None
+ continue
+ cur_point = KeyPoint(point_title=text, sub_points=[])
+ cur_section["key_points"].append(cur_point)
+ else: # level == 3
+ if cur_section is None: # 孤立 L3:提升为章节
+ cur_section = OutlineSection(section_title=text, key_points=[], material_ids=[])
+ sections.append(cur_section)
+ cur_point = None
+ elif cur_point is None: # 章节下尚无小节:提升为小节
+ cur_point = KeyPoint(point_title=text, sub_points=[])
+ cur_section["key_points"].append(cur_point)
+ else:
+ cur_point["sub_points"].append(text)
+
+ if not sections:
+ return None
+
+ return Outline(title=title, sections=sections)
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
index eb48f278..d49d494e 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/writer_prompts.py
@@ -123,6 +123,60 @@ OUTLINE_REVISE_USER = """写作意图:{user_intent}
请在「当前大纲」基础上落实上述「用户修改要求」,未提到的章节保持原样,输出调整后的完整大纲 JSON。"""
+# ── 用户自带大纲:LLM 兜底归一(确定性解析失败时才用)──────────────────────────
+
+OUTLINE_NORMALIZE_SYSTEM = """你是一名大纲格式整理助手。
+用户粘贴了一份已有的文章大纲(格式可能不规范),你的任务是把它原样转成结构化 JSON。
+
+【最高原则:保真,绝不改写】
+- 严格保留用户写的每一条标题文字,逐字照搬,不要润色、改写、翻译、增加或删除任何条目。
+- 你只判断层级(哪条是章节、哪条是小节、哪条是子要点),不创作内容。
+- 层级判断依据用户原文里的编号(一、/(一)/1./1.1)、Markdown 井号数、或缩进深度。
+
+【三级结构】
+- Level 1 章节 → section_title
+- Level 2 小节 → key_points[].point_title
+- Level 3 子要点 → key_points[].sub_points[](字符串数组)
+- 用户大纲只有一级时,每条都是章节,key_points 留空 []。
+- 用户大纲只有两级时,sub_points 留空 []。
+
+【输出要求】
+- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
+- material_ids 一律输出 [](此步不关联素材)。
+- 结构:{"title": "...", "sections": [{"section_title": "...", "key_points": [{"point_title": "...", "sub_points": ["..."]}], "material_ids": []}]}
+- 用户大纲若包含一个总标题,放进 title;没有就留空字符串。"""
+
+OUTLINE_NORMALIZE_USER = """用户粘贴的大纲原文:
+{raw_outline}
+
+请逐条保留原文,仅判断层级,输出结构化大纲 JSON。"""
+
+# ── 用户自带大纲:素材关联(结构锁定,只挑 material_ids)──────────────────────────
+
+OUTLINE_MATERIAL_ASSIGN_SYSTEM = """你是一名资料编辑。
+给定一份【已确定的文章大纲】(章节顺序固定)和一批检索到的素材,你的任务是为每个章节
+挑选最相关的素材编号。
+
+【规则】
+- 不要修改大纲的任何标题或结构,你只输出每个章节对应的素材编号列表。
+- 严格按大纲给出的章节顺序输出,一个章节对应一个编号数组;该章节没有相关素材就给 []。
+- material_id 必须来自给定素材清单里出现过的编号,不要编造。
+- 一条素材可以分配给多个章节,也可以一个都不分配。
+
+【输出要求】
+- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
+- 结构:{"assignments": [["m001","m002"], [], ["m003"]]},数组长度等于章节数、顺序一致。"""
+
+OUTLINE_MATERIAL_ASSIGN_USER = """写作意图:{user_intent}
+
+文章大纲(章节按顺序编号):
+{sections_list}
+
+可用素材:
+{materials_summary}
+
+请按章节顺序,为每个章节挑选相关的 material_id,输出 assignments 数组。"""
+
DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。
你的目标:根据大纲和素材,写出一段结构清晰、高质量的文章内容。
你的工作背景:你有十年内容创作经验,文字流畅,善于用事实说话。
diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
index f8bfec66..1f70c698 100644
--- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
+++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/state.py
@@ -125,6 +125,9 @@ class BlockedSection(TypedDict):
class AIWritingState(TypedDict):
# 写作配置
user_intent: str
+ # 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
+ # 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
+ user_outline_raw: str
article_type: str
target_audience: str
word_count_target: int
From ad4b27b7caa0754b490554fff1fe212c4106b3b7 Mon Sep 17 00:00:00 2001
From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com>
Date: Wed, 3 Jun 2026 16:20:45 +0800
Subject: [PATCH 2/2] =?UTF-8?q?feat:=20=E6=97=A0=E7=95=8C=20ticket=20?=
=?UTF-8?q?=E7=99=BB=E5=BD=95=E3=80=81AI=20=E5=86=99=E4=BD=9C=E9=85=8D?=
=?UTF-8?q?=E7=BD=AE=E7=A1=AE=E8=AE=A4=E4=B8=8E=E5=AE=9A=E6=97=B6=E4=BB=BB?=
=?UTF-8?q?=E5=8A=A1=E5=BC=B9=E7=AA=97=E4=BF=AE=E5=A4=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
无界 onlineTicket 对接 MaxKey 与 consumer 接口并用 sub 登录;新增 setup_writing 写作配置确认流;定时任务创建弹窗限制多行高度并支持滚动。
---
frontend-web/.env.development | 2 +
frontend-web/.env.development.local.example | 1 +
frontend-web/.env.example | 4 +
frontend-web/.env.network | 2 +
frontend-web/.env.production | 2 +
frontend-web/.env.sit | 2 +
.../workspace/messages/message-list.tsx | 181 ++++++++++++++++++
frontend-web/src/core/auth/external-login.ts | 4 +
.../src/core/auth/wujie-ticket-login.ts | 142 ++++++++++++++
frontend-web/src/core/embed/wujie.ts | 4 +-
frontend-web/src/core/messages/utils.ts | 22 ++-
.../components/ai-writing/AIWritingForm.tsx | 27 +--
.../ai-writing/WritingSetupChat.tsx | 164 ++++++++++++++++
.../layout/CanvasResizableLayout.tsx | 5 +-
.../contexts/WritingFormContext.tsx | 102 ++++++++++
.../src/open-canvas/pages/AIWritingPage.tsx | 17 +-
.../src/pages/AdminScheduledTasksPage.tsx | 11 +-
frontend-web/src/pages/LoginPage.tsx | 26 ++-
frontend-web/src/pages/ScheduledTasksPage.tsx | 27 ++-
.../backend/app/gateway/services.py | 2 +
.../deerflow/agents/lead_agent/agent.py | 27 ++-
.../deerflow/agents/lead_agent/prompt.py | 76 ++++++--
.../setup_writing_approval_middleware.py | 129 +++++++++++++
.../deerflow/tools/builtins/__init__.py | 2 +
.../tools/builtins/setup_writing_tool.py | 53 +++++
25 files changed, 980 insertions(+), 54 deletions(-)
create mode 100644 frontend-web/src/core/auth/wujie-ticket-login.ts
create mode 100644 frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx
create mode 100644 frontend-web/src/open-canvas/contexts/WritingFormContext.tsx
create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py
create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py
diff --git a/frontend-web/.env.development b/frontend-web/.env.development
index 6228c720..15aac5a9 100644
--- a/frontend-web/.env.development
+++ b/frontend-web/.env.development
@@ -26,6 +26,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+# MaxKey 鉴权(getTokenByOnlineTicket)
+VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
# 可选:Canvas 独立 LangGraph 地址(不设则回退到 VITE_LANGGRAPH_BASE_URL)
diff --git a/frontend-web/.env.development.local.example b/frontend-web/.env.development.local.example
index f01f2187..a5092803 100644
--- a/frontend-web/.env.development.local.example
+++ b/frontend-web/.env.development.local.example
@@ -17,6 +17,7 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
diff --git a/frontend-web/.env.example b/frontend-web/.env.example
index 80a2b0f7..0c211a17 100644
--- a/frontend-web/.env.example
+++ b/frontend-web/.env.example
@@ -25,6 +25,10 @@
# --- 生产打包(见 .env.production)---
# 部署前修改 .env.production 中的地址,再执行 pnpm build
+# MaxKey 鉴权根地址(无界 onlineTicket 换 token)
+# VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
+# VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+
# VITE_AIPI_B2ACTOR_URL=https://ty.b.uat.4.cn
# VITE_AIPI_B2ACTOR_XWT_PATH=/fromB/toXWT?isFrame=1
# VITE_AIPI_KNOWLEDGE_IFRAME_API_URL=https://ch1.b.uat.4.cn/knowes/api/iframeKnowledgeUrl
diff --git a/frontend-web/.env.network b/frontend-web/.env.network
index f8962ea6..4bcd8f15 100644
--- a/frontend-web/.env.network
+++ b/frontend-web/.env.network
@@ -23,6 +23,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=http://192.168.1.100/likun_b1_mope
VITE_AIPI_BACKENP_URL=http://192.168.1.100
VITE_AIPI_CONSUMER_URL=http://192.168.1.100/consumer
+# MaxKey 鉴权(getTokenByOnlineTicket,内网部署请改为实际地址)
+VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=http://192.168.1.100
VITE_AIPI_XKLINKTO_URL=http://192.168.1.100
diff --git a/frontend-web/.env.production b/frontend-web/.env.production
index 0f10b55c..67dabce1 100644
--- a/frontend-web/.env.production
+++ b/frontend-web/.env.production
@@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.uat.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.uat.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.uat.4.cn/consumer
+# MaxKey 鉴权(getTokenByOnlineTicket)
+VITE_AUTH_BASE_URL=https://auth.y.uat.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.uat.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.uat.4.cn
diff --git a/frontend-web/.env.sit b/frontend-web/.env.sit
index 5ae77457..913fe3a3 100644
--- a/frontend-web/.env.sit
+++ b/frontend-web/.env.sit
@@ -21,6 +21,8 @@ VITE_AIPI_KNOWLEDGE_IFRAME_SKIN=red
VITE_AIPI_MOPECHAT_URL=https://ch1.b.sit.4.cn/likun_b1_mope
VITE_AIPI_BACKENP_URL=https://knowledgetoolserver.b.sit.4.cn
VITE_AIPI_CONSUMER_URL=https://ch1.b.sit.4.cn/consumer
+# MaxKey 鉴权(getTokenByOnlineTicket)
+VITE_AUTH_BASE_URL=https://auth.y.sit.4.cn
VITE_AIPI_PGLINKTO_URL=https://pg.b.sit.4.cn
VITE_AIPI_XKLINKTO_URL=https://kz.b.sit.4.cn
diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx
index 33463bf9..d5149b45 100644
--- a/frontend-web/src/components/workspace/messages/message-list.tsx
+++ b/frontend-web/src/components/workspace/messages/message-list.tsx
@@ -60,6 +60,8 @@ export function MessageList({
onClarificationSubmit,
onSetupAgentApprove,
onSetupAgentReject,
+ onSetupWritingApprove,
+ onSetupWritingReject,
}: {
className?: string;
threadId: string;
@@ -70,6 +72,8 @@ export function MessageList({
onClarificationSubmit?: (answer: string) => void | Promise;
onSetupAgentApprove?: (payload: SetupAgentApprovalPayload) => void | Promise;
onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise;
+ onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise;
+ onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise;
}) {
const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading);
const rehypePlugins = useMemo(
@@ -77,6 +81,7 @@ export function MessageList({
[baseRehypePlugins, extraRehypePlugins],
);
const updateSubtask = useUpdateSubtask();
+ const { t } = useI18n();
const messages = thread.messages;
// Stamp a wall-clock time onto every question/answer the first time it is
@@ -307,6 +312,23 @@ export function MessageList({
/>
) : null;
+ } else if (group.type === "assistant:setup-writing-approval") {
+ const message = group.messages[0];
+ return message ? (
+
+
+
+
+ ) : null;
} else if (group.type === "assistant:present-files") {
const files: string[] = [];
for (const message of group.messages) {
@@ -485,6 +507,16 @@ export type SetupAgentApprovalPayload = {
skills?: string[];
};
+export type SetupWritingApprovalPayload = {
+ user_intent?: string;
+ article_type?: string;
+ word_count_target?: number | null;
+ target_audience?: string;
+ writing_mode?: string;
+ material_source?: string;
+ outline?: string;
+};
+
function findPreviousHumanMessageId(
messages: Message[],
beforeMessageId?: string,
@@ -635,6 +667,155 @@ function SetupAgentApprovalCard({
);
}
+function getSetupWritingApprovalPayload(
+ message: unknown,
+): SetupWritingApprovalPayload | null {
+ if (typeof message !== "object" || message === null) {
+ return null;
+ }
+ const additionalKwargs = Reflect.get(message, "additional_kwargs");
+ if (typeof additionalKwargs !== "object" || additionalKwargs === null) {
+ return null;
+ }
+ const approval = Reflect.get(additionalKwargs, "setup_writing_approval");
+ if (typeof approval !== "object" || approval === null) {
+ return null;
+ }
+ return approval as SetupWritingApprovalPayload;
+}
+
+const WRITING_MODE_LABELS: Record
= {
+ strict: "严格依据素材",
+ loose: "可结合通用知识",
+};
+const MATERIAL_SOURCE_LABELS: Record = {
+ knowledge_base: "知识库",
+ skill: "技能检索",
+};
+
+function SetupWritingApprovalCard({
+ disabled,
+ message,
+ onApprove,
+ onReject,
+}: {
+ disabled: boolean;
+ message: unknown;
+ onApprove?: (payload: SetupWritingApprovalPayload) => void | Promise;
+ onReject?: (payload: SetupWritingApprovalPayload) => void | Promise;
+}) {
+ const payload = getSetupWritingApprovalPayload(message);
+ const [submitting, setSubmitting] = useState<"approve" | "reject" | null>(
+ null,
+ );
+
+ if (!payload) {
+ return null;
+ }
+
+ const approvalPayload = payload;
+ const busy = disabled || submitting !== null;
+
+ async function run(action: "approve" | "reject") {
+ if (busy) {
+ return;
+ }
+ setSubmitting(action);
+ try {
+ if (action === "approve") {
+ await onApprove?.(approvalPayload);
+ } else {
+ await onReject?.(approvalPayload);
+ }
+ } finally {
+ setSubmitting(null);
+ }
+ }
+
+ const rows: Array<{ label: string; value: string }> = [];
+ if (approvalPayload.article_type) {
+ rows.push({ label: "文章类型", value: approvalPayload.article_type });
+ }
+ if (typeof approvalPayload.word_count_target === "number") {
+ rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` });
+ }
+ if (approvalPayload.target_audience) {
+ rows.push({ label: "目标读者", value: approvalPayload.target_audience });
+ }
+ if (approvalPayload.writing_mode) {
+ rows.push({
+ label: "写作模式",
+ value: WRITING_MODE_LABELS[approvalPayload.writing_mode] ?? approvalPayload.writing_mode,
+ });
+ }
+ if (approvalPayload.material_source) {
+ rows.push({
+ label: "素材来源",
+ value: MATERIAL_SOURCE_LABELS[approvalPayload.material_source] ?? approvalPayload.material_source,
+ });
+ }
+
+ return (
+
+
+
+
写作配置建议
+
确认后将应用到左侧写作表单。
+
+
+ {approvalPayload.user_intent ? (
+
+
写作意图
+
{approvalPayload.user_intent}
+
+ ) : null}
+
+ {rows.length > 0 ? (
+
+ {rows.map((row) => (
+
+
{row.label}
+
{row.value}
+
+ ))}
+
+ ) : null}
+
+ {approvalPayload.outline ? (
+
+
已有大纲
+
+ {approvalPayload.outline}
+
+
+ ) : null}
+
+
+
+
+
+
+
+ );
+}
+
function getClarificationPayload(message: unknown): ClarificationPayload | null {
if (typeof message !== "object" || message === null) {
return null;
diff --git a/frontend-web/src/core/auth/external-login.ts b/frontend-web/src/core/auth/external-login.ts
index bd80d522..37c865a6 100644
--- a/frontend-web/src/core/auth/external-login.ts
+++ b/frontend-web/src/core/auth/external-login.ts
@@ -13,6 +13,10 @@ export const EXTERNAL_LOGIN_STORAGE_KEYS = {
ticket: "login.ticket",
userId: "login.userId",
env: "login.env",
+ /** MaxKey getTokenByOnlineTicket 返回的 token */
+ maxkeyOnlineToken: "login.maxkeyOnlineToken",
+ /** consumer /cop/userDetailToken 返回的 data 整包(JSON) */
+ userDetailToken: "login.userDetailToken",
} as const;
export type ExternalLoginParams = {
diff --git a/frontend-web/src/core/auth/wujie-ticket-login.ts b/frontend-web/src/core/auth/wujie-ticket-login.ts
new file mode 100644
index 00000000..9bccd610
--- /dev/null
+++ b/frontend-web/src/core/auth/wujie-ticket-login.ts
@@ -0,0 +1,142 @@
+import { requestCrudService } from "@/service/request-axios-service";
+
+import {
+ EXTERNAL_LOGIN_STORAGE_KEYS,
+ persistExternalLoginParams,
+} from "./external-login";
+
+const AUTH_BASE_URL = (import.meta.env.VITE_AUTH_BASE_URL || "").replace(
+ /\/$/,
+ "",
+);
+const CONSUMER_URL = (import.meta.env.VITE_AIPI_CONSUMER_URL || "").replace(
+ /\/$/,
+ "",
+);
+
+/** MaxKey onlineTicket 换发的 token(后续请求可能用到) */
+export const MAXKEY_ONLINE_TOKEN_STORAGE_KEY =
+ EXTERNAL_LOGIN_STORAGE_KEYS.maxkeyOnlineToken;
+
+/** `/cop/userDetailToken` 返回的 data 整包(JWT 解析后的用户信息) */
+export const USER_DETAIL_TOKEN_STORAGE_KEY =
+ EXTERNAL_LOGIN_STORAGE_KEYS.userDetailToken;
+
+export type MaxkeyTokenByTicketResponse = {
+ code: number;
+ data: string;
+ message?: string;
+};
+
+export type UserDetailTokenPayload = {
+ sub?: string;
+ user_id?: string;
+ exp?: number;
+ expired?: boolean;
+ iat?: number;
+ institution?: string;
+ iss?: string;
+ locale?: string;
+ kid?: string;
+ jti?: string;
+ [key: string]: unknown;
+};
+
+type UserDetailTokenResponse = {
+ code?: number;
+ data?: UserDetailTokenPayload;
+ message?: string;
+};
+
+function persistMaxkeyOnlineToken(token: string): void {
+ try {
+ localStorage.setItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY, token);
+ } catch {
+ // 静默失败,不阻断登录
+ }
+}
+
+function persistUserDetailToken(data: UserDetailTokenPayload): void {
+ try {
+ localStorage.setItem(USER_DETAIL_TOKEN_STORAGE_KEY, JSON.stringify(data));
+ if (data.user_id) {
+ localStorage.setItem(EXTERNAL_LOGIN_STORAGE_KEYS.userId, String(data.user_id));
+ }
+ } catch {
+ // 静默失败
+ }
+}
+
+/**
+ * 无界 / 外部跳转:用 onlineTicket 换 MaxKey token,并拉取用户详情。
+ * - token 存入 localStorage(`login.maxkeyOnlineToken`)
+ * - userDetailToken 的 data 存入 localStorage(`login.userDetailToken`)
+ * @returns 用于 DeerFlow `loginByUsername` 的用户名(取自 data.sub)
+ */
+export async function resolveUsernameByOnlineTicket(
+ ticket: string,
+): Promise {
+ const onlineTicket = ticket.trim();
+ if (!onlineTicket) {
+ throw new Error("缺少 onlineTicket,无法完成无界登录。");
+ }
+ if (!AUTH_BASE_URL) {
+ throw new Error("未配置 VITE_AUTH_BASE_URL,无法换取 MaxKey token。");
+ }
+ if (!CONSUMER_URL) {
+ throw new Error("未配置 VITE_AIPI_CONSUMER_URL,无法获取用户详情。");
+ }
+
+ persistExternalLoginParams({ ticket: onlineTicket });
+
+ const tokenRes = (await requestCrudService.get(
+ `${AUTH_BASE_URL}/maxkey-mgt-api/sys/getTokenByOnlineTicket`,
+ { onlineTicket },
+ )) as unknown as MaxkeyTokenByTicketResponse;
+
+ console.log("[wujie] getTokenByOnlineTicket:", tokenRes);
+
+ if (tokenRes.code !== 200 || !tokenRes.data) {
+ throw new Error(
+ tokenRes.message || "getTokenByOnlineTicket 未返回有效 token。",
+ );
+ }
+ persistMaxkeyOnlineToken(tokenRes.data);
+
+ const detailRes = (await requestCrudService.get(
+ `${CONSUMER_URL}/cop/userDetailToken`,
+ { ticket: onlineTicket },
+ )) as unknown as UserDetailTokenResponse;
+
+ console.log("[wujie] userDetailToken:", detailRes);
+
+ const userData = detailRes.data;
+ if (!userData) {
+ throw new Error("userDetailToken 未返回用户 data。");
+ }
+ persistUserDetailToken(userData);
+
+ const username = (userData.sub ?? "").trim();
+ if (!username) {
+ throw new Error("userDetailToken 中缺少 sub(用户名)。");
+ }
+ return username;
+}
+
+export function readMaxkeyOnlineToken(): string {
+ try {
+ return localStorage.getItem(MAXKEY_ONLINE_TOKEN_STORAGE_KEY) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+export function readUserDetailToken(): UserDetailTokenPayload | null {
+ try {
+ const raw = localStorage.getItem(USER_DETAIL_TOKEN_STORAGE_KEY);
+ if (!raw) return null;
+ return JSON.parse(raw) as UserDetailTokenPayload;
+ } catch {
+ return null;
+ }
+}
diff --git a/frontend-web/src/core/embed/wujie.ts b/frontend-web/src/core/embed/wujie.ts
index cd98e224..e725cba2 100644
--- a/frontend-web/src/core/embed/wujie.ts
+++ b/frontend-web/src/core/embed/wujie.ts
@@ -6,7 +6,7 @@
* 注入参数,子应用在 `window.$wujie.props` 上拿到**同一个对象**(无界在子应用
* bootstrap 之前就已注入,因此首帧即可读)。
*
- * 本期范围:只「读出来 + 存起来 + 打印」,**不接入对方鉴权**。读出的
+ * ticket 在 LoginPage 会走 MaxKey + consumer 双接口换用户名并登录;读出的
* ticket/userId/env 复用 external-login 的 localStorage 存储(与 URL 跳转登录
* 同一套 key),theme 交给调用方(LoginPage)映射到 ThemeContext。
*/
@@ -16,7 +16,7 @@ import { persistExternalLoginParams } from "@/core/auth/external-login";
/** 无界父级注入的 props 形状。除已知字段外允许透传其余未知字段便于排查。 */
export interface WujieProps {
- /** 第三方鉴权 ticket(本期仅存储待用,不消费)。 */
+ /** 第三方 onlineTicket(LoginPage 用于 MaxKey + userDetailToken 登录)。 */
ticket?: string;
/** 外部用户 id。 */
userId?: string;
diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts
index f13c5747..fbc038a7 100644
--- a/frontend-web/src/core/messages/utils.ts
+++ b/frontend-web/src/core/messages/utils.ts
@@ -20,6 +20,8 @@ interface AssistantClarificationGroup extends GenericMessageGroup<"assistant:cla
interface AssistantSetupAgentApprovalGroup extends GenericMessageGroup<"assistant:setup-agent-approval"> {}
+interface AssistantSetupWritingApprovalGroup extends GenericMessageGroup<"assistant:setup-writing-approval"> {}
+
interface AssistantSubagentGroup extends GenericMessageGroup<"assistant:subagent"> {}
type MessageGroup =
@@ -29,6 +31,7 @@ type MessageGroup =
| AssistantPresentFilesGroup
| AssistantClarificationGroup
| AssistantSetupAgentApprovalGroup
+ | AssistantSetupWritingApprovalGroup
| AssistantSubagentGroup;
export function groupMessages(
@@ -50,7 +53,8 @@ export function groupMessages(
last.type !== "human" &&
last.type !== "assistant" &&
last.type !== "assistant:clarification" &&
- last.type !== "assistant:setup-agent-approval"
+ last.type !== "assistant:setup-agent-approval" &&
+ last.type !== "assistant:setup-writing-approval"
) {
return last;
}
@@ -88,6 +92,13 @@ export function groupMessages(
type: "assistant:setup-agent-approval",
messages: [message],
});
+ } else if (isSetupWritingApprovalToolMessage(message)) {
+ lastOpenGroup()?.messages.push(message);
+ groups.push({
+ id: message.id,
+ type: "assistant:setup-writing-approval",
+ messages: [message],
+ });
} else {
const open = lastOpenGroup();
if (open) {
@@ -354,6 +365,15 @@ export function isSetupAgentApprovalToolMessage(message: Message) {
);
}
+export function isSetupWritingApprovalToolMessage(message: Message) {
+ return (
+ message.type === "tool" &&
+ message.name === "setup_writing" &&
+ typeof message.additional_kwargs?.setup_writing_approval === "object" &&
+ message.additional_kwargs.setup_writing_approval !== null
+ );
+}
+
export function extractPresentFilesFromMessage(message: Message) {
if (message.type !== "ai" || !hasPresentFiles(message)) {
return [];
diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
index 246a4b09..bfa8dfd6 100644
--- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
+++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo } from 'react';
import { PenLine } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -14,6 +14,7 @@ import { useModels } from '@/core/models/hooks';
import type { AIWritingRequest } from '../../types/ai-writing';
import { useArticleTypes } from '../../hooks/useArticleTypes';
+import { useWritingForm } from '../../contexts/WritingFormContext';
interface AIWritingFormProps {
onSubmit: (req: AIWritingRequest) => void;
@@ -31,7 +32,6 @@ const INPUT_CLASS =
const DEFAULT_KEYWORD_COUNT = 1;
const DEFAULT_AUDIENCE = '普通读者';
-const DEFAULT_MATERIAL_SOURCE: 'knowledge_base' | 'skill' = 'knowledge_base';
const MAX_INTENT_LENGTH = 500;
// 素材来源选项:知识库=素材专家默认 web/intranet 检索;技能检索=用技能编排去检索
@@ -49,16 +49,17 @@ export function AIWritingForm({
disabled,
hideAdvancedFields = false,
}: AIWritingFormProps) {
- const [intent, setIntent] = useState('');
- const [showOutline, setShowOutline] = useState(false);
- const [outlineText, setOutlineText] = useState('');
- const [articleType, setArticleType] = useState('analysis');
- const [audience, setAudience] = useState('普通读者');
- const [wordCountInput, setWordCountInput] = useState('800');
- const [strictMode, setStrictMode] = useState(false);
- const [materialSource, setMaterialSource] =
- useState<'knowledge_base' | 'skill'>(DEFAULT_MATERIAL_SOURCE);
- const [modelName, setModelName] = useState('');
+ const {
+ intent, setIntent,
+ showOutline, setShowOutline,
+ outlineText, setOutlineText,
+ articleType, setArticleType,
+ audience, setAudience,
+ wordCountInput, setWordCountInput,
+ strictMode, setStrictMode,
+ materialSource, setMaterialSource,
+ modelName, setModelName,
+ } = useWritingForm();
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
@@ -161,7 +162,7 @@ export function AIWritingForm({