feat(ai写作): 大纲整理可见化 + 检索词结合大纲 + 素材/大纲阶段关思考

- 草稿确认卡:「给出修改意见」「让编辑继续审核」移到「确认定稿」上方。
- 大纲整理阶段文案:用户自带大纲时显示「整理你的大纲」而非「规划大纲」
  (OutlineStreamingView / 骨架屏 / 状态栏,按 request.userOutline 切换)。
- 素材收集专家:用户自带大纲时把大纲原文带入检索词生成,让关键词结合写作
  意图覆盖各章节主题(_keyword_outline_section,agent + legacy 两路)。
- 素材收集(检索词/排序)与大纲整理/生成阶段强制关闭思考
  (create_chat_model force_disable_thinking:GLM thinking.disabled +
  Qwen/vLLM enable_thinking=false),关不掉的模型仍由 ThinkTagParser 把
  <think> 分流到思考框正常展示。disable_thinking 串入
  _utils.get_model/llm_json 与 _agent_runner.stream/run_writing_agent。
- 含草稿「局部修改」WIP(writer_draft 只重写命中章节)及外观控件改动。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
fengxiangdebaoziandClaude Opus 4.8 committed 2026-06-24 18:32:10 +08:00
1 parent 8678ceb422
commit 49173d9c28
13 files changed
+484 -35

No files matched your search

@@ -2,6 +2,7 @@
import { Check, LayoutIcon, PanelLeftIcon, Shirt } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "@/components/ui/button";
import {
@@ -140,6 +141,7 @@ export function LayoutModeSwitcher({
fallback?: "full" | "compact";
}) {
const [mode, setLayoutMode] = useLayoutMode();
const navigate = useNavigate();
const current = mode ?? fallback;
const TriggerIcon = current === "full" ? PanelLeftIcon : LayoutIcon;
@@ -166,7 +168,10 @@ export function LayoutModeSwitcher({
return (
<DropdownMenuItem
key={option.id}
onClick={() => setLayoutMode(option.id)}
onClick={() => {
setLayoutMode(option.id);
navigate("/page/workspace/chats/new");
}}
className="gap-2"
>
<Icon className="size-4 shrink-0" />
@@ -496,9 +496,13 @@ export function AIWritingPanel({
/>
)}
{/* 大纲流式思考框 */}
{/* 大纲流式思考框(用户自带大纲时文案显示「整理」) */}
{state.status === 'writing_outline' && (state.outlineStreamingText || state.outlineThinkingText) && (
<OutlineStreamingView text={state.outlineStreamingText} thinkingText={state.outlineThinkingText} />
<OutlineStreamingView
text={state.outlineStreamingText}
thinkingText={state.outlineThinkingText}
organizing={!!state.request?.userOutline}
/>
)}
{/* 编辑审核流式结果 */}
@@ -560,7 +564,11 @@ export function AIWritingPanel({
/* 意图解析流式过程中(LLM 还在吐 JSON)不要显示 skeleton */
!state.intentStreamingText && !state.intentThinkingText && (
<div className="px-4 pb-4">
<WritingSkeletonCard status={state.status} researchProgress={state.researchProgress} />
<WritingSkeletonCard
status={state.status}
researchProgress={state.researchProgress}
organizing={!!state.request?.userOutline}
/>
</div>
)}
@@ -605,6 +613,8 @@ export function AIWritingPanel({
)}
{state.status === 'researching' && state.researchProgress
? state.researchProgress
: state.status === 'writing_outline' && state.request?.userOutline
? '作家整理你的大纲中…'
: STATUS_LABEL[state.status] ?? state.status}
</>
)}
@@ -455,13 +455,6 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio
)}
<div className="flex flex-col gap-1.5 pt-1">
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
{/* 按编辑审核意见修改(有审核结果时始终显示) */}
{hasReview && (
<Button
@@ -504,6 +497,13 @@ function DraftConfirmCard({ draftTitle, draftWordCount, wordCountTarget, revisio
<ArrowRight className="size-3.5" />让编辑继续审核
</Button>
</div>
<Button
type="button" size="sm" className="w-full h-8 text-xs gap-1.5"
onClick={() => submit('finalize')} disabled={disabled || submitting}
>
<CheckCircle2 className="size-3.5" />确认定稿
</Button>
</div>
</div>
);
@@ -4,9 +4,11 @@ import { FileText, ChevronDown, ChevronRight, Brain } from 'lucide-react';
interface OutlineStreamingViewProps {
text: string;
thinkingText?: string;
/** 用户自带大纲时为 true:文案显示「整理你的大纲」而非「规划大纲」。 */
organizing?: boolean;
}
export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingViewProps) {
export function OutlineStreamingView({ text, thinkingText, organizing }: OutlineStreamingViewProps) {
const preRef = useRef<HTMLPreElement>(null);
const thinkRef = useRef<HTMLPreElement>(null);
const [thinkOpen, setThinkOpen] = useState(true);
@@ -66,7 +68,7 @@ export function OutlineStreamingView({ text, thinkingText }: OutlineStreamingVie
<div className="flex items-center gap-2 px-3 py-2 border-b border-purple-200/60 dark:border-purple-900/60 bg-purple-100/40 dark:bg-purple-900/30">
<FileText className="size-3.5 text-purple-500 shrink-0" />
<span className="text-xs font-medium text-purple-600 dark:text-purple-400">
作家正在规划大纲…
{organizing ? '作家正在整理你的大纲…' : '作家正在规划大纲…'}
</span>
<span className="ml-auto inline-flex gap-1">
{[0, 1, 2].map((i) => (
@@ -5,6 +5,8 @@ interface WritingSkeletonCardProps {
status: WritingStatus;
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
researchProgress?: string;
/** 用户自带大纲时为 true:大纲阶段文案显示「整理你的大纲」而非「规划大纲」。 */
organizing?: boolean;
}
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
@@ -80,12 +82,12 @@ function MaterialsSkeleton({ researchProgress }: { researchProgress?: string })
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
function OutlineSkeleton() {
function OutlineSkeleton({ organizing }: { organizing?: boolean } = {}) {
return (
<div className="space-y-3 p-4">
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
<ThinkingDots color="bg-purple-400" />
<span>作家规划大纲中…</span>
<span>{organizing ? '作家整理你的大纲中…' : '作家规划大纲中…'}</span>
</div>
{/* 文章标题占位 */}
@@ -213,7 +215,7 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
};
export function WritingSkeletonCard({ status, researchProgress }: WritingSkeletonCardProps) {
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
// 素材收集阶段单独渲染,以便透传实时进度文案
if (status === 'researching' || status === 'awaiting_material_confirm') {
return (
@@ -222,6 +224,14 @@ export function WritingSkeletonCard({ status, researchProgress }: WritingSkeleto
</div>
);
}
// 大纲阶段单独渲染,以便透传「整理 / 规划」文案(用户自带大纲走「整理」)
if (status === 'writing_outline' || status === 'awaiting_outline_confirm') {
return (
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
<OutlineSkeleton organizing={organizing} />
</div>
);
}
const Skeleton = SKELETON_MAP[status];
if (!Skeleton) return null;
return (
@@ -278,6 +278,8 @@ Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship
**Agentic skill execution (`ai_writing.researcher_skill_agent`, default false).** By default a configured non-native skill is *not* run agent-style — the node just turns its `description` into a query for the generic search provider. With this flag on, the directed-skill branch instead runs `nodes/_skill_agent.py::run_skill_search_agent`: it injects the skill's **SKILL.md** (read from `skill.skill_file`) alongside the researcher SOUL as the system prompt, binds a `search` tool to the researcher model, and runs a bounded (`_MAX_TURNS=4`) tool-call loop so the model drives the retrieval per the skill's instructions; the `SearchResult`s the tool returns are harvested directly as materials (no fuzzy text extraction). Requires a tool-calling model; if the model can't tool-call (returns no `tool_calls`) or the agent yields nothing, `_execute_skill` falls back to the legacy description→query search so materials are never empty. The built-in `knowledge-base-search` native concurrent path is unchanged. Tests: `tests/test_ai_writing_skill_agent.py`.
**Targeted user revision (局部修改 — 只改命中章节).** When the user submits a free-text 修改意见 on the draft-confirm card (`action == "user_revise"`), `writer_draft_node` no longer always rewrites the whole article section-by-section. Before the full-rewrite loop it tries `_targeted_user_revision` (`nodes/writer_draft.py`): `_split_draft_sections` parses the previous `current_draft.full_markdown` into `(article_title, [(section_title, body)], references_block)` (level-1 `#` title, level-2 `##` sections, trailing `## 参考文献` pulled out and kept verbatim), then `_detect_revision_scope` runs a small `llm_json` classifier (prompts `DRAFT_REVISE_SCOPE_{SYSTEM,USER}`) that maps the note to the section indices it targets ("第一段/开头/引言"→0, "结尾/结论"→last, by title/topic, etc.). **Only the hit sections are rewritten** via `_stream_revise_section` (prompt `DRAFT_REVISE_SECTION_USER`, streamed as `draft_chunk` so the user watches the edit); **every unhit section is preserved byte-for-byte** and emitted to the stream as one full-text `draft_chunk` + `is_section_done` so the frontend's `streamingDraftSections` rebuilds the complete article (it was reset to `[]` on entering `revising`). The classifier returns `whole_article=true` (整体性意见 like 「整体语气更正式」/「全文都改」, or any structure-changing ask) → `_detect_revision_scope` yields `None` → `_targeted_user_revision` returns `None` → the node **falls back to the existing whole-article rewrite** (which still runs `_revise_outline_with_notes` for title/structure changes). Also falls back when there is no prior draft, an empty note, or scope detection errors. Editor-review revisions (`accept_review`) are unaffected — they keep the holistic rewrite. Reassembly preserves the article title + section structure and re-appends the original references block; word count is computed on the body (excluding references), matching the main path. Tests: `tests/test_ai_writing_targeted_revision.py`.
**Background suspend (后台挂起).** A writing session can be handed off to the server to auto-drive the remaining flow to finalize, surviving page leave / refresh / account switch. `POST /api/ai-writing/sessions/{id}/background` (owner-checked, idempotent) starts `AIWritingJobExecutor` (`app/gateway/ai_writing_job_executor.py`), wired in `deps.py` as `app.state.ai_writing_job_executor`. The executor **drives the same `ai_writing` graph in-process** — it builds `make_ai_writing_graph()`, attaches the shared `app.state.checkpointer` (the very saver the runtime worker uses, so it continues the real thread state the frontend already wrote), sets the owning user via `set_current_user`, and loops `aget_state` → `ainvoke(Command(resume=…))` until `status==done`. **Auto-action per interrupt**: material/outline confirm → `confirm`; section_help → every blocked section `loose` (continue with general knowledge, never re-loop to research); draft_confirm → `to_editor` until reviewed, then `finalize` (on pass, or when `revision_count >= max_revisions`); review_confirm → `accept_review` (revise & re-review until pass or budget exhausted, then forced finalize). To avoid racing a still-running runtime worker (suspend mid-node), the driver **waits for an actionable state** (`_wait_for_actionable`): it only acts when the graph is at an interrupt / done, or when the checkpoint stops advancing (no worker → abandoned/restart). No new table — the graph checkpoint + `ai_writing_sessions` already hold all state; `status="background"` marks 挂起中 (surfaced in the history dropdown), nodes write `draft`/`review_result`/`status=done` as usual, so the user reopens the finished article from history. Per-user concurrency cap `AI_WRITING_BACKGROUND_SLOTS_PER_USER` (default 2, same SQLite-lock rationale as the roundtable executor). Startup `reconcile` (in `deps.py`) relaunches drivers for sessions left `background` after a restart (`AIWritingSessionRepository.list_by_status`). **Progress flowchart popup**: `GET /api/ai-writing/sessions/{id}/progress` (owner-checked) reads the graph checkpoint via `executor.get_progress` and derives a canonical pipeline `stage` key (`intent/research/material_confirm/outline/outline_confirm/draft/draft_confirm/review/done`, from the pending interrupt's `pause_point` or `snapshot.next` node) + `note` (素材不足求助 / 编辑打回) + review progress + `isRunning`. Frontend: 「后台挂起」 button + auto-suspend on route unmount / `beforeunload` (`AIWritingPanel.tsx`, `suspendAIWritingToBackground[Keepalive]` in `api/ai-writing-sessions.ts`); 挂起中 badge in `AIWritingHistoryDropdown.tsx`, and **clicking a 挂起中 history record opens a flowchart dialog** (`AIWritingFlowDialog.tsx` polls `getAIWritingProgress` every 2.5s, renders the fixed vertical pipeline `AIWritingFlowChart.tsx` lit by the live `stage`) so the user sees how far the background run has gotten without reopening/taking over the session. **Transcript consistency**: the rich history timeline (`AIWritingHistoryView` → `AIWritingTimeline`) replays `transcript = {progressEvents, completedInterventions}`, normally assembled+saved client-side. A background run has no client, so the executor rebuilds it at the end (`_save_transcript`/`_build_transcript_from_checkpoint`) **entirely from the final checkpoint** — both `progressEvents` and `completedInterventions` are derived from the same set of `resumed` events (every pause-resume emits exactly one), so they are always 1:1 aligned. For each `resumed`: classify its message → `(pausePoint, action)` (`_classify_resumed`, matched against `graph.py`'s resume messages), insert an `await_user` (with the pause prompt) before it, and build a **rich** intervention card enriched from final state (material_confirm→`materials`, outline_confirm→`outline`, draft_confirm→`draftTitle`/`draftWordCount`/`reviewResult`, review_confirm→`reviewResult`); `materials_ready` is inserted before the first material-confirm. (Earlier code merged the frontend's pre-suspend `completedInterventions` with auto ones and aligned by index — but the frontend's last save can predate its most recent decision, so the counts drifted and cards rendered off-by-one with empty data; deriving everything from the checkpoint root-fixes both.) Raw (snake_case) events **and** intervention data are normalized on load by the **exported** `normalizeProgressDict` / `normalize{Material,Outline,ReviewResult}` (idempotent on already-camelCase live transcripts — one normalize path for both sources), so reopening a background-completed session shows the same cards as a frontend-driven one. Tests: `tests/test_ai_writing_background.py`.
### Admin Leaderboard Snapshots (`/api/admin/users/leaderboard*`)
@@ -184,6 +184,7 @@ async def stream_writing_agent(
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
disable_thinking: bool = False,
) -> str:
"""在「SOUL 作 system prompt + 单条 Human 输入」契约下流式运行一个写作 agent。
@@ -194,6 +195,9 @@ async def stream_writing_agent(
chunk 包装成本阶段的 custom 事件(outline_chunk / draft_chunk /
review_chunk ...)推给前端;None = 不透传(如 researcher 的辅助任务)。
explicit_model_fallback / strict_model: 见 resolve_agent_model_name。
disable_thinking: True 时强制关闭模型思考(force_disable_thinking),用于
素材收集/大纲整理这类不需要思考的阶段(GLM5 等爱出 <think>)。关不掉的
模型仍由下方 ThinkTagParser 把思考分流到思考框,不会泄进正文。
Returns:
累积的**正文**文本(<think> 思考与 reasoning_content 已剥离),供
@@ -212,7 +216,7 @@ async def stream_writing_agent(
from deerflow.models import create_chat_model
model = create_chat_model(name=model_name)
model = create_chat_model(name=model_name, force_disable_thinking=disable_thinking)
full_text = ""
parser = ThinkTagParser()
@@ -246,12 +250,15 @@ async def run_writing_agent(
on_text: Callable[[str, bool], None] | None = None,
explicit_model_fallback: str | None = None,
strict_model: bool = True,
disable_thinking: bool = False,
) -> dict:
"""跑一个写作 agent 并按 marker 契约解析其结构化输出(失败回 fallback)。
模型调用异常时与 ``_utils.llm_json`` 同口径:返回
``{**fallback, "_llm_error": str(e)}``,不上抛(SOUL 缺失除外——
WritingAgentUnavailable 原样上抛,由节点切回旧 prompt 路径)。
disable_thinking: 透传给 stream_writing_agent,强制关闭思考(见其文档)。
"""
try:
full_text = await stream_writing_agent(
@@ -262,6 +269,7 @@ async def run_writing_agent(
on_text=on_text,
explicit_model_fallback=explicit_model_fallback,
strict_model=strict_model,
disable_thinking=disable_thinking,
)
except WritingAgentUnavailable:
raise
@@ -63,15 +63,21 @@ def safe_get_stream_writer():
return lambda _payload: None
def get_model(config: RunnableConfig, model_name: str | None = None):
def get_model(config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False):
"""创建对话模型。
model_name 显式传入时优先使用(如素材收集专家的检索词/排序专用快模型),
否则回退到 configurable.model_name(用户在写作表单里选的主模型)。
disable_thinking=True 时强制关闭模型的思考/推理(force_disable_thinking):
对 OpenAI 兼容网关注入 GLM ``thinking.type=disabled`` / Qwen·vLLM
``enable_thinking=False`` 三种禁用形态,用于素材收集、大纲整理这类不需要思考的
结构化任务(GLM5 等内网模型尤其爱出 <think>)。关不掉的模型由各调用方的
ThinkTagParser 兜底,照常把思考分流到思考框。
"""
from deerflow.models import create_chat_model
name = model_name or config.get("configurable", {}).get("model_name", "") or None
return create_chat_model(name=name)
return create_chat_model(name=name, force_disable_thinking=disable_thinking)
def resolve_writing_model_name(config: RunnableConfig, state: dict) -> tuple[str | None, bool]:
@@ -316,16 +322,18 @@ class ThinkTagParser:
return 0
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None) -> dict:
async def llm_json(system: str, user: str, fallback: dict, config: RunnableConfig, model_name: str | None = None, *, disable_thinking: bool = False) -> dict:
"""调用 LLM,解析 JSON 输出,失败用 fallback。
model_name 可覆盖默认模型(如检索词/排序使用配置的专用快模型)。
disable_thinking=True 时强制关闭思考(见 get_model),用于检索词/素材排序这类
结构化抽取任务,省掉 GLM5 等模型的 <think> 思考开销。
"""
messages = [SystemMessage(content=system), HumanMessage(content=user)]
try:
# get_model 放进 try:模型名在 config.yaml 找不到时 create_chat_model 会抛
# ValueError,若留在 try 外会逐层上抛使整个图硬崩溃。
model = get_model(config, model_name=model_name)
model = get_model(config, model_name=model_name, disable_thinking=disable_thinking)
resp = await model.ainvoke(messages)
return parse_json(_content_to_text(resp.content), fallback)
except Exception as e:
@@ -52,13 +52,31 @@ _AGENT_KEYWORDS_USER = """任务:提取检索词
写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
{outline_section}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图提炼真实检索词。
关键词数量必须为 {keyword_count} 个,不多不少;必须依据上述写作意图(及大纲,若有)提炼真实检索词。
严格按 SOUL 约定输出 [KEYWORDS_READY] + JSON。
/no_think"""
# 用户自带大纲时,检索词生成的大纲提示片段做长度截断,防止 token 溢出。
_OUTLINE_KEYWORD_SNIPPET_CHARS = 1000
def _keyword_outline_section(state: AIWritingState) -> str:
"""用户自带大纲时,给检索词生成附上大纲原文,让关键词覆盖各章节主题。
无大纲返回空串(提示词里该行变空行,行为与原来一致)。
"""
raw = (state.get("user_outline_raw") or "").strip()
if not raw:
return ""
snippet = raw[:_OUTLINE_KEYWORD_SNIPPET_CHARS]
return (
"用户已提供文章大纲,请结合写作意图,让检索词覆盖大纲涉及的各章节主题:\n"
f"{snippet}"
)
_AGENT_RANK_USER = """任务:素材排序
写作意图:{user_intent}
@@ -225,6 +243,59 @@ def _per_keyword_limit(max_materials: int, total_keywords: int) -> int:
return (max_materials + total_keywords - 1) // total_keywords
# 「再追加 N 条素材」之类的数量指令:动词 + 数字(+ 量词 + 名词)。
_APPEND_COUNT_VERBS = "追加|新增|增加|补充|再来|多来|多给|多找|多搜|多检索|再给|再要|再找|再搜|再加"
_APPEND_COUNT_RE = re.compile(
rf"(?:再|又|继续|另外?|额外)?\s*(?:{_APPEND_COUNT_VERBS})\s*"
r"(?:我|你|您|咱|大家|一下|一些|些|到)?\s*"
r"([0-9一二三四五六七八九十两]+)\s*(?:条|个|篇|份)?\s*(?:素材|资料|内容|结果|数据|文献)?"
)
_CN_DIGITS = {"零": 0, "一": 1, "两": 2, "二": 2, "三": 3, "四": 4, "五": 5, "六": 6, "七": 7, "八": 8, "九": 9}
def _cn_to_int(s: str) -> int | None:
"""把数量字符串解析成整数:阿拉伯数字直接转,常见中文数字(0~99)兜底。"""
if s.isdigit():
try:
return int(s)
except ValueError:
return None
if "十" in s: # 十 / 十X / X十 / X十Y
left, _, right = s.partition("十")
tens = _CN_DIGITS.get(left, 1) if left else 1
ones = _CN_DIGITS.get(right, 0) if right else 0
return tens * 10 + ones
if len(s) == 1 and s in _CN_DIGITS:
return _CN_DIGITS[s]
return None
def _parse_append_request(text: str) -> tuple[int | None, str]:
"""从用户的追加输入里解析「再追加 N 条素材」这类显式数量诉求。
返回 ``(count, residual_topic)``:
- ``count``:用户要求新增的素材条数(1~100),未显式说明 → ``None``。
- ``residual_topic``:剔除数量指令后剩下的检索主题。为空表示用户只给了数量、
没给新主题,调用方应回退到写作意图(而不是把「再追加10条素材」当检索词)。
"""
raw = (text or "").strip()
if not raw:
return None, ""
m = _APPEND_COUNT_RE.search(raw)
if not m:
return None, raw
count = _cn_to_int(m.group(1))
if count is None or count <= 0:
return None, raw
count = min(count, _MAX_MAX_MATERIALS)
residual = (raw[: m.start()] + raw[m.end():]).strip()
# 去掉残留的纯标点 / 语气词后,判断是否还剩真正的主题
residual_core = re.sub(r"[,,。.、;;::!!??\s的了吧呢啊]+", "", residual)
if len(residual_core) < 2:
residual = ""
return count, residual
def _notice(title: str, detail: str, raw: str = "", agent_name: str = "素材收集专家") -> dict:
"""构造一个非致命告警事件,前端在时间线显示「标题 + 真实原因」,不打断写作流程。
@@ -361,6 +432,15 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
is_user_query_search = action in ("re_search", "enable_skill_search") and bool(user_query)
# 兼容旧版"追加关键词"调用方(如果还有 client 传 extra_keywords 数组而非 user_query)
is_append_only = action == "re_search" and not user_query and bool(extra_keywords)
# 用户在追加输入里显式说「再追加 N 条素材」:尊重这个数量。若只给了数量、没给新主题,
# 就按原写作意图继续检索,而不是把「再追加10条素材」这串话本身当成检索词。
requested_append_count: int | None = None
effective_user_query: str = user_query
if is_user_query_search:
requested_append_count, residual_topic = _parse_append_request(user_query)
if requested_append_count is not None:
effective_user_query = residual_topic or intent
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# notebook(我的空间)。旧值 knowledge_base / skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
@@ -418,15 +498,18 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# Step 1: 提取关键词
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分
keywords: list[str] = [user_query]
_emit(f"按你的输入「{user_query[:30]}」匹配技能检索…")
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分。
# 若用户只说了「再追加 N 条素材」,effective_user_query 已回退为写作意图。
keywords: list[str] = [effective_user_query]
_emit(f"按你的输入「{effective_user_query[:30]}」匹配技能检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
_emit(f"追加检索 {len(keywords)} 个关键词…")
else:
_emit("正在生成检索词…")
# 用户自带大纲时,把大纲原文带进检索词生成,让关键词结合意图覆盖各章节主题
outline_section = _keyword_outline_section(state)
if use_agent:
kw_result = await run_writing_agent(
agent_id=RESEARCHER_AGENT_ID,
@@ -435,6 +518,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
marker=KEYWORDS_READY_MARKER,
fallback={"keywords": [intent]},
@@ -442,6 +526,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
config=config,
explicit_model_fallback=keyword_model,
strict_model=False,
disable_thinking=True,
)
else:
kw_result = await llm_json(
@@ -451,10 +536,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
article_type=article_type,
target_audience=target_audience,
keyword_count=keyword_count,
outline_section=outline_section,
),
fallback={"keywords": [intent]},
config=config,
model_name=keyword_model,
disable_thinking=True,
)
# 清洗关键词:剔除占位符 / 空值,全空时回退写作意图
keywords = _clean_keywords(kw_result.get("keywords"), intent)
@@ -527,6 +614,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
configured = _load_configured_skills()
provider = get_search_provider(ai_writing_config)
per_skill = max(1, max_materials)
# 用户显式要「再追加 N 条」时,单技能至少检索 N 条,保证能凑够数量(末尾再截断到 N)
if requested_append_count:
per_skill = max(per_skill, requested_append_count)
ordered = configured
if is_user_query_search and len(configured) > 1:
@@ -537,7 +627,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
model_name=keyword_model,
emit_step=_emit_skill_step,
max_select=5,
user_query=user_query,
user_query=effective_user_query,
candidates=configured,
)
ranked = await picker.pick(user_intent=intent)
@@ -649,7 +739,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
skill=sk,
intent=intent,
keywords=keywords,
user_query=user_query if is_user_query_search else "",
user_query=effective_user_query if is_user_query_search else "",
config=config,
state=state,
model_name_fallback=keyword_model,
@@ -664,7 +754,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 定向技能检索(默认路径 / 智能体模式回退):user_query 模式优先用用户原话;
# 非知识库技能再附上 description 让 query 贴合该技能的领域。
if is_user_query_search:
skill_query = user_query if is_knowledge else f"{user_query} {sk.description or ''}".strip()
skill_query = effective_user_query if is_knowledge else f"{effective_user_query} {sk.description or ''}".strip()
else:
skill_query = f"{intent} {sk.description or ''}".strip() or intent
_emit_skill_step({
@@ -697,13 +787,16 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
"matched_count": added,
})
# 早停上限:通常为 max_materials;用户显式要「再追加 N 条」时放宽到 N,确保凑够
collect_limit = max(max_materials, requested_append_count or 0)
for sk in ordered:
added, error_detail = await _execute_skill(sk)
tried_names.add(sk.name)
_emit_skill_outcome(sk, added, error_detail)
# 早停:累积素材已达上限
if len(new_results) >= max_materials:
if len(new_results) >= collect_limit:
_emit_skill_step({
"type": "stop_early",
"message": f"已获取 {len(new_results)} 条素材,达到上限,停止调用后续技能",
@@ -754,6 +847,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
appended_count = 0
for r in new_results:
# 用户显式要「再追加 N 条」时,凑够 N 条新素材即止,严格按用户要求的数量给
if requested_append_count and appended_count >= requested_append_count:
break
if r.url and r.url in seen_urls:
continue
if r.url:
@@ -799,6 +895,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
config=config,
explicit_model_fallback=rank_model,
strict_model=False,
disable_thinking=True,
)
else:
rank_result = await llm_json(
@@ -807,6 +904,7 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
fallback=rank_fallback,
config=config,
model_name=rank_model,
disable_thinking=True,
)
rank_error = rank_result.get("_llm_error", "")
ranked_ids: list[str] = rank_result.get("ranked_ids", [m["id"] for m in raw_materials])
@@ -11,6 +11,7 @@ from langchain_core.runnables import RunnableConfig
from ..state import AIWritingState, DraftArticle
from ..prompts.writer_prompts import (
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_USER, DRAFT_REVISE_SECTION_USER,
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
)
@@ -363,6 +364,234 @@ async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> No
logger.exception("writer_draft: save_draft failed (non-fatal) thread_id=%s", thread_id)
# ── 用户局部修改(只改命中章节,其余原样保留)────────────────────────────────
#
# 用户在草稿确认卡填「修改意见」(action=user_revise)时,若意见只指向部分章节
# (如「帮我修改一下第一段」),不再整篇逐节重写——先用 LLM 识别意见指向的章节,
# 只重写命中章节、其余章节字节级原样保留。意见偏整体性 / 无法定位时返回 None,由
# writer_draft_node 回退到既有的整篇重写路径。
# 文章标题(单 #)与章节标题(双 ##)的行级匹配。`^#[ \t]+` 不会命中 `## `(第 2 字符是
# `#` 非空白);`^##[ \t]+` 也不会命中 `### / ####`,互不串扰。
_ARTICLE_TITLE_RE = re.compile(r"^#[ \t]+(.+?)[ \t]*$", re.MULTILINE)
_SECTION_HEADING_RE = re.compile(r"^##[ \t]+(.+?)[ \t]*$", re.MULTILINE)
_REFERENCES_TITLE = "参考文献"
def _split_draft_sections(markdown: str) -> tuple[str, list[tuple[str, str]], str | None]:
"""把成稿 Markdown 拆成 (文章标题, [(章节标题, 章节正文), ...], 文末参考文献块或 None)。
章节正文不含 `## 标题` 行(与写作循环里的 section_bufs 语义一致)。文末「## 参考文献」
单独抽出原样保留,不计入可编辑章节。
"""
matches = list(_SECTION_HEADING_RE.finditer(markdown))
head = markdown[: matches[0].start()] if matches else markdown
m_title = _ARTICLE_TITLE_RE.search(head)
article_title = (m_title.group(1).strip() if m_title else "").strip()
sections: list[tuple[str, str]] = []
references_block: str | None = None
for i, m in enumerate(matches):
title = m.group(1).strip()
body_start = m.end()
body_end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown)
if title == _REFERENCES_TITLE:
references_block = markdown[m.start():body_end].strip("\n")
continue
body = markdown[body_start:body_end].strip("\n")
sections.append((title, body))
return article_title, sections, references_block
async def _detect_revision_scope(
state: AIWritingState, config: RunnableConfig,
parsed_sections: list[tuple[str, str]], note: str,
) -> set[int] | None:
"""LLM 识别修改意见指向哪些章节编号;整体性/无法定位/异常一律返回 None(回退整篇重写)。"""
listing = "\n".join(f"{i}. {t}" for i, (t, _b) in enumerate(parsed_sections))
model_name = (state.get("model_name") or "").strip() or None
try:
result = await llm_json(
DRAFT_REVISE_SCOPE_SYSTEM,
DRAFT_REVISE_SCOPE_USER.format(n=len(parsed_sections), sections=listing, note=note),
{"whole_article": True, "target_indexes": []},
config,
model_name=model_name,
)
except Exception as e:
logger.warning("draft targeted-revise: scope detection failed: %s", e)
return None
if not isinstance(result, dict) or result.get("whole_article"):
return None
raw = result.get("target_indexes") or result.get("targetIndexes") or []
if not isinstance(raw, list):
return None
idxs: set[int] = set()
for x in raw:
try:
k = int(x)
except (ValueError, TypeError):
continue
if 0 <= k < len(parsed_sections):
idxs.add(k)
return idxs or None
async def _stream_revise_section(
state: AIWritingState, config: RunnableConfig, *,
article_title: str, idx: int, section_title: str, existing_text: str, key_points_str: str,
section_materials: str, note: str, add_references: bool,
writer, use_agent: bool, legacy_model_name: str | None,
) -> str:
"""流式重写单个命中章节并返回新正文(失败/为空时回退原文)。"""
msg = DRAFT_REVISE_SECTION_USER.format(
article_title=article_title or "文章",
section_title=section_title,
key_points=key_points_str,
section_materials=section_materials,
existing_text=existing_text,
note=note,
)
if add_references:
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
def _on_text(text: str, is_thinking: bool, _i: int = idx, _t: str = section_title) -> None:
writer({
"type": "draft_chunk", "section_index": _i, "section_title": _t,
"text": text, "is_thinking": is_thinking, "is_section_done": False,
})
try:
if use_agent:
buf = await stream_writing_agent(
agent_id=WRITER_AGENT_ID, user_payload=msg,
state=state, config=config, on_text=_on_text,
)
else:
from deerflow.models import create_chat_model
buf = ""
parser = ThinkTagParser()
m = create_chat_model(name=legacy_model_name) if legacy_model_name else create_chat_model()
async for chunk in m.astream([
SystemMessage(content=DRAFT_SECTION_SYSTEM),
HumanMessage(content=msg),
]):
reasoning = reasoning_delta(chunk)
if reasoning:
_on_text(reasoning, True)
delta = _content_to_text(chunk.content)
if not delta:
continue
for is_thinking, text in parser.feed(delta):
if not is_thinking:
buf += text
_on_text(text, is_thinking)
except Exception as e:
logger.warning("draft targeted-revise: section [%s] rewrite failed, keep original: %s", section_title, e)
buf = ""
finally:
writer({
"type": "draft_chunk", "section_index": idx, "section_title": section_title,
"text": "", "is_section_done": True,
})
cleaned = (buf or "").strip()
return cleaned or existing_text
async def _targeted_user_revision(
state: AIWritingState, config: RunnableConfig, *,
pkg: dict, cite_map: dict | None, add_references: bool, note: str,
writer, agent_label: str, use_agent: bool, legacy_model_name: str | None,
extra_notices: list[dict] | None = None,
) -> dict | None:
"""尝试只重写意见命中的章节;不适用(无旧稿/意见偏整体/解析失败)时返回 None 走整篇重写。"""
prev_draft = state.get("current_draft") or {}
prev_md = (prev_draft.get("full_markdown") or "").strip()
if not prev_md or not note.strip():
return None
article_title, parsed, references_block = _split_draft_sections(prev_md)
if not parsed:
return None
targets = await _detect_revision_scope(state, config, parsed, note)
if not targets:
return None
outline = state.get("current_outline") or {"title": article_title, "sections": []}
out_sections = outline.get("sections", [])
revision_count = state.get("revision_count", 0)
new_bodies: list[str] = []
for idx, (sec_title, body) in enumerate(parsed):
if idx in targets:
out_sec = out_sections[idx] if idx < len(out_sections) else {}
key_points_str = _render_key_points_for_prompt(
out_sec.get("key_points") or out_sec.get("keyPoints") or []
)
section_materials = _get_section_materials(
out_sec.get("material_ids") or out_sec.get("materialIds") or [], pkg, cite_map
)
new_body = await _stream_revise_section(
state, config, article_title=article_title, idx=idx, section_title=sec_title, existing_text=body,
key_points_str=key_points_str, section_materials=section_materials,
note=note, add_references=add_references, writer=writer,
use_agent=use_agent, legacy_model_name=legacy_model_name,
)
new_bodies.append(new_body)
else:
# 未改动章节:整段瞬时下发 + 收尾,让前端草稿视图完整重建(其余字节级原样保留)
writer({
"type": "draft_chunk", "section_index": idx, "section_title": sec_title,
"text": body, "is_thinking": False, "is_section_done": False,
})
writer({
"type": "draft_chunk", "section_index": idx, "section_title": sec_title,
"text": "", "is_section_done": True,
})
new_bodies.append(body)
body_md = "\n\n".join(
f"## {t}\n\n{_normalize_section_headings(b)}"
for (t, _old), b in zip(parsed, new_bodies)
)
full_markdown = f"# {article_title}\n\n{body_md}"
word_count = count_words(full_markdown)
if references_block:
full_markdown = f"{full_markdown}\n\n{references_block}"
draft = DraftArticle(
title=article_title or outline.get("title", "文章"),
outline=outline,
full_markdown=full_markdown,
word_count=word_count,
revision_count=revision_count,
)
await _save_draft_to_repo(config, draft)
changed_names = "、".join(parsed[i][0] for i in sorted(targets))
notices: list[dict] = list(extra_notices or [])
notices.append({
"type": "notice", "agent_name": agent_label, "title": "局部修改",
"detail": f"已按你的意见只修改了 {len(targets)} 个章节({changed_names}),其余内容原样保留。",
})
return {
"current_draft": draft,
"drafts": [draft],
"status": "awaiting_draft_confirm",
"progress_events": [
{"type": "step_start", "agent_name": agent_label, "message": f"开始局部修改(第{revision_count+1}稿)"},
*notices,
{
"type": "draft_ready", "agent_name": agent_label,
"message": f"修改完成,共 {word_count} 字",
"draft_title": article_title or outline.get("title", "文章"),
"draft_markdown": full_markdown,
"revision_count": revision_count,
},
],
}
async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> dict:
outline = state.get("current_outline")
if not outline:
@@ -390,9 +619,11 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
# 修改意见(来自用户或编辑)
revision_notes = ""
user_note_raw = "" # 用户原话(不含「用户修改意见:」前缀),供局部修改的范围识别与单节重写用
if intervention.get("action") == "user_revise":
notes = intervention.get("_user_notes") or intervention.get("user_revision_notes", "")
if notes:
user_note_raw = notes
revision_notes = f"\n用户修改意见:{notes}"
elif intervention.get("action") in ("accept_review", "custom_revise"):
review = state.get("review_result") or {}
@@ -472,6 +703,21 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
}
draft_chain = fallback_chain(initial_name)
from deerflow.models import create_chat_model
# 用户局部修改:意见只指向部分章节时,只重写命中章节、其余字节级原样保留(不整篇重写)。
# 偏整体性 / 无法定位 / 无旧稿时 _targeted_user_revision 返回 None,继续走下面的整篇重写路径。
if intervention.get("action") == "user_revise" and user_note_raw.strip():
legacy_model_name = draft_chain[0] if (not use_agent and draft_chain) else None
targeted = await _targeted_user_revision(
state, config,
pkg=pkg, cite_map=cite_map, add_references=add_references,
note=user_note_raw, writer=writer, agent_label=agent_label,
use_agent=use_agent, legacy_model_name=legacy_model_name,
extra_notices=[model_default_notice] if model_default_notice else None,
)
if targeted is not None:
return targeted
section_titles: list[str] = []
section_bufs: list[str] = [] # 各章节正文(不含 ## 标题),供扩写兜底逐节替换
section_errors: list[str] = [] # 分节写作的模型异常,供完成后非致命提示
@@ -229,7 +229,9 @@ async def _stream_normalize_user_outline(
try:
from deerflow.models import create_chat_model
model = create_chat_model(name=name)
# 大纲整理是结构化保真任务,强制关思考(GLM5 等爱出 <think>);
# 关不掉的模型仍由下方 ThinkTagParser 把思考分流到思考框实时展示。
model = create_chat_model(name=name, force_disable_thinking=True)
parser = ThinkTagParser()
async for chunk in model.astream([
SystemMessage(content=OUTLINE_NORMALIZE_SYSTEM),
@@ -391,6 +393,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
full_text = await stream_writing_agent(
agent_id=OUTLINER_AGENT_ID,
user_payload=agent_payload,
disable_thinking=True,
state=state,
config=config,
on_text=_on_text,
@@ -419,7 +422,10 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
full_text = ""
parser = ThinkTagParser()
try:
model = create_chat_model(name=candidate) if candidate else create_chat_model()
model = (
create_chat_model(name=candidate, force_disable_thinking=True)
if candidate else create_chat_model(force_disable_thinking=True)
)
async for chunk in model.astream([
SystemMessage(content=system_prompt),
HumanMessage(content=user_prompt),
@@ -7,9 +7,13 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专
你的工作背景:你在一家权威媒体机构任职,熟悉各类信息源的可信度评估。
你只能做以下事情:
1. 从写作意图中按要求的数量提取搜索关键词
1. 从写作意图(以及用户提供的文章大纲,若有)中按要求的数量提取搜索关键词
你不能写文章,不能评价内容好坏,只做关键词提取。
【结合大纲】
- 若用户提供了文章大纲,关键词要结合写作意图,尽量覆盖大纲涉及的各章节主题,
让检索到的素材能支撑每个章节,而不是只围绕一个笼统的意图。
【严禁思考】
- 这是一个简单的结构化抽取任务,禁止任何思考、分析、推理或规划过程。
- 禁止输出 <think></think> 标签及其中的任何内容,禁止输出"让我想想""首先"之类的过渡语。
@@ -28,10 +32,10 @@ EXTRACT_KEYWORDS_SYSTEM = """你是一名专业情报研究员(素材收集专
EXTRACT_KEYWORDS_USER = """写作意图:{user_intent}
文章类型:{article_type}
目标读者:{target_audience}
{outline_section}
请提取 {keyword_count} 个搜索关键词,用于搜集相关素材。
关键词数量必须为 {keyword_count} 个,不多不少。
关键词必须是依据上述写作意图提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。
关键词必须是依据上述写作意图(及大纲,若有)提炼的真实检索词,不要照抄下方 JSON 模板里的占位符。
只输出 JSON(下方 ... 是占位示意,不是真实关键词):
{{"keywords": ["...", "..."]}}
@@ -229,6 +229,56 @@ DRAFT_SECTION_USER = """文章标题:{article_title}
论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。"""
# ── 用户局部修改:识别意见指向的章节范围 ───────────────────────────────────────
DRAFT_REVISE_SCOPE_SYSTEM = """你是一名编辑助理。用户对一篇已成稿的文章提出了一句修改意见,你要判断这条意见究竟要改文章里的哪些「章节」。
文章由若干带编号的章节组成(编号从 0 开始)。
【判断规则】(最高优先级)
- 仔细理解意见指向,可能是:
- 序号:「第一段 / 第一部分 / 第1章」→ 编号 0,「第二段」→ 编号 1,依此类推;
- 位置:「开头 / 引言 / 导语」→ 第一个章节,「结尾 / 结论 / 最后一段」→ 最后一个章节;
- 直接点名某个章节的标题或主题。
- 若意见只针对个别章节,输出这些章节的编号列表,并令 whole_article=false。
- 若意见是**整体性**的(如「全文都改」「整体语气更正式」「每一部分都精简」「通篇润色」),
或会**改变文章结构**(如「标题改成…」「加一章 / 删一章 / 调整章节顺序」),令 whole_article=true、target_indexes 留空。
- 拿不准、无法明确定位到具体章节时,一律 whole_article=true(交给整篇重写更安全)。
【严禁思考与表态】
- 不要输出任何思考、解释或对用户说的话;收到输入后立即直接输出最终 JSON。
【输出要求】
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块,必须用英文双引号。
- 结构:{"whole_article": false, "target_indexes": [0]}"""
DRAFT_REVISE_SCOPE_USER = """文章共有 {n} 个章节,编号与标题如下:
{sections}
用户的修改意见:{note}
请判断这条意见要改哪些章节,按要求只输出 JSON。"""
# ── 用户局部修改:只重写命中的单个章节(保留其余内容)─────────────────────────
DRAFT_REVISE_SECTION_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):
{key_points}
相关素材:
{section_materials}
这一章节的现有内容如下:
{existing_text}
用户对本章节的修改意见:{note}
请**只针对用户意见**修改这一章节:
- 落实用户意见的同时,未被要求改动的内容、事实与措辞尽量保留原样,不要借机重写整段。
- 保留原有的「### 小节标题 / #### 子要点标题」两级结构,不要新增或删除小节/子要点,也不要把 #### 改成 bullet。
- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。
- 直接返回修改后的完整章节内容(含 ### 与 #### 两级标题及各自正文段),不要解释、不要前置说明。"""
DRAFT_EXPAND_USER = """文章标题:{article_title}
当前章节:{section_title}
章节内的小节与子要点(层级 bullet 列表):