From b8c78ba06ec5f2ab93ebffb775e27d6cf62fa0d3 Mon Sep 17 00:00:00 2001 From: fengxiangdebaozi <10845145+fengxiangdebaozi@user.noreply.gitee.com> Date: Tue, 23 Jun 2026 19:06:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(ai-writing):=20=E5=A4=A7=E7=BA=B2=E5=85=88?= =?UTF-8?q?=E8=A1=8C=E7=A1=AE=E8=AE=A4=20+=20=E6=96=B0=E6=96=87=E7=AB=A0?= =?UTF-8?q?=E7=B1=BB=E5=9E=8B=E8=AF=86=E5=88=AB=20+=20=E5=8F=82=E8=80=83?= =?UTF-8?q?=E6=96=87=E7=8C=AE=20+=20=E7=BC=96=E8=BE=91=E5=99=A8=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 写作配置 Q&A 改为「先流式生成大纲并多轮确认 → 再确认意图」,放宽轮数上限 - 意图识别出列表外的新文章类型时带上类型名+简介(仅本次写作临时使用) - 成稿支持参考文献:正文 [n] 角标 + 文末按出现顺序的「参考文献」列表(表单开关,默认开) - 流式接取未闭合 思考内容,首 token 前加「正在整理…」占位 - 修复右侧编辑器输入空格只出一次、光标跳字(避免自激回灌 setContent 重置光标) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../workspace/messages/message-list.tsx | 58 +- frontend-web/src/core/messages/utils.ts | 32 +- .../components/ai-writing/AIWritingForm.tsx | 19 + .../components/ai-writing/AIWritingPanel.tsx | 3 + .../ai-writing/InterventionCard.tsx | 23 +- .../components/ai-writing/OutlineCard.tsx | 321 +++++++++-- .../components/ai-writing/OutlineSection.tsx | 514 +++++++++++------- .../ai-writing/WritingSetupChat.tsx | 76 +++ .../open-canvas/contexts/AIWritingContext.tsx | 10 +- .../contexts/WritingFormContext.tsx | 7 +- .../open-canvas/hooks/useAIWritingStream.ts | 8 +- .../src/open-canvas/hooks/useWritingEditor.ts | 16 +- .../src/open-canvas/types/ai-writing.ts | 4 + .../src/open-canvas/utils/wordCount.ts | 12 + .../deerflow/agents/ai_writing/graph.py | 12 +- .../agents/ai_writing/nodes/editor.py | 23 +- .../agents/ai_writing/nodes/intent_parser.py | 4 +- .../agents/ai_writing/nodes/writer_draft.py | 378 +++++++++++-- .../agents/ai_writing/nodes/writer_outline.py | 7 +- .../ai_writing/prompts/editor_prompts.py | 15 +- .../ai_writing/prompts/writer_prompts.py | 25 +- .../deerflow/agents/ai_writing/state.py | 5 + .../deerflow/agents/lead_agent/agent.py | 4 +- .../deerflow/agents/lead_agent/prompt.py | 35 +- .../setup_writing_approval_middleware.py | 2 + .../tools/builtins/setup_writing_tool.py | 12 +- 26 files changed, 1273 insertions(+), 352 deletions(-) diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index f10dc1bc..95c5668c 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -1,6 +1,6 @@ import type { Message } from "@langchain/langgraph-sdk"; import type { BaseStream } from "@langchain/langgraph-sdk/react"; -import { CheckIcon, SendIcon, XIcon } from "lucide-react"; +import { CheckIcon, PenLineIcon, SendIcon, XIcon } from "lucide-react"; import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { useStickToBottomContext } from "use-stick-to-bottom"; @@ -20,10 +20,10 @@ import { groupMessages, hasContent, hasPresentFiles, - hasReasoning, - hasToolCalls, - isHiddenFromUIMessage, -} from "@/core/messages/utils"; + hasReasoning, + hasToolCalls, + isHiddenFromUIMessage, +} from "@/core/messages/utils"; import { useRehypeSplitWordsIntoSpans } from "@/core/rehype"; import { isThreadRagModeLocked, useLocalSettings } from "@/core/settings"; import { useSkills } from "@/core/skills/hooks"; @@ -103,6 +103,7 @@ export function MessageList({ onSetupAgentReject, onSetupWritingApprove, onSetupWritingReject, + onSetupWritingStart, }: { className?: string; contentClassName?: string; @@ -120,6 +121,8 @@ export function MessageList({ onSetupAgentReject?: (payload: SetupAgentApprovalPayload) => void | Promise; onSetupWritingApprove?: (payload: SetupWritingApprovalPayload) => void | Promise; onSetupWritingReject?: (payload: SetupWritingApprovalPayload) => void | Promise; + /** 审批卡「直接开始写作」回调:应用配置(含已确认大纲)并立即启动写作流程(仅写作页提供,模板编辑等场景缺省隐藏该按钮)。 */ + onSetupWritingStart?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(thread.isLoading); const rehypePlugins = useMemo( @@ -152,10 +155,10 @@ export function MessageList({ }); }, []); - const allMessages = useMemo( - () => thread.messages.filter((m) => !isHiddenFromUIMessage(m)), - [thread.messages], - ); + const allMessages = useMemo( + () => thread.messages.filter((m) => !isHiddenFromUIMessage(m)), + [thread.messages], + ); const messages = useMemo( () => hiddenMessageIds.size === 0 @@ -430,6 +433,7 @@ export function MessageList({ message={message} onApprove={onSetupWritingApprove} onReject={onSetupWritingReject} + onStart={onSetupWritingStart} /> void | Promise; onReject?: (payload: SetupWritingApprovalPayload) => void | Promise; + onStart?: (payload: SetupWritingApprovalPayload) => void | Promise; }) { const payload = getSetupWritingApprovalPayload(message); - const [submitting, setSubmitting] = useState<"approve" | "reject" | null>( + const [submitting, setSubmitting] = useState<"approve" | "reject" | "start" | null>( null, ); @@ -838,7 +846,7 @@ function SetupWritingApprovalCard({ const approvalPayload = payload; const busy = disabled || submitting !== null; - async function run(action: "approve" | "reject") { + async function run(action: "approve" | "reject" | "start") { if (busy) { return; } @@ -846,6 +854,8 @@ function SetupWritingApprovalCard({ try { if (action === "approve") { await onApprove?.(approvalPayload); + } else if (action === "start") { + await onStart?.(approvalPayload); } else { await onReject?.(approvalPayload); } @@ -854,9 +864,14 @@ function SetupWritingApprovalCard({ } } + const hasNewType = !!approvalPayload.article_type_detail?.trim(); const rows: Array<{ label: string; value: string }> = []; if (approvalPayload.article_type) { - rows.push({ label: "文章类型", value: approvalPayload.article_type }); + rows.push({ + label: "文章类型", + // 新类型(列表外)标注「· 新」,让用户知道这是模型新识别的类型。 + value: hasNewType ? `${approvalPayload.article_type} · 新` : approvalPayload.article_type, + }); } if (typeof approvalPayload.word_count_target === "number") { rows.push({ label: "目标字数", value: `${approvalPayload.word_count_target} 字` }); @@ -903,6 +918,13 @@ function SetupWritingApprovalCard({ ) : null} + {hasNewType ? ( +
+
新类型说明
+

{approvalPayload.article_type_detail}

+
+ ) : null} + {approvalPayload.outline ? (
已有大纲
@@ -926,6 +948,7 @@ function SetupWritingApprovalCard({ + {onStart ? ( + + ) : null}
); diff --git a/frontend-web/src/core/messages/utils.ts b/frontend-web/src/core/messages/utils.ts index 2b6ea765..909a1283 100644 --- a/frontend-web/src/core/messages/utils.ts +++ b/frontend-web/src/core/messages/utils.ts @@ -212,21 +212,33 @@ export function extractToolResultPayload( } const THINK_TAG_RE = /\s*([\s\S]*?)\s*<\/think>/g; +const THINK_OPEN = ""; function splitInlineReasoning(content: string) { const reasoningParts: string[] = []; - const cleaned = content - .replace(THINK_TAG_RE, (_, reasoning: string) => { - const normalized = reasoning.trim(); - if (normalized) { - reasoningParts.push(normalized); - } - return ""; - }) - .trim(); + // 1) 先抽出所有**已闭合**的 ...。 + let working = content.replace(THINK_TAG_RE, (_, reasoning: string) => { + const normalized = reasoning.trim(); + if (normalized) { + reasoningParts.push(normalized); + } + return ""; + }); + + // 2) 流式中常见**未闭合**的 (还没等到 就先到达):把开标签之后的内容 + // 当作「实时思考」抽到 reasoning、从答案正文里移除。否则模型慢/先思考时,用户既会看到 + // 字面 "",又会因答案区长时间空白而以为卡死。这样思考内容会实时流进思考区。 + const openIdx = working.indexOf(THINK_OPEN); + if (openIdx !== -1 && !working.slice(openIdx).includes("")) { + const partial = working.slice(openIdx + THINK_OPEN.length).trim(); + if (partial) { + reasoningParts.push(partial); + } + working = working.slice(0, openIdx); + } return { - content: cleaned, + content: working.trim(), reasoning: reasoningParts.length > 0 ? reasoningParts.join("\n\n") : 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 index c89efb9f..fad55ff1 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -65,6 +65,7 @@ export function AIWritingForm({ audience, setAudience, wordCountInput, setWordCountInput, strictMode, setStrictMode, + addReferences, setAddReferences, materialSource, setMaterialSource, // 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext, // 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。 @@ -164,6 +165,7 @@ export function AIWritingForm({ wordCountTarget: wordCount, keywordCount: DEFAULT_KEYWORD_COUNT, writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', + addReferences, materialSource: materialSource, modelName: selectedModel?.name || modelName || undefined, // 只有选了"我的空间"才传笔记参数 @@ -381,6 +383,23 @@ export function AIWritingForm({ +
+ setAddReferences(e.target.checked)} + disabled={disabled} + className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary" + /> + +
+ + {/* before:顶部 12px 命中区,命中时顶边显示蓝色线 */} +
+ {beforeDrop.isOver && } - {index + 1}. - - {editingTitle && !readOnly ? ( - setTitleDraft(e.target.value)} - onBlur={commitTitle} - onKeyDown={(e) => { - if (e.key === 'Enter') commitTitle(); - if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); } - }} - className="flex-1 bg-transparent border-b border-primary outline-none font-medium" - /> - ) : ( - !readOnly && setEditingTitle(true)} - > - {section.sectionTitle || (未命名)} - - )} - - - {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} - - {!readOnly && onRemove && ( - - )} -
- - {/* 小节列表 (Level 2) */} - -
- {keyPoints.map((kp, i) => ( - updateKeyPoint(i, updated)} - onRemove={readOnly ? undefined : () => removeKeyPoint(i)} - readOnly={readOnly} - /> - ))} + {/* into:包住头部 + 折叠体;本身就是 drop 容器 */} +
+ {/* 章节头 */} +
{!readOnly && ( + )} + + + {index + 1}. + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(section.sectionTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none font-medium" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {section.sectionTitle || (未命名)} + + )} + + + {keyPoints.length} 小节{totalSubPoints > 0 ? ` · ${totalSubPoints} 子要点` : ''} + + {!readOnly && onRemove && ( + )}
- + + {/* 小节列表 (Level 2) */} + +
+ {keyPoints.map((kp, i) => ( + updateKeyPoint(i, updated)} + onRemove={readOnly ? undefined : () => removeKeyPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+
+
+ + {/* after:底部 12px 命中区 */} +
+ {afterDrop.isOver && }
); } -// ─── 小节行(Level 2,内嵌 Level 3 子要点) ─────────────────────────────────────── +// ─── 小节 (Level 2) ─────────────────────────────────────────────────────────── interface KeyPointRowProps { keyPoint: KeyPoint; + sectionIndex: number; + keyPointIndex: number; onChange: (updated: KeyPoint) => void; onRemove?: () => void; readOnly?: boolean; } -function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProps) { - const [expanded, setExpanded] = useState(false); +function KeyPointRow({ keyPoint, sectionIndex, keyPointIndex, onChange, onRemove, readOnly }: KeyPointRowProps) { + const [expanded, setExpanded] = useState(true); const [editingTitle, setEditingTitle] = useState(false); const [titleDraft, setTitleDraft] = useState(keyPoint.pointTitle); + const id = `s${sectionIndex}-k${keyPointIndex}`; + const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ + id, disabled: readOnly, + }); + const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); + const intoDrop = useDroppable({ id: `into:${id}`, disabled: readOnly }); + const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); + function commitTitle() { setEditingTitle(false); const trimmed = titleDraft.trim(); @@ -181,120 +205,222 @@ function KeyPointRow({ keyPoint, onChange, onRemove, readOnly }: KeyPointRowProp onChange({ ...keyPoint, pointTitle: trimmed }); } } - function updateSubPoint(i: number, value: string) { const next = [...keyPoint.subPoints]; next[i] = value; onChange({ ...keyPoint, subPoints: next }); } - function removeSubPoint(i: number) { onChange({ ...keyPoint, subPoints: keyPoint.subPoints.filter((_, idx) => idx !== i) }); } - function addSubPoint() { onChange({ ...keyPoint, subPoints: [...keyPoint.subPoints, ''] }); - setExpanded(true); // 添加时自动展开,免得用户看不到刚加的输入框 + setExpanded(true); } return ( -
- {/* 小节头 */} -
- +
+
+ {beforeDrop.isOver && } - ### - - {editingTitle && !readOnly ? ( - setTitleDraft(e.target.value)} - onBlur={commitTitle} - onKeyDown={(e) => { - if (e.key === 'Enter') commitTitle(); - if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } - }} - className="flex-1 bg-transparent border-b border-primary outline-none" - placeholder="小节标题" - /> - ) : ( - !readOnly && setEditingTitle(true)} - > - {keyPoint.pointTitle || (未命名小节)} - - )} - - - {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} - - {!readOnly && onRemove && ( - - )} -
- - {/* 子要点 (Level 3) — 每个子要点会变成正文里的 #### 标题 + 一段独立论述 */} - -
- {keyPoint.subPoints.map((sp, i) => ( -
- {/* 用 #### 标记替代 bullet,提示这是「四级标题」而非简单列表项 */} - #### - {readOnly ? ( - {sp} - ) : ( - updateSubPoint(i, e.target.value)} - placeholder="子要点标题(会展开成一段正文)" - className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" - /> - )} - {!readOnly && ( - - )} -
- ))} +
+ {/* 小节头 */} +
{!readOnly && ( + )} + + + ### + + {editingTitle && !readOnly ? ( + setTitleDraft(e.target.value)} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === 'Enter') commitTitle(); + if (e.key === 'Escape') { setTitleDraft(keyPoint.pointTitle); setEditingTitle(false); } + }} + className="flex-1 bg-transparent border-b border-primary outline-none" + placeholder="小节标题" + /> + ) : ( + !readOnly && setEditingTitle(true)} + > + {keyPoint.pointTitle || (未命名小节)} + + )} + + + {keyPoint.subPoints.length > 0 ? `${keyPoint.subPoints.length} 子要点` : '无子要点'} + + {!readOnly && onRemove && ( + )}
- + + {/* 子要点 (Level 3) */} + +
+ {keyPoint.subPoints.map((sp, i) => ( + updateSubPoint(i, value)} + onRemove={readOnly ? undefined : () => removeSubPoint(i)} + readOnly={readOnly} + /> + ))} + {!readOnly && ( + + )} +
+
+
+ +
+ {afterDrop.isOver && }
); } + +// ─── 子要点 (Level 3,叶节点,无 into) ──────────────────────────────────────── +interface SubPointRowProps { + value: string; + sectionIndex: number; + keyPointIndex: number; + subPointIndex: number; + onChange: (value: string) => void; + onRemove?: () => void; + readOnly?: boolean; +} + +function SubPointRow({ + value, sectionIndex, keyPointIndex, subPointIndex, onChange, onRemove, readOnly, +}: SubPointRowProps) { + const id = `s${sectionIndex}-k${keyPointIndex}-p${subPointIndex}`; + const { attributes, listeners, setNodeRef: setDragRef, isDragging } = useDraggable({ + id, disabled: readOnly, + }); + // 叶节点:没有 into;顶半区 = before、底半区 = after,把整行高度铺满 + const beforeDrop = useDroppable({ id: `before:${id}`, disabled: readOnly }); + const afterDrop = useDroppable({ id: `after:${id}`, disabled: readOnly }); + + return ( +
+
+ {beforeDrop.isOver && } + + {!readOnly && ( + + )} + #### + {readOnly ? ( + {value} + ) : ( + onChange(e.target.value)} + placeholder="子要点标题(会展开成一段正文)" + className="flex-1 bg-transparent border-b border-transparent hover:border-border focus:border-primary outline-none text-muted-foreground" + /> + )} + {!readOnly && onRemove && ( + + )} + +
+ {afterDrop.isOver && } +
+ ); +} + +// ─── 蓝色插入指示线 ─────────────────────────────────────────────────────────── +function DropLine({ position, thin }: { position: 'top' | 'bottom'; thin?: boolean }) { + return ( +
+ ); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx index e620b0e2..adbdc80e 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/WritingSetupChat.tsx @@ -21,6 +21,7 @@ import { type SetupWritingApprovalPayload, } from '@/components/workspace/messages'; import { ThreadContext } from '@/components/workspace/messages/context'; +import { extractContentFromMessage, extractReasoningContentFromMessage, hasToolCalls } from '@/core/messages/utils'; import { getAPIClient } from '@/core/api'; import { useModels } from '@/core/models/hooks'; import { useThreadStream } from '@/core/threads/hooks'; @@ -29,6 +30,8 @@ import { cn } from '@/lib/utils'; import { useArticleTypes } from '../../hooks/useArticleTypes'; import { useWritingFormOptional } from '../../contexts/WritingFormContext'; +import { useAIWritingOptional } from '../../contexts/AIWritingContext'; +import type { AIWritingRequest } from '../../types/ai-writing'; import { WritingWelcome } from './WritingWelcome'; /** @@ -61,6 +64,9 @@ export function WritingSetupChat({ // 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。 const formCtx = useWritingFormOptional(); const applyProposal = onApproveProp ?? formCtx?.applyProposal; + // 「直接开始写作」:仅在真正的写作上下文(AIWritingProvider 内、未被复用为模板编辑)提供。 + const aiWriting = useAIWritingOptional(); + const canStartWriting = !onApproveProp && !!aiWriting; // 首个模型可用时默认选中 useEffect(() => { @@ -116,9 +122,68 @@ export function WritingSetupChat({ [applyProposal], ); + // 「直接开始写作」:意图确认卡里已带上**用户已确认的大纲**(setup agent 在确认大纲后才提交), + // 这里把审批建议(含该大纲)直接组装成写作请求并启动 ai_writing 流程,同时回填表单保持一致。 + const handleStart = useCallback( + async (payload: SetupWritingApprovalPayload) => { + if (!aiWriting) return; + applyProposal?.(payload); + + const wordCount = + typeof payload.word_count_target === 'number' && payload.word_count_target > 0 + ? payload.word_count_target + : Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800); + const materialSource = formCtx?.materialSource ?? 'general'; + + const request: AIWritingRequest = { + userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '', + // 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划) + userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined, + articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis', + // 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。 + articleTypeDetail: payload.article_type_detail?.trim() || undefined, + targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者', + wordCountTarget: wordCount, + keywordCount: 1, + writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose', + // 跟随表单的「添加参考文献」开关(默认开)。 + addReferences: formCtx?.addReferences ?? true, + materialSource, + modelName: chatModel || formCtx?.modelName || undefined, + noteSearchSpaceId: + materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined, + noteSourceIds: + materialSource === 'notebook' && formCtx?.selectedNotebookId != null + ? formCtx.selectedDocumentIds + : undefined, + }; + + if (!request.userIntent) { + toast.error('写作意图为空,请先补充意图'); + return; + } + await aiWriting.startWriting(request); + }, + [aiWriting, applyProposal, formCtx, chatModel], + ); + // 对话还没有任何消息时显示欢迎/空状态屏(与原型图一致) const showWelcome = thread.messages.length === 0 && !thread.isLoading; + // 首 token 到达前的「正在整理…」占位:模型在跑(isLoading),但当前助手回合还没有任何 + // 可见输出(无答案文本、无 思考、无工具调用)—— 这段纯网络/排队延迟下答案区空白, + // 加个轻量提示避免用户以为卡死(思考一旦开始流式,此提示自动消失,改由思考区接力)。 + const showThinkingPlaceholder = useMemo(() => { + if (!thread.isLoading) return false; + const last = thread.messages[thread.messages.length - 1]; + if (!last || last.type !== 'ai') return true; // 还停在用户消息,AI 尚未开口 + const hasOutput = + !!extractContentFromMessage(last).trim() || + !!extractReasoningContentFromMessage(last) || + hasToolCalls(last); + return !hasOutput; + }, [thread.isLoading, thread.messages]); + const handleReject = useCallback(async () => { await handleChatSubmit('这份配置先不应用,请继续帮我调整。'); }, [handleChatSubmit]); @@ -147,10 +212,21 @@ export function WritingSetupChat({ onClarificationSubmit={handleChatSubmit} onSetupWritingApprove={handleApprove} onSetupWritingReject={handleReject} + onSetupWritingStart={canStartWriting ? handleStart : undefined} /> )}
+ {/* 首 token 前的「正在整理…」占位:避免模型慢/排队时答案区空白像卡死 */} + {showThinkingPlaceholder && ( +
+
+ + 正在整理… +
+
+ )} +
{/* 与原型图一致:白色圆角卡片包裹「模型选择行 + 输入框」 */}
diff --git a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx index c2a7cab5..28bc1f87 100644 --- a/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx +++ b/frontend-web/src/open-canvas/contexts/AIWritingContext.tsx @@ -23,7 +23,7 @@ import { import { useCanvasGraphContext } from '../contexts/CanvasGraphContext'; import { saveAIWritingTranscript } from '../api/ai-writing-sessions'; -import { countWords } from '../utils/wordCount'; +import { countArticleWords } from '../utils/wordCount'; import { INITIAL_STATE, customToProgressEvent, @@ -280,10 +280,14 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { // 用户自带大纲原文(留空则后端走自动规划) user_outline_raw: request.userOutline?.trim() || '', article_type: request.articleType, + // 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。 + article_type_detail: request.articleTypeDetail?.trim() || '', target_audience: request.targetAudience, word_count_target: request.wordCountTarget, keyword_count: Math.max(1, Math.min(10, request.keywordCount)), writing_mode: request.writingMode === 'strict' ? 'strict' : 'loose', + // 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。 + add_references: request.addReferences ?? false, material_source: request.materialSource, notebook_search_space_id: request.noteSearchSpaceId ?? null, notebook_source_ids: request.noteSourceIds ?? null, @@ -328,7 +332,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined; else if (pausePoint === 'draft_confirm') { record.draftTitle = state.currentDraftTitle; - record.draftWordCount = countWords(state.currentDraftMarkdown); + record.draftWordCount = countArticleWords(state.currentDraftMarkdown); record.reviewResult = state.reviewResult ?? undefined; } else if (pausePoint === 'review_confirm') { record.reviewResult = state.reviewResult ?? undefined; @@ -342,7 +346,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) { pendingRevisionContext = { submittedIssues: allIssues.filter((_, i) => selectedSet.has(i)), skippedIssues: allIssues.filter((_, i) => !selectedSet.has(i)), - oldWordCount: countWords(state.currentDraftMarkdown), + oldWordCount: countArticleWords(state.currentDraftMarkdown), }; } diff --git a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx index a05dc175..b6e3642e 100644 --- a/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx +++ b/frontend-web/src/open-canvas/contexts/WritingFormContext.tsx @@ -34,6 +34,9 @@ export interface WritingFormValue { setAudience: (v: string) => void; strictMode: boolean; setStrictMode: (v: boolean) => void; + /** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认开。 */ + addReferences: boolean; + setAddReferences: (v: boolean) => void; materialSource: 'general' | 'notebook'; setMaterialSource: (v: 'general' | 'notebook') => void; /** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */ @@ -81,6 +84,7 @@ export function WritingFormProvider({ const [wordCountInput, setWordCountInput] = useState('800'); const [audience, setAudience] = useState('普通读者'); const [strictMode, setStrictMode] = useState(false); + const [addReferences, setAddReferences] = useState(true); const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general'); const [selectedNotebookId, setSelectedNotebookId] = useState(initialNotebookId ?? null); const [selectedDocumentIds, setSelectedDocumentIds] = useState(initialDocumentIds ?? []); @@ -117,13 +121,14 @@ export function WritingFormProvider({ wordCountInput, setWordCountInput, audience, setAudience, strictMode, setStrictMode, + addReferences, setAddReferences, materialSource, setMaterialSource, selectedNotebookId, setSelectedNotebookId, selectedDocumentIds, setSelectedDocumentIds, modelName, setModelName, applyProposal, }), - [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal], + [intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal], ); return {children}; diff --git a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts index 04607a21..143f72fd 100644 --- a/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts +++ b/frontend-web/src/open-canvas/hooks/useAIWritingStream.ts @@ -21,7 +21,7 @@ * 这样 reducer 逻辑(INITIAL_STATE + switch 各分支)与老 hook 共享同一份代码, * 只换"事件源"。阶段 7 删冗余时把老 hook 整个删掉就行。 */ -import { countWords } from '../utils/wordCount'; +import { countArticleWords } from '../utils/wordCount'; import type { AIWritingRequest, AIWritingState, @@ -339,7 +339,7 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState case 'draft_ready': { const newMarkdown = event.draftMarkdown ?? ''; - const newWordCount = countWords(newMarkdown); + const newWordCount = countArticleWords(newMarkdown); const summary = prev.pendingRevisionContext ? { revisedIssues: prev.pendingRevisionContext.submittedIssues, @@ -701,6 +701,10 @@ export type AIWritingValues = { user_intent?: string; user_outline_raw?: string; article_type?: string; + /** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */ + article_type_detail?: string; + /** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */ + add_references?: boolean; status?: string; material_package?: unknown; current_outline?: unknown; diff --git a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts index f9afc30a..fa00a635 100644 --- a/frontend-web/src/open-canvas/hooks/useWritingEditor.ts +++ b/frontend-web/src/open-canvas/hooks/useWritingEditor.ts @@ -25,25 +25,33 @@ export function useWritingEditor({ const onChangeRef = useRef(onChange); useEffect(() => { onChangeRef.current = onChange; }, [onChange]); + // 编辑器自身最近一次 emit 出去的 markdown。父级把它存进 state 后会作为新的 `markdown` prop + // 回灌进来,下面的同步 effect 据此识别「这是我自己刚产生的变更」并跳过 setContent。 + const lastEmittedRef = useRef(null); + const editor = useEditor({ extensions, editable, content: markdown, onUpdate({ editor }) { - const fn = onChangeRef.current; - if (!fn) return; // eslint-disable-next-line @typescript-eslint/no-explicit-any const md = (editor as any).storage?.markdown?.getMarkdown?.() ?? editor.getText(); - fn(md); + lastEmittedRef.current = md; + onChangeRef.current?.(md); }, }, []); - // Sync external markdown changes (e.g. after streaming completes) + // Sync external markdown changes (e.g. after streaming completes / AI 改写) useEffect(() => { if (!editor) return; if (markdown === prevMarkdownRef.current) return; prevMarkdownRef.current = markdown; + // 跳过「编辑器自身刚 emit、又被父级回灌」的变更:此时编辑器内容已经是最新的,再 setContent + // 会重新解析整篇文档、重置光标。markdown 序列化对空格/换行是有损的(如行尾空格被裁剪), + // 自激回灌一旦触发 setContent,就会把刚输入的空格吞掉并让光标跳到下一个字(本次修的 bug)。 + if (markdown === lastEmittedRef.current) return; + const { from, to } = editor.state.selection; editor.commands.setContent(markdown, { emitUpdate: false }); const docSize = editor.state.doc.content.size; diff --git a/frontend-web/src/open-canvas/types/ai-writing.ts b/frontend-web/src/open-canvas/types/ai-writing.ts index e3f2f58d..407d6535 100644 --- a/frontend-web/src/open-canvas/types/ai-writing.ts +++ b/frontend-web/src/open-canvas/types/ai-writing.ts @@ -254,6 +254,8 @@ export interface AIWritingRequest { /** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */ userOutline?: string; articleType: string; + /** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */ + articleTypeDetail?: string; targetAudience: string; wordCountTarget: number; modelName?: string; @@ -261,6 +263,8 @@ export interface AIWritingRequest { keywordCount: number; /** 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) */ writingMode: "strict" | "loose"; + /** 是否在成稿里加参考文献(正文 [n] 角标 + 文末「参考文献」列表,像论文)。默认 true。 */ + addReferences?: boolean; /** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */ materialSource: "general" | "notebook"; /** 笔记来源(可选):空间 search_space_id */ diff --git a/frontend-web/src/open-canvas/utils/wordCount.ts b/frontend-web/src/open-canvas/utils/wordCount.ts index 47d1ef7e..ecc2d071 100644 --- a/frontend-web/src/open-canvas/utils/wordCount.ts +++ b/frontend-web/src/open-canvas/utils/wordCount.ts @@ -10,3 +10,15 @@ export function countWords(text: string): number { const alnumRuns = (text.match(/[A-Za-z0-9]+/g) ?? []).length; return cjk + cjkPunct + alnumRuns; } + +/** + * 正文字数:与后端 word_count 同口径——**不计文末「参考文献」列表**。 + * 后端在字数收敛后才追加 `## 参考文献` 段,其 word_count 只统计正文;前端展示「实际 vs 目标」 + * 也必须排除该段,否则会因文献列表虚高几十~几百字,让用户误以为严重超标。 + */ +export function countArticleWords(markdown: string): number { + if (!markdown) return 0; + const idx = markdown.lastIndexOf('## 参考文献'); + const body = idx >= 0 ? markdown.slice(0, idx) : markdown; + return countWords(body); +} diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py index b063009e..d09bcbbe 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/graph.py @@ -209,11 +209,17 @@ async def pause_draft_confirm(state: AIWritingState, config: RunnableConfig) -> "progress_events": [{"type": "resumed", "agent_name": "系统", "message": f"按用户意见重写({notes[:30]})"}], } - # to_editor + # to_editor:用户可能在「您的修改意见」里填了关注点再点「让编辑继续审核」, + # 把它带给编辑节点,让三维度审查重点核查用户在意的方面(不再被丢弃)。 + editor_notes = (user_input.get("userRevisionNotes") or "").strip() + msg = ( + f"提交编辑审核(已附带你的修改意见:{editor_notes[:30]})" + if editor_notes else "提交编辑审核" + ) return { - "last_user_intervention": user_input, + "last_user_intervention": {**user_input, "_editor_notes": editor_notes}, "status": "reviewing", - "progress_events": [{"type": "resumed", "agent_name": "系统", "message": "提交编辑审核"}], + "progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}], } diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py index 191cc602..eeb24b87 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/editor.py @@ -10,6 +10,7 @@ from langchain_core.runnables import RunnableConfig from ..state import AIWritingState, ReviewResult, ReviewIssue from ..prompts.editor_prompts import ( EDITOR_SYSTEM, FACT_REVIEW_USER, LOGIC_REVIEW_USER, LANGUAGE_REVIEW_USER, + USER_FOCUS_BLOCK, ) from ._agent_runner import ( EDITOR_AGENT_ID, @@ -34,6 +35,19 @@ logger = logging.getLogger(__name__) _DEFAULT_PASS_THRESHOLD = 75 +def _build_user_focus(state: AIWritingState) -> str: + """从干预数据取用户在「编辑审核」时附带的修改意见,拼成注入审查提示词的关注块。 + + 仅当用户点「让编辑继续审核」且填了意见(graph.py `to_editor` 分支 stash 的 + ``_editor_notes``)时返回非空块;否则返回空串,三维度审查保持原样。 + """ + intervention = state.get("last_user_intervention") or {} + notes = (intervention.get("_editor_notes") or "").strip() + if not notes: + return "" + return USER_FOCUS_BLOCK.format(notes=notes) + + def _materials_summary(pkg: dict) -> str: lines = [] for m in (pkg.get("materials") or [])[:6]: @@ -129,10 +143,13 @@ async def editor_node(state: AIWritingState, config: RunnableConfig) -> dict: ai_writing_config = get_ai_writing_config() pass_threshold = int(ai_writing_config.get("pass_threshold", _DEFAULT_PASS_THRESHOLD)) + # 用户在草稿确认时附带的修改意见 → 注入三维度审查,让编辑重点核查(空则不影响原审查) + user_focus = _build_user_focus(state) + REVIEW_TASKS = [ - ("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article)), - ("logic", LOGIC_REVIEW_USER.format(article=article)), - ("language", LANGUAGE_REVIEW_USER.format(article=article)), + ("fact", FACT_REVIEW_USER.format(materials_summary=mats, article=article, user_focus=user_focus)), + ("logic", LOGIC_REVIEW_USER.format(article=article, user_focus=user_focus)), + ("language", LANGUAGE_REVIEW_USER.format(article=article, user_focus=user_focus)), ] # 内置 agent 模式(config.yaml ai_writing.use_builtin_agents.editor): diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py index 3e66f7b0..30f325ed 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/intent_parser.py @@ -151,7 +151,9 @@ def _resolve_intent_fields( llm_type = raw_type if raw_type in valid_keys else fallback_type preconfigured_type = (state.get("article_type") or "").strip() - if preconfigured_type in valid_keys: + if preconfigured_type: + # 用户/配置助手已明确指定文章类型(含**列表外的自定义/新类型**)→ 原样尊重, + # 不再因不在默认表里被丢弃回退成默认(否则自定义/新类型在写作时会被还原成默认)。 final_article_type = preconfigured_type else: final_article_type = llm_type diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py index 5f96f458..d84f770c 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/writer_draft.py @@ -1,6 +1,7 @@ """作家节点 Phase 2:按大纲逐段写正文,支持流式 chunk 输出。""" from __future__ import annotations +import json import logging import re @@ -9,9 +10,16 @@ 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_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER, + OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER, WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER, ) +# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。 +# writer_outline 不反向 import 本模块,无循环。 +from .writer_outline import ( + _parse_outline as _revise_parse_outline, + _materials_summary as _revise_materials_summary, +) from ._agent_runner import ( WRITER_AGENT_ID, WritingAgentUnavailable, @@ -27,13 +35,116 @@ from ._model_fallback import ( emit_model_switch_event, fallback_chain, ) -from ._utils import resolve_writing_model_name, _content_to_text, count_words, reasoning_delta, ThinkTagParser, safe_get_stream_writer +from ._utils import resolve_writing_model_name, _content_to_text, count_words, llm_json, reasoning_delta, ThinkTagParser, safe_get_stream_writer logger = logging.getLogger(__name__) # 章节正文里的 Markdown 标题行(允许前导空白) — 分组捕获缩进 + 井号串 _HEADING_PREFIX_RE = re.compile(r"^([ \t]*)(#{1,6})[ \t]+", re.MULTILINE) +# ── 字数控制 ──────────────────────────────────────────────────────────────── +# 全文允许偏离目标字数的误差带(用户要求「误差不超过 100 字」):初稿装配后双向收敛 +# 到 [target-tol, target+tol] 内,太短就扩写、太长就压缩。 +_WORD_COUNT_TOLERANCE = 100 +# 单节字数下限:均分/压缩时给章节兜个底,避免某节被压成一两句话。 +_SECTION_MIN_WORDS = 80 +# 收敛回合上限:每回合调一节(按相对应得字数偏差最大的节优先)。放宽到 8,让大幅超量 +# (需逐节压多节)也能压到位;命中容差或所有节都压不动即提前停。 +_MAX_REBALANCE_ROUNDS = 8 + + +def _allocate_section_words(word_count_target: int, n_sections: int) -> int: + """把总目标字数均分到各章节。 + + 历史实现按 1.1 过量系数放大以补偿模型「写不够」,但实测模型更常「写太多」 + (800 字写成 2000)。这里去掉放大、按目标如实均分,只留一个保底下限防止单节退化; + 初稿超量/欠量统一交给装配后的双向收敛阶段纠正,确保全文落在误差带内。 + """ + n = max(n_sections, 1) + return max(_SECTION_MIN_WORDS, round(word_count_target / n)) + + +def _section_word_range(per_section: int) -> tuple[int, int]: + """单节目标对应的「硬上下限」,喂给模型卡字数。 + + 关键是给出**上限**——旧提示词只说「不少于 N 字」,模型逐节超额累加才把 800 写成 2000。 + """ + return int(per_section * 0.85), int(per_section * 1.2) + + +def _pick_rebalance( + counts: list[int], fair: int, target: int, tol: int, skip: set[int] | tuple = () +) -> tuple[int, int] | None: + """决定下一步调整哪一节、调到多少字;全文已在容差内或无可调项则返回 None。 + + 返回 ``(idx, sec_target)``: + - 全文偏长 → 选「超应得份额最多」且未跳过的节,**压到它的应得份额**(可达目标,不强求 + 一节吃下全部缺口;各超额节轮流压到份额,累计即收敛到目标)。没有超份额的节时,退而 + 把最长的一节按缺口压一压。 + - 全文偏短 → 选「最欠份额」的节,把整体缺口加到它头上扩写(扩写对模型更容易)。 + ``skip`` 是本轮已尝试但「压不动 / 没改善」的章节集合,跳过它们换下一节,避免一节压不动 + 就放弃整个收敛。配合调用方「确有改善才采用」的护栏,保证单调收敛、自然终止。 + """ + if not counts: + return None + total = sum(counts) + delta = target - total # >0 需补,<0 需删 + if abs(delta) <= tol: + return None + pool = [k for k in range(len(counts)) if k not in skip] + if not pool: + return None + if delta < 0: + over = [k for k in pool if counts[k] > fair] + if over: + idx = max(over, key=lambda k: counts[k] - fair) + return idx, max(_SECTION_MIN_WORDS, fair) + # 各节都不高于均分却仍超目标(节数偏多 / 下限抬高)→ 压最长的一节 + idx = max(pool, key=lambda k: counts[k]) + return idx, max(_SECTION_MIN_WORDS, counts[idx] + delta) + under = [k for k in pool if counts[k] < fair] + target_pool = under or pool + idx = min(target_pool, key=lambda k: counts[k] - fair) + return idx, counts[idx] + delta + + +# 触发「先改大纲再重写正文」的修订动作(直接按用户意见改 / 采纳编辑意见 / 自定义修订) +_REVISE_ACTIONS = {"user_revise", "accept_review", "custom_revise"} + + +async def _revise_outline_with_notes( + state: AIWritingState, config: RunnableConfig, outline: dict, feedback: str +) -> dict: + """修订模式下,先按用户/编辑意见更新大纲(标题/结构),再据此重写正文。 + + 章节标题、文章标题都由大纲注入,正文生成本身不产出标题——所以「把第一章标题改成…」 + 「加一节 / 删一节 / 调整顺序」这类指令必须先落到大纲,成稿里才看得到变化(否则只是 + 正文被重写、标题仍沿用旧大纲)。复用 OUTLINE_REVISE 提示词:它只改用户明确提到的 + 部分,纯正文类意见(如「第二段数据来源不清晰」)不会误动结构。任何异常都保留原大纲。 + """ + pkg = state.get("material_package") or {} + model_name = (state.get("model_name") or "").strip() or None + try: + result = await llm_json( + OUTLINE_REVISE_SYSTEM, + OUTLINE_REVISE_USER.format( + user_intent=state.get("user_intent", ""), + article_type=state.get("article_type", "analysis"), + target_audience=state.get("target_audience", "普通读者"), + word_count_target=state.get("word_count_target", 800), + current_outline=json.dumps(outline, ensure_ascii=False, indent=2), + feedback=feedback, + materials_summary=_revise_materials_summary(pkg), + ), + outline, # 解析失败 → 回退原大纲 + config, + model_name=model_name, + ) + return _revise_parse_outline(result) + except Exception as e: + logger.warning("draft revise: outline update failed, keep original outline: %s", e) + return outline + def _normalize_section_headings(text: str) -> str: """把章节正文里的 Markdown 标题层级归一到允许的两级:### / ####。 @@ -117,19 +228,112 @@ def _looks_like_refusal(text: str) -> bool: return any(marker in text for marker in _REFUSAL_MARKERS) -def _get_section_materials(material_ids: list[str], pkg: dict) -> str: +def _build_citation_map(pkg: dict) -> dict[str, int]: + """material_id → 1-based 序号(按 package 顺序),写作时作为角标 [n] 的稳定编号。 + + 成稿装配后再按「正文出现顺序」重排成连续 [1][2][3](见 ``_append_references``)。 + """ + return {m["id"]: i + 1 for i, m in enumerate(pkg.get("materials", [])) if m.get("id")} + + +def _get_section_materials(material_ids: list[str], pkg: dict, cite_map: dict[str, int] | None = None) -> str: + """渲染本节可用素材喂给作家。 + + ``cite_map`` 非空(开启参考文献)时,每条素材前缀用其**全局引用编号** ``[n]``,作家据此在 + 正文里打角标;为空时沿用旧行为(前缀素材 id,仅作辨识、不要求引用)。 + """ mat_map = {m["id"]: m for m in pkg.get("materials", [])} + + def _prefix(mid: str) -> str: + if cite_map and mid in cite_map: + return f"[{cite_map[mid]}]" + return f"[{mid}]" + lines = [] for mid in material_ids: if mid in mat_map: m = mat_map[mid] - lines.append(f"[{mid}] {m['title']}: {m['content'][:300]}") + lines.append(f"{_prefix(mid)} {m['title']}: {m['content'][:300]}") if not lines: for m in pkg.get("materials", [])[:2]: - lines.append(f"[{m['id']}] {m['title']}: {m['content'][:300]}") + lines.append(f"{_prefix(m['id'])} {m['title']}: {m['content'][:300]}") return "\n".join(lines) if lines else "(无相关素材)" +# 开启参考文献时,追加到每节写作指令末尾:要求作家在引用素材处打角标。 +_CITATION_DIRECTIVE = ( + "\n\n【引用要求】上面每条素材前的 [n] 是它的编号。" + "当你在正文里用到某条素材的事实/数据/观点时,在该句句末加上对应角标,如 [1] 或 [1][3];" + "只能使用确实给出的素材编号,不要编造编号,不要给通用常识加角标。" + "不要在本节末尾自行罗列参考文献——文末的「参考文献」列表由系统统一生成。" +) + + +_CITATION_TOKEN_RE = re.compile(r"\[(\d{1,3})\]") + + +def _format_reference_entry(m: dict) -> str: + """单条参考文献的展示文本:标题. 来源. 日期. url(缺项自动省略)。""" + title = (m.get("title") or "").strip() or "(无标题)" + parts = [title] + source = (m.get("source") or "").strip() + if source: + parts.append(source) + date = (m.get("published_at") or "").strip() + if date: + parts.append(date) + entry = ". ".join(parts) + url = (m.get("url") or "").strip() + if url: + entry = f"{entry}. {url}" + return entry + + +def _append_references(markdown: str, pkg: dict, cite_map: dict[str, int]) -> str: + """把正文里的引用角标按「出现顺序」重排为连续 [1][2][3],并在文末追加「参考文献」列表。 + + 只收录正文真正引用到(且编号有效)的素材,列表顺序与正文出现顺序一致——像论文一样。 + 没有任何有效引用时原样返回(不加空列表)。 + """ + num_to_mat: dict[int, dict] = {} + for m in pkg.get("materials", []): + n = cite_map.get(m.get("id")) + if n: + num_to_mat[n] = m + if not num_to_mat: + return markdown + + # 1) 按正文出现顺序收集被引用且有效的原始编号 + order: list[int] = [] + seen: set[int] = set() + for match in _CITATION_TOKEN_RE.finditer(markdown): + k = int(match.group(1)) + if k in num_to_mat and k not in seen: + seen.add(k) + order.append(k) + if not order: + return markdown + + # 2) 原始编号 → 连续新编号(出现先后);逐 match 独立映射,避免顺序替换串号 + renumber = {old: new for new, old in enumerate(order, start=1)} + + def _sub(match: re.Match) -> str: + k = int(match.group(1)) + return f"[{renumber[k]}]" if k in renumber else match.group(0) + + body = _CITATION_TOKEN_RE.sub(_sub, markdown) + + # 3) 文末参考文献列表(按新编号顺序)。 + # 每条之间用**空行**分隔,使其各成独立段落——否则 Markdown 会把单换行的多行并成一段, + # 所有文献会挤成一坨(见排版反馈)。与正文一致沿用 [n] 角标,不再叠加列表序号。 + entries = [ + f"[{renumber[old]}] {_format_reference_entry(num_to_mat[old])}" + for old in order + ] + references_md = "## 参考文献\n\n" + "\n\n".join(entries) + return f"{body}\n\n{references_md}" + + async def _save_draft_to_repo(config: RunnableConfig, draft: DraftArticle) -> None: """把 current_draft 异步落到业务表(best-effort,写失败不影响流程)。 @@ -171,6 +375,10 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di intervention = state.get("last_user_intervention") or {} revision_count = state.get("revision_count", 0) + # 参考文献:开启且有素材时,写作给素材编号 + 要求作家打角标 [n],成稿装配后统一生成文献列表。 + add_references = bool(state.get("add_references", False)) and bool(pkg.get("materials")) + cite_map = _build_citation_map(pkg) if add_references else None + # 写作模式;用户在「素材不足求助」里选「按通用知识续写」的章节强制走宽松 writing_mode = state.get("writing_mode", "loose") loose_titles: set[str] = set(intervention.get("_loose_sections") or []) @@ -198,11 +406,23 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di if parts: revision_notes = "\n修改要求:\n" + "\n".join(parts) + # 修订模式:先把上面这条意见落实到大纲(标题/结构),再据更新后的大纲逐节重写。 + # 章节标题、文章标题都由大纲注入、正文生成本身不产出标题,所以「改第一章标题」「加一节/ + # 删一节」这类指令必须先改大纲才能在成稿里生效(否则只是正文被重写、标题仍是旧的)。 + outline_revised = False + if intervention.get("action") in _REVISE_ACTIONS and revision_notes.strip(): + revised_outline = await _revise_outline_with_notes(state, config, outline, revision_notes.strip()) + if revised_outline and revised_outline != outline: + outline = revised_outline + outline_revised = True + word_count_target = state.get("word_count_target", 800) sections = outline.get("sections", []) n_sections = max(len(sections), 1) - # 单节硬下限:总目标 / 节数,乘 1.1 过量系数补偿模型对短目标的天然欠量;单节至少 150 字 - words_per_section = max(150, int(word_count_target * 1.1 / n_sections)) + # 按目标如实均分到各节(不再放大),并给模型一个硬上下限区间卡字数; + # 装配后再双向收敛把全文夹逼进误差带。 + words_per_section = _allocate_section_words(word_count_target, n_sections) + sec_min_words, sec_max_words = _section_word_range(words_per_section) article_title = outline.get("title", "文章") # 兼容后端 snake_case 与前端编辑后回传的 camelCase @@ -265,7 +485,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di key_points = section.get("key_points") or section.get("keyPoints") or [] mat_ids = section.get("material_ids") or section.get("materialIds") or [] - section_materials = _get_section_materials(mat_ids, pkg) + section_materials = _get_section_materials(mat_ids, pkg, cite_map) # 三级结构:把每个 KeyPoint 展开成「小节标题 + 缩进子要点」喂给作家。 # 兼容旧 session 里 key_points 为 List[str] 的情况(走相同 helper)。 key_points_str = _render_key_points_for_prompt(key_points) @@ -289,7 +509,12 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di revision_section=revision_notes, mode_section=mode_section, target_words=words_per_section, + min_words=sec_min_words, + max_words=sec_max_words, ) + # 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。 + if add_references: + user_msg += _CITATION_DIRECTIVE section_buf = "" # 只累积正文(非思考)用于拼接 Markdown section_succeeded = False @@ -440,61 +665,95 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di ], } - def _assemble() -> str: + def _assemble(bufs: list[str]) -> str: # 章节正文里的标题统一归一到 ###(要点小标题),章节标题用 ##,文章标题用 # body = "\n\n".join( f"## {t}\n\n{_normalize_section_headings(b)}" - for t, b in zip(section_titles, section_bufs) + for t, b in zip(section_titles, bufs) ) return f"# {article_title}\n\n{body}" - full_markdown = _assemble() + async def _rewrite_section(idx: int, sec_target: int, *, shrink: bool) -> str: + """重写第 idx 节使其趋近 sec_target 字:shrink=True 走压缩、否则扩写(单轮,非流式)。 + + 兜底优化,不再做模型链容错:拿当前已成功的模型试一次即可,失败由调用方保留原文。 + """ + sec = sections[idx] + tmpl = DRAFT_COMPRESS_USER if shrink else DRAFT_EXPAND_USER + msg = tmpl.format( + article_title=article_title, + section_title=section_titles[idx], + key_points=_render_key_points_for_prompt( + sec.get("key_points") or sec.get("keyPoints") or [] + ), + section_materials=_get_section_materials( + sec.get("material_ids") or sec.get("materialIds") or [], pkg, cite_map + ), + existing_text=section_bufs[idx], + target_words=sec_target, + ) + # 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。 + if add_references: + msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。" + if use_agent: + return (await stream_writing_agent( + agent_id=WRITER_AGENT_ID, + user_payload=msg, + state=state, + config=config, + on_text=None, + )).strip() + rewrite_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model() + resp = await rewrite_model.ainvoke([ + SystemMessage(content=DRAFT_SECTION_SYSTEM), + HumanMessage(content=msg), + ]) + parser = ThinkTagParser() + return "".join( + t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking + ).strip() + + full_markdown = _assemble(section_bufs) word_count = count_words(full_markdown) - # 扩写兜底:总字数不足目标 90% 时,重写并扩充最短的章节(单轮,非流式) - if section_bufs and word_count < word_count_target * 0.9: - idx = min(range(len(section_bufs)), key=lambda k: count_words(section_bufs[k])) - target_sec = sections[idx] - expand_target = count_words(section_bufs[idx]) + (word_count_target - word_count) + # 双向收敛兜底:把全文字数夹逼进 [target±tol]。太短就扩写最欠的节、太长就压缩最超的节, + # 每回合只动一节,且仅在「确实更接近目标」时才采用。某节压不动 / 没改善 → 记入 skip 换 + # 下一节继续(不再一节失败就放弃整个收敛,否则弱模型一次压缩失败就留下超量稿)。 + rounds = 0 + skip: set[int] = set() + while section_bufs and rounds < _MAX_REBALANCE_ROUNDS: + counts = [count_words(b) for b in section_bufs] + pick = _pick_rebalance(counts, words_per_section, word_count_target, _WORD_COUNT_TOLERANCE, skip) + if pick is None: + break + rounds += 1 + idx, sec_target = pick + shrink = sec_target < counts[idx] try: - expand_msg = DRAFT_EXPAND_USER.format( - article_title=article_title, - section_title=section_titles[idx], - key_points=_render_key_points_for_prompt( - target_sec.get("key_points") or target_sec.get("keyPoints") or [] - ), - section_materials=_get_section_materials( - target_sec.get("material_ids") or target_sec.get("materialIds") or [], pkg - ), - existing_text=section_bufs[idx], - target_words=expand_target, - ) - if use_agent: - expanded = (await stream_writing_agent( - agent_id=WRITER_AGENT_ID, - user_payload=expand_msg, - state=state, - config=config, - on_text=None, - )).strip() - else: - # 扩写是兜底优化,不再做模型链兜底:拿当前已成功的模型试一次即可,失败就保留原文。 - expand_model = create_chat_model(name=current_model_name) if current_model_name else create_chat_model() - resp = await expand_model.ainvoke([ - SystemMessage(content=DRAFT_SECTION_SYSTEM), - HumanMessage(content=expand_msg), - ]) - parser = ThinkTagParser() - expanded = "".join( - t for is_thinking, t in parser.feed(_content_to_text(resp.content)) if not is_thinking - ).strip() - # 仅在扩写确实更长时才采用,避免越改越短 - if count_words(expanded) > count_words(section_bufs[idx]): - section_bufs[idx] = expanded - full_markdown = _assemble() - word_count = count_words(full_markdown) + new_text = await _rewrite_section(idx, sec_target, shrink=shrink) except Exception as e: - logger.warning("draft expand failed for section [%s]: %s", section_titles[idx], e) + logger.warning("draft rebalance failed for section [%s]: %s", section_titles[idx], e) + skip.add(idx) + continue + if not new_text: + skip.add(idx) + continue + candidate = list(section_bufs) + candidate[idx] = new_text + new_md = _assemble(candidate) + new_wc = count_words(new_md) + if abs(new_wc - word_count_target) < abs(word_count - word_count_target): + section_bufs = candidate + full_markdown = new_md + word_count = new_wc + skip.discard(idx) # 改善了,后续若仍最超可再压 + else: + skip.add(idx) # 这一节压不动 / 反而更差,跳过它,换下一节 + + # 参考文献:在全文(含字数收敛)定稿后统一处理——把正文角标按出现顺序重排为连续 [1][2][3], + # 并在文末追加「参考文献」列表(只收录被引用到的素材)。word_count 保持正文字数、不计文献列表。 + if add_references and cite_map: + full_markdown = _append_references(full_markdown, pkg, cite_map) draft = DraftArticle( title=outline.get("title", "文章"), @@ -513,6 +772,13 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di notices: list[dict] = [] if model_default_notice: notices.append(model_default_notice) + if outline_revised: + notices.append({ + "type": "notice", + "agent_name": agent_label, + "title": "已按意见调整大纲", + "detail": "已根据你的修改意见更新大纲(如标题/结构),并据此重写正文。", + }) if section_errors: notices.append({ "type": "notice", @@ -521,7 +787,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di "detail": "部分章节生成失败,对应段落已占位。原因:" + ";".join(section_errors[:3]), }) - return { + updates: dict = { "current_draft": draft, "drafts": [draft], "status": "awaiting_draft_confirm", @@ -539,3 +805,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di }, ], } + # 大纲被意见改过 → 同步回 state,使后续修订/确认基于更新后的大纲(标题不再回退) + if outline_revised: + updates["current_outline"] = outline + return updates 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 1a938d25..7b78f3d2 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 @@ -271,9 +271,14 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) -> logger.warning("outliner builtin agent unavailable, falling back to legacy prompts: %s", e) use_agent = False + # 新(列表外)文章类型时附上配置助手识别的一句话特征,让大纲生成体现该类型侧重; + # 命中已有类型时 detail 为空,article_type 维持原样。 + _article_type = state.get("article_type", "analysis") + _article_type_detail = (state.get("article_type_detail") or "").strip() + article_type_text = f"{_article_type}({_article_type_detail})" if _article_type_detail else _article_type fmt_args = dict( user_intent=state["user_intent"], - article_type=state.get("article_type", "analysis"), + article_type=article_type_text, target_audience=state.get("target_audience", "普通读者"), word_count_target=state.get("word_count_target", 800), materials_summary=_materials_summary(pkg), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/editor_prompts.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/editor_prompts.py index fe826363..790406d4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/editor_prompts.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/prompts/editor_prompts.py @@ -24,8 +24,17 @@ EDITOR_SYSTEM = """你是一名资深主编(编辑),标准极高,要求 ] }""" +# 用户在提交编辑审核时附带的修改意见 / 关注点(无则为空串,不注入)。 +# 放在「判断标准」之后、待审文章之前,提醒编辑在本维度重点核查用户在意的方面。 +USER_FOCUS_BLOCK = """ + +【用户特别关注(高优先级)】 +用户在提交审核时提出了以下修改意见 / 关注点,请在本维度审查时**重点核查**这些方面; +若发现文章未妥善处理,必须在 issues 中明确列出对应问题并给出针对性的 suggestion: +{notes}""" + FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。 -判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。 +判断标准:引用的数据/事实是否有素材支撑,是否有捏造或夸大。{user_focus} 参考素材: {materials_summary} @@ -36,7 +45,7 @@ FACT_REVIEW_USER = """请审查以下文章的【事实准确性】维度。 只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):""" LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。 -判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。 +判断标准:论点是否清晰,论据是否充分,段落是否连贯,结构是否合理。{user_focus} 待审文章: {article} @@ -44,7 +53,7 @@ LOGIC_REVIEW_USER = """请审查以下文章的【逻辑结构】维度。 只输出 JSON(score: 0-100 整数,issues 列表,每条含 severity):""" LANGUAGE_REVIEW_USER = """请审查以下文章的【语言文字】维度。 -判断标准:语言是否流畅,用词是否准确,有无错别字或病句。 +判断标准:语言是否流畅,用词是否准确,有无错别字或病句。{user_focus} 待审文章: {article} 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 d49d494e..ff392b73 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 @@ -208,7 +208,8 @@ DRAFT_SECTION_SYSTEM = """你是一名专业内容创作者(作家)。 - 直接返回这一段的内容(含 `### 小节标题` + 概述段 + `#### 子要点标题` + 各自正文),不要 JSON,不要解释。 - 不要写"以下是正文""本段内容如下"之类的开场白。 - 紧扣本章节主题,不要写「文章整体结构」中其他章节负责的内容,避免重复。 -- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理,写满要求的篇幅。""" +- 充分展开每一层:小节级 2-3 句概述,子要点级 3-5 句具体论述,尽量结合素材中的事实与数据说理。 +- 【字数纪律,硬性要求】严格遵守用户给定的字数区间:既不要明显写不够,更不要超出上限。靠控制论述详略来卡字数,不要为凑字数注水,也不要为收尾而草草带过。""" # ── 写作模式说明:注入 DRAFT_SECTION_USER 的 {mode_section} ─────────────────── @@ -240,14 +241,14 @@ DRAFT_SECTION_USER = """文章标题:{article_title} 相关素材: {section_materials} {revision_section}{mode_section} -请写出「{section_title}」这一章节的内容,不少于 {target_words} 字。 +请写出「{section_title}」这一章节的内容,目标约 {target_words} 字,并**严格控制在 {min_words}~{max_words} 字之间**——不要明显少于下限,更绝不要超过上限。 按下面的结构两层展开写: - 每个主要 bullet(顶格)→ `### 小节标题`,标题下先写 2-3 句概述/引入正文。 - 该小节的每个缩进 bullet(子要点)→ `#### 子要点标题`,标题下展开 3-5 句具体论述。 - 没有缩进 bullet 的小节,直接在 ### 标题下连贯成段论述,不出现 ####。 -论述充分、层次清晰、内容充实。""" +论述层次清晰、内容充实,但务必在上述字数上限内完成:宁可精炼也不要为凑字数注水。""" DRAFT_EXPAND_USER = """文章标题:{article_title} 当前章节:{section_title} @@ -266,3 +267,21 @@ DRAFT_EXPAND_USER = """文章标题:{article_title} - 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。 - 不要输出 # 或 ##(文章标题、章节标题由系统添加)。 - 直接返回扩充后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。""" + +DRAFT_COMPRESS_USER = """文章标题:{article_title} +当前章节:{section_title} +章节内的小节与子要点(层级 bullet 列表): +{key_points} + +相关素材: +{section_materials} + +这一章节已有内容如下: +{existing_text} + +以上内容篇幅明显偏长。请在保留原意、关键事实和「### 小节 / #### 子要点」两级标题结构的基础上**大幅精简**改写这一章节,使内容**压到 {target_words} 字以内**——这是硬性上限,宁可删减细节也绝不能超过。 +要求: +- 这是「压缩」任务,不是「重写扩充」:必须比原文明显更短。大胆删减冗余表述、合并重复论点、砍掉次要细节与铺陈,只保留关键事实、数据与核心结论。 +- 保留原有的 ### 小节标题与 #### 子要点标题;不要新增或删除小节/子要点,也不要把 #### 改成 bullet。 +- 不要输出 # 或 ##(文章标题、章节标题由系统添加)。 +- 直接返回精简后的完整章节内容(含 ### 与 #### 两层标题及各自正文段),不要解释。""" 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 8add3a82..34e3535a 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 @@ -129,12 +129,17 @@ class AIWritingState(TypedDict): # 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。 user_outline_raw: str article_type: str + # 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。 + # 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。 + article_type_detail: str target_audience: str word_count_target: int # 素材检索关键词数量(用户指定,1~10,默认 6) keyword_count: int # 写作模式:strict=严格依据素材 / loose=可结合通用知识(默认 loose) writing_mode: str + # 是否在成稿里加参考文献:正文引用素材处加角标 [n],文末按出现顺序生成「参考文献」列表(像论文)。 + add_references: bool # 检索类型:general=通用检索(按素材收集专家 agent 配置的技能编排,默认)/ # notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher # 节点按 general 兼容处理。 diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py index 66cf4d88..faf6abf4 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/agent.py @@ -27,7 +27,9 @@ from deerflow.models import create_chat_model logger = logging.getLogger(__name__) # 写作配置 Q&A:意图澄清最多几轮(一条用户消息 = 一轮),到顶强制提交配置卡。 -WRITING_SETUP_MAX_ROUNDS = 3 +# 写作配置 Q&A 现在「先生成大纲并与用户多轮确认 → 再提交配置」,需要更多来回,故上限放宽。 +# 仍是兜底安全阀:到顶才用 tool_choice 强制提交,正常流程由用户确认大纲后自然提交。 +WRITING_SETUP_MAX_ROUNDS = 8 def _get_runtime_config(config: RunnableConfig) -> dict: diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py index 839a83e0..e74190ca 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/lead_agent/prompt.py @@ -1128,29 +1128,40 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str: type_lines = "\n".join( f" - `{t.get('key', '')}`:{t.get('label', '')}" for t in article_types if t.get("key") ) - type_block = f"【可选文章类型】article_type 只能填下列 key 之一:\n{type_lines}" + type_block = ( + "【文章类型】优先从下列已有类型里选,命中就把它的 key 填进 `article_type`:\n" + f"{type_lines}\n" + "若上述类型**都不贴切**,可以**自拟一个新的文章类型**:`article_type` 填一个简短的中文类型名" + "(如「政策解读」「人物特写」「复盘总结」),并在 `article_type_detail` 里用一句话说明该类型的" + "写作侧重/特征。能用已有类型就不要新造,确实不贴切才新建。" + ) else: - type_block = "【文章类型】若不确定 article_type,可留空,交给用户在表单里选。" + type_block = ( + "【文章类型】根据意图判断文章类型:`article_type` 填一个简短的中文类型名,并在 " + "`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。" + ) - return f"""你是「AI 写作」功能的**配置助手**。你的唯一职责:通过简短对话把用户的写作需求聊清楚,然后调用 `setup_writing` 工具把写作配置提交给用户确认。提交后由用户在表单里应用、再点「开始写作」。 + return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。 【铁律——最高优先级】 -- 你**绝对不撰写**文章正文、分析内容、大纲内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只把它当作"要配置的写作目标"**,绝不替用户动笔、绝不直接完成这篇写作。 +- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。 - 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。 -- 不要在回复里输出文章段落、要点清单或大纲(用户主动粘贴的大纲除外——原样放进 `outline` 参数)。 +- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。 -【工作流程】(与用户交互确认,但最多 3 轮内必须提交) -1. 读用户输入。当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 向用户提问——**优先给出 `options` 选项**让用户点选(例如目标字数给「800 / 1500 / 3000」,读者给「普通读者 / 行业专家 / 政策决策者」)。一次可就 1-2 个独立的关键项各发一个 ask_clarification。 -2. 与用户的澄清来回**最多 2 次**。一旦关键信息明确、或已经问过 2 次,就**立即进入第 3 步提交**,缺失字段用合理默认值,绝不无休止追问。 -3. 调用 `setup_writing` 提交配置: +【工作流程】 +1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。 +2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。 +3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。 +4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交: - `user_intent`:精炼成一句话的写作目标(必填)。 - - `article_type`:见下方可选类型;不确定就留空。 + - `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。 + - `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。 - `word_count_target`:正整数;用户没明说就给合理默认(如 800)。 - `target_audience`:如「普通读者」「行业专家」;没提就填「普通读者」。 - `writing_mode`:用户强调「严格依据素材/不要编造」→ `strict`,否则 `loose`。 - `material_source`:**默认 `knowledge_base`(知识库)**;只有当用户明确要求用「技能/技能检索」时才填 `skill`,否则一律 `knowledge_base`。 - - `outline`:用户提供的大纲原文(保留其层级与标题),否则留空。 -4. 调用 `setup_writing` 前,用**一句话**说明你将提交的配置要点;调用之后不要再输出任何文章内容。 + - `outline`:**填入刚刚与用户确认的那份大纲全文**(保留其层级与标题)。 + 调用前用**一句话**说明将提交的配置要点;调用之后不再输出任何内容。 {type_block} """ diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py index d1dbf0d8..06976c49 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/agents/middlewares/setup_writing_approval_middleware.py @@ -74,6 +74,8 @@ class SetupWritingApprovalMiddleware(AgentMiddleware[SetupWritingApprovalMiddlew return { "user_intent": str(args.get("user_intent") or "").strip(), "article_type": str(args.get("article_type") or "").strip(), + # 新(列表外)文章类型的一句话特征说明;命中已有类型时为空。 + "article_type_detail": str(args.get("article_type_detail") or "").strip(), "word_count_target": self._coerce_word_count(args.get("word_count_target")), "target_audience": str(args.get("target_audience") or "").strip(), "writing_mode": str(args.get("writing_mode") or "").strip(), diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py index 3678aa87..1f653ba9 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/tools/builtins/setup_writing_tool.py @@ -28,24 +28,30 @@ def setup_writing( writing_mode: str | None = None, material_source: str | None = None, outline: str | None = None, + article_type_detail: str | None = None, ) -> Command: """把与用户聊清的写作配置提交给前端表单(不直接开始写作)。 Args: user_intent: 用户的写作意图(精炼成一句话的写作目标)。 - article_type: 文章类型的 key(须是系统给定的合法类型之一;不确定可留空)。 + article_type: 文章类型。优先填系统给定的合法类型 key;若已有类型都不贴切,可**自拟一个新的 + 文章类型**——此时填一个简短的中文类型名(如「政策解读」「人物特写」),并在 + ``article_type_detail`` 里说明该类型特征。不确定可留空。 word_count_target: 目标字数(正整数)。 target_audience: 目标读者(如「行业专家」「普通读者」)。 writing_mode: 写作模式,'strict'(严格依据素材)或 'loose'(可结合通用知识)。 material_source: 素材来源,'knowledge_base'(知识库)或 'skill'(技能检索)。 - outline: 若用户已提供大纲,原样填入其大纲文本(保留其层级与标题),否则留空。 + outline: **已与用户确认的大纲全文**(保留其层级与标题)。本工具在用户确认大纲后调用, + 故通常应带上这份大纲;用户未提供/未确认大纲时才留空。 + article_type_detail: 仅当 ``article_type`` 是**新类型(不在已有列表里)**时填写:用一句话说明 + 这个类型的写作侧重/特征(如「以政策原文为锚、逐条拆解影响并给出执行建议」)。命中已有类型时留空。 """ logger.info("[setup_writing] proposed writing config submitted (intercepted by approval middleware)") return Command( update={ "messages": [ ToolMessage( - content="写作配置已生成,请在右侧确认后应用到表单。", + content="写作配置已生成,请在右侧确认后应用到表单,或直接开始写作。", tool_call_id=runtime.tool_call_id, ) ]