feat(ai写作): 自带大纲/会商及研究模板等前后端改动

提交工作区中 AI 写作(表单/时间线/历史/上下文/流式 hook/类型)、
研究模板配置页、lead_agent、researcher/graph/state 及 gateway/config
的进行中改动。

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 16:15:39 +08:00
1 parent d2c76100f2
commit e9c8d1cea2
18 files changed
+287 -50

No files matched your search

@@ -3,6 +3,7 @@ import { PenLine, SlidersHorizontal } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import { cn } from '@/lib/utils';
import {
Select,
SelectContent,
@@ -67,6 +68,7 @@ export function AIWritingForm({
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
skipResearch, setSkipResearch,
// 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext,
// 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。
selectedNotebookId, setSelectedNotebookId,
@@ -164,13 +166,16 @@ export function AIWritingForm({
: audience.trim() || DEFAULT_AUDIENCE,
wordCountTarget: wordCount,
keywordCount: DEFAULT_KEYWORD_COUNT,
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
// 跳过素材收集时强制 loose(无素材可严格依据,后端也会强制)
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
materialSource: materialSource,
skipResearch,
// 跳过素材收集时检索类型无意义,归一为 general
materialSource: skipResearch ? 'general' : materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"才传笔记参数
noteSearchSpaceId: materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
noteSearchSpaceId: !skipResearch && materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -278,6 +283,25 @@ export function AIWritingForm({
</div>
</div>
{/* 不收集素材,直接用通用知识写作 */}
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<input
type="checkbox"
id="skip-research"
checked={skipResearch}
onChange={(e) => setSkipResearch(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="skip-research" className="cursor-pointer select-none">
<span className="text-sm font-medium text-foreground/90">不收集素材,直接用通用知识写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后跳过素材检索阶段(不弹素材确认),AI 直接用通用知识规划大纲并写作;检索类型与「严格依据素材」将被忽略。
</p>
</label>
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
@@ -354,6 +378,7 @@ export function AIWritingForm({
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label>
@@ -416,15 +441,17 @@ export function AIWritingForm({
<input
type="checkbox"
id="strict-mode"
checked={strictMode}
checked={strictMode && !skipResearch}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
disabled={disabled || skipResearch}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<label htmlFor="strict-mode" className="cursor-pointer select-none">
<label htmlFor="strict-mode" className={cn('select-none', skipResearch ? 'cursor-not-allowed opacity-50' : 'cursor-pointer')}>
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
{skipResearch
? '已选「不收集素材」,本项不适用:将直接用通用知识写作。'
: '勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。'}
</p>
</label>
</div>
@@ -26,6 +26,7 @@ function normalizeCompletedIntervention(rec: Record<string, unknown>): Completed
if (rr) out.reviewResult = normalizeReviewResult(rr);
if (out.draftTitle == null && rec.draft_title != null) out.draftTitle = rec.draft_title;
if (out.draftWordCount == null && rec.draft_word_count != null) out.draftWordCount = rec.draft_word_count;
if (out.userInput == null && rec.user_input != null) out.userInput = rec.user_input;
return out as unknown as CompletedIntervention;
}
@@ -1,6 +1,7 @@
import type { CompletedIntervention, ProgressEvent } from '../../types/ai-writing';
import { TimelineStep } from './TimelineStep';
import { CompletedInterventionCard } from './CompletedInterventionCard';
import { UserRequirementCard } from './UserRequirementCard';
interface AIWritingTimelineProps {
events: ProgressEvent[];
@@ -48,8 +49,13 @@ export function AIWritingTimeline({
awaitUserIdx += 1;
if (completedRecord) {
// 已完成的干预点 → 渲染折叠历史卡
slot = <CompletedInterventionCard record={completedRecord} />;
// 已完成的干预点 → 先以独立样式回显用户的输入要求,再渲染折叠历史卡
slot = (
<div className="space-y-2">
<UserRequirementCard record={completedRecord} />
<CompletedInterventionCard record={completedRecord} />
</div>
);
} else if (currentPause && isLast) {
// 当前活跃干预点 → 渲染操作卡
slot = currentInterventionSlot;
@@ -0,0 +1,50 @@
import { UserRound } from 'lucide-react';
import type { CompletedIntervention } from '../../types/ai-writing';
/**
* 时间线里「用户输入交互的要求」独立样式卡 —— 与 AI 步骤(灰)、完成卡(绿)、
* 等待卡(黄)在视觉上明确区分:靛蓝主色 + 用户图标 + 顶部要求类型标签。
*
* 触发场景(record.userInput 非空时):
* 素材确认 → 按用户要求补充检索内容
* 大纲确认 → 用户提出大纲修改意见
* 草稿确认 → 用户提出写作修改意见
* 编辑意见处理 → 用户在编辑审核时提出处理意见
*/
function requirementLabel(record: CompletedIntervention): string {
switch (record.pausePoint) {
case 'material_confirm':
return '补充检索内容';
case 'outline_confirm':
return '修改大纲意见';
case 'draft_confirm':
return '修改写作意见';
case 'review_confirm':
return '编辑审核意见';
default:
return '我的要求';
}
}
interface Props {
record: CompletedIntervention;
}
export function UserRequirementCard({ record }: Props) {
if (!record.userInput) return null;
return (
<div className="mt-2 rounded-md border border-indigo-200 bg-indigo-50/60 dark:border-indigo-900/40 dark:bg-indigo-950/20 px-3 py-2">
<div className="flex items-center gap-1.5">
<UserRound className="size-3.5 text-indigo-500 shrink-0" />
<span className="text-[11px] font-semibold text-indigo-600 dark:text-indigo-400">
我的要求 · {requirementLabel(record)}
</span>
</div>
<p className="mt-1 pl-5 text-xs leading-relaxed text-foreground/90 whitespace-pre-wrap break-words">
{record.userInput}
</p>
</div>
);
}
@@ -15,6 +15,8 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Zap } from 'lucide-react';
import { ArtifactsProvider } from '@/components/workspace/artifacts';
import {
MessageList,
@@ -59,6 +61,8 @@ export function WritingSetupChat({
const threadId = useMemo(() => uuid(), []);
const [threadPrepared, setThreadPrepared] = useState(false);
const [chatModel, setChatModel] = useState('');
// 快捷模式:开启后跳过逐项选项澄清(文章类型/字数/读者用默认),给标题直接生成大纲。
const [quickMode, setQuickMode] = useState(false);
const { models, isLoading: modelsLoading } = useModels();
const { articleTypes } = useArticleTypes();
// 缺省回填写作表单;onApprove 传入时脱离 WritingFormProvider(如模板创建页)。
@@ -85,6 +89,7 @@ export function WritingSetupChat({
context: {
mode: 'flash',
is_writing_setup: true,
writing_setup_quick: quickMode,
valid_article_types: validArticleTypes,
model_name: chatModel || undefined,
},
@@ -133,7 +138,8 @@ export function WritingSetupChat({
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 skipResearch = formCtx?.skipResearch ?? false;
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
const request: AIWritingRequest = {
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
@@ -145,9 +151,11 @@ export function WritingSetupChat({
targetAudience: payload.target_audience?.trim() || formCtx?.audience?.trim() || '普通读者',
wordCountTarget: wordCount,
keywordCount: 1,
writingMode: payload.writing_mode === 'strict' ? 'strict' : 'loose',
// 跳过素材收集时强制 loose
writingMode: skipResearch ? 'loose' : payload.writing_mode === 'strict' ? 'strict' : 'loose',
// 跟随表单的「添加参考文献」开关(默认开)。
addReferences: formCtx?.addReferences ?? true,
skipResearch,
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
@@ -258,9 +266,25 @@ export function WritingSetupChat({
</Select>
)}
</div>
<span className="hidden shrink-0 text-[11px] text-muted-foreground/70 sm:block">
输入想法,按 Enter 直接解析填充
</span>
{/* 快捷模式开关:放在模型选择右侧。开启后跳过逐项选项确认,给标题直接出大纲。 */}
<label
className={cn(
'flex shrink-0 cursor-pointer select-none items-center gap-1.5 rounded-md border px-2 py-1 text-xs transition',
quickMode
? 'border-primary/40 bg-primary/5 text-primary dark:border-[#1B477E] dark:bg-[#022248]'
: 'border-input bg-background text-muted-foreground dark:border-[#1B477E] dark:bg-[#022248]',
)}
title="开启后跳过逐项选项确认(文章类型/字数/读者用默认),给出标题即直接生成大纲"
>
<Zap className={cn('size-3.5 shrink-0', quickMode && 'fill-current')} />
<span className="shrink-0">快捷模式</span>
<Switch
checked={quickMode}
onCheckedChange={setQuickMode}
disabled={thread.isLoading}
className="ml-0.5 scale-90"
/>
</label>
</div>
<PromptInput onSubmit={({ text }) => void handleChatSubmit(text)}>
<PromptInputTextarea
@@ -289,6 +289,8 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
// 是否加参考文献(正文 [n] + 文末「参考文献」列表);缺省 false 不改其它入口行为。
add_references: request.addReferences ?? false,
material_source: request.materialSource,
// 不收集素材,直接用通用知识写作:researcher 节点据此短路跳过检索
skip_research: request.skipResearch ?? false,
notebook_search_space_id: request.noteSearchSpaceId ?? null,
notebook_source_ids: request.noteSourceIds ?? null,
revision_count: 0,
@@ -328,6 +330,17 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
pausePoint: pausePoint as CompletedIntervention['pausePoint'],
action: payload.action,
};
// 捕获用户在该干预点输入的自然语言要求,供时间线以「我的要求」独立样式回显:
// 素材确认 → 补充检索内容(userQuery) / 大纲确认 → 修改意见(outlineFeedback) /
// 草稿确认 + 编辑意见处理 → 修改·审核意见(userRevisionNotes)
const userInput = (
payload.userQuery ??
payload.outlineFeedback ??
payload.userRevisionNotes ??
''
).trim();
if (userInput) record.userInput = userInput;
if (pausePoint === 'material_confirm') record.materials = state.materials;
else if (pausePoint === 'outline_confirm') record.outline = state.outline ?? undefined;
else if (pausePoint === 'draft_confirm') {
@@ -39,6 +39,9 @@ export interface WritingFormValue {
setAddReferences: (v: boolean) => void;
materialSource: 'general' | 'notebook';
setMaterialSource: (v: 'general' | 'notebook') => void;
/** 不收集素材,直接用通用知识写作:勾选后跳过素材检索阶段(含素材确认暂停点)。默认关。 */
skipResearch: boolean;
setSkipResearch: (v: boolean) => void;
/** 我的空间(笔记本)来源:选中的 search_space_id;上提到 Context 以便左栏「笔记本」点击直接设为素材来源。 */
selectedNotebookId: number | null;
setSelectedNotebookId: (v: number | null) => void;
@@ -86,6 +89,7 @@ export function WritingFormProvider({
const [strictMode, setStrictMode] = useState(false);
const [addReferences, setAddReferences] = useState(true);
const [materialSource, setMaterialSource] = useState<'general' | 'notebook'>(initialMaterialSource ?? 'general');
const [skipResearch, setSkipResearch] = useState(false);
const [selectedNotebookId, setSelectedNotebookId] = useState<number | null>(initialNotebookId ?? null);
const [selectedDocumentIds, setSelectedDocumentIds] = useState<number[]>(initialDocumentIds ?? []);
const [modelName, setModelName] = useState('');
@@ -123,12 +127,13 @@ export function WritingFormProvider({
strictMode, setStrictMode,
addReferences, setAddReferences,
materialSource, setMaterialSource,
skipResearch, setSkipResearch,
selectedNotebookId, setSelectedNotebookId,
selectedDocumentIds, setSelectedDocumentIds,
modelName, setModelName,
applyProposal,
}),
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
);
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
@@ -705,6 +705,8 @@ export type AIWritingValues = {
article_type_detail?: string;
/** 是否加参考文献(正文 [n] 角标 + 文末「参考文献」列表)。 */
add_references?: boolean;
/** 不收集素材,直接用通用知识写作(researcher 节点据此短路跳过检索)。 */
skip_research?: boolean;
status?: string;
material_package?: unknown;
current_outline?: unknown;
@@ -143,7 +143,7 @@ export default function ResearchTemplateEditorPage() {
const payload = toPayload(form);
if (editing && id) await update(id, payload);
else await create(payload);
navigate(LIST_PATH);
navigate(LIST_PATH, { replace: true });
} catch (e) {
setFormError(e instanceof Error ? e.message : '保存失败');
setSaving(false);
@@ -154,7 +154,7 @@ export default function ResearchTemplateEditorPage() {
<div className="flex h-full flex-col bg-background">
<header className="flex shrink-0 items-center justify-between border-b border-border px-5 py-3">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(LIST_PATH)}>
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<ArrowLeft className="size-4" />
</Button>
<div>
@@ -168,7 +168,7 @@ export default function ResearchTemplateEditorPage() {
</div>
<div className="flex items-center gap-2">
{formError && <span className="text-sm text-destructive">{formError}</span>}
<Button variant="outline" size="sm" onClick={() => navigate(LIST_PATH)} disabled={saving}>
<Button variant="outline" size="sm" onClick={() => navigate(-1)} disabled={saving}>
取消
</Button>
<Button size="sm" onClick={() => void handleSave()} disabled={saving}>
@@ -14,6 +14,7 @@ import {
import { useResearchTemplates } from '../hooks/useResearchTemplates';
import { useArticleTypes } from '../hooks/useArticleTypes';
const AI_WRITING_PATH = '/page/canvas/ai-writing';
const NEW_PATH = '/page/canvas/research-templates/new';
const editPath = (id: string) => `/page/canvas/research-templates/edit/${id}`;
@@ -48,7 +49,11 @@ export default function ResearchTemplatesConfigPage() {
<div className="mx-auto h-full w-full max-w-4xl overflow-y-auto p-6">
<div className="mb-5 flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="ghost" size="icon-sm" onClick={() => navigate(-1)}>
<Button
variant="ghost"
size="icon-sm"
onClick={() => navigate(AI_WRITING_PATH, { replace: true })}
>
<ArrowLeft className="size-4" />
</Button>
<div>
@@ -267,6 +267,9 @@ export interface AIWritingRequest {
addReferences?: boolean;
/** 检索类型:general=通用检索(素材收集专家按配置技能检索,默认知识库检索技能)/ notebook=我的空间 */
materialSource: "general" | "notebook";
/** 不收集素材,直接用通用知识写作:勾选后跳过整个素材检索流程(含素材确认暂停点),
* 直接进入大纲规划;后端强制 loose 写作模式。默认 false。 */
skipResearch?: boolean;
/** 笔记来源(可选):空间 search_space_id */
noteSearchSpaceId?: number;
/** 笔记来源(可选):空间内指定文档的 document_id 列表,空数组表示检索整个空间 */
@@ -316,6 +319,10 @@ export interface CompletedIntervention {
| "review_confirm"
| "section_help";
action: string;
/** 用户在该干预点输入的自然语言要求(补充检索内容 / 大纲修改意见 / 写作修改意见 /
* 编辑审核处理意见)。仅当用户实际输入了文字时存在,用于在时间线里以「我的要求」
* 独立样式回显——区别于 AI 步骤与绿色完成卡。 */
userInput?: string;
// material_confirm
materials?: AIMaterial[];
// outline_confirm
@@ -239,6 +239,7 @@ _CONTEXT_CONFIGURABLE_KEYS: frozenset[str] = frozenset(
"is_bootstrap",
"is_writing_setup",
"valid_article_types",
"writing_setup_quick",
"excluded_tools",
"skill_stop_names",
"skill_stop_message",
+12 -12
View File
@@ -10,18 +10,18 @@ gateway:
port: 8001
models:
# - name: deepseek-chat
# display_name: DeepSeek Chat
# use: langchain_openai:ChatOpenAI
# model: deepseek-chat
# api_key: sk-35345154983047aba9e12faa86c5f62c
# base_url: https://api.deepseek.com
# request_timeout: 600.0
# max_retries: 2
# max_tokens: 4096
# temperature: 1.0
# supports_vision: false
# supports_thinking: true
- name: deepseek-chat
display_name: DeepSeek Chat
use: langchain_openai:ChatOpenAI
model: deepseek-chat
api_key: sk-686b7bc000be4b7cbed6dccb3897c411
base_url: https://api.deepseek.com
request_timeout: 600.0
max_retries: 2
max_tokens: 4096
temperature: 1.0
supports_vision: false
supports_thinking: true
- name: zai-org/GLM-5-FP8
display_name: zai-org/GLM-5-FP8
@@ -62,6 +62,17 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
filtered = [m for m in pkg["materials"] if m["id"] in approved_ids]
updated_pkg = {**pkg, "materials": filtered}
# 用户自带大纲:researcher 已在「搜集素材」阶段解析大纲并归类素材 → 跳过大纲生成步,
# 仅把被用户取消勾选的素材从各章节 material_ids 里剔除,直接进入大纲确认。
prebuilt = state.get("current_outline")
if prebuilt and (state.get("user_outline_raw") or "").strip():
return {
"material_package": updated_pkg,
"current_outline": _prune_outline_material_ids(prebuilt, approved_ids),
"last_user_intervention": user_input,
"status": "awaiting_outline_confirm",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": "已采用你提供的大纲并归类素材,请确认大纲"}],
}
return {
"material_package": updated_pkg,
"last_user_intervention": user_input,
@@ -90,6 +101,21 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
}
def _prune_outline_material_ids(outline: dict, approved_ids: set) -> dict:
"""把大纲各章节 material_ids 里、用户在素材确认卡取消勾选的素材编号剔除掉。
用户自带大纲时素材归类发生在「搜集素材」阶段(researcher),早于素材确认;
用户在确认卡里去掉某些素材后,用这个函数把对应编号从大纲剔除,保持一致。
"""
if not outline:
return outline
sections = []
for s in outline.get("sections", []):
kept = [mid for mid in (s.get("material_ids") or []) if mid in approved_ids]
sections.append({**s, "material_ids": kept})
return {**outline, "sections": sections}
def _normalize_outline(outline: dict) -> dict:
"""将前端回传的 camelCase 大纲统一为后端 snake_case 格式。"""
if not outline:
@@ -318,7 +344,13 @@ def after_researcher(state: AIWritingState) -> str:
def after_material_pause(state: AIWritingState) -> str:
return "researcher" if state["status"] == "researching" else "writer_outline"
status = state["status"]
if status == "researching":
return "researcher"
# 用户自带大纲:researcher 已在搜集阶段整理好大纲 → 跳过 writer_outline 生成步,直接确认大纲。
if status == "awaiting_outline_confirm":
return "pause_outline"
return "writer_outline"
def after_outline_pause(state: AIWritingState) -> str:
@@ -396,9 +428,9 @@ def make_ai_writing_graph(config=None):
g.set_entry_point("intent_parser")
g.add_edge("intent_parser", "researcher")
g.add_conditional_edges("researcher", after_researcher,
{"pause_material": "pause_material", "writer_draft": "writer_draft"})
{"pause_material": "pause_material", "writer_draft": "writer_draft", "writer_outline": "writer_outline"})
g.add_conditional_edges("pause_material", after_material_pause,
{"researcher": "researcher", "writer_outline": "writer_outline"})
{"researcher": "researcher", "writer_outline": "writer_outline", "pause_outline": "pause_outline"})
g.add_edge("writer_outline", "pause_outline")
g.add_conditional_edges("pause_outline", after_outline_pause,
{"writer_outline": "writer_outline", "writer_draft": "writer_draft"})
@@ -399,6 +399,22 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
def _emit(message: str) -> None:
writer({"type": "research_progress", "message": message})
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」。
# 仅在**首次**进入(无任何 pause 干预 = intervention 为空)时短路,避免后续
# 「补充检索 / 按输入追加」等再入研究节点时被误跳过。直接产出空素材包、强制
# loose 写作模式(无素材可严格依据),并跳过素材确认暂停点直奔大纲规划。
if state.get("skip_research") and not intervention:
empty_pkg = MaterialPackage(keywords=[], materials=[], summary="用户选择不收集素材,直接用通用知识写作")
return {
"material_package": empty_pkg,
"writing_mode": "loose",
"status": "writing_outline",
"progress_events": [
{"type": "step_start", "agent_name": researcher_label, "message": "已跳过素材收集,直接用通用知识写作"},
{"type": "materials_ready", "agent_name": researcher_label, "message": "未收集素材(通用知识写作)"},
],
}
# Step 1: 提取关键词
kw_result: dict = {} # 走"用户输入 / 追加"分支时跳过 LLM 调用,notice 拼接需要兜底值
if is_user_query_search:
@@ -856,13 +872,36 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
step_start_msg = f"通用检索(技能编排 {len(skill_steps)} 步):{', '.join(keywords[:3])}"
else:
step_start_msg = f"搜索关键词:{', '.join(keywords[:3])}"
progress_events: list[dict] = [
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
]
extra_updates: dict = {}
# 用户自带大纲:在「搜集素材」阶段顺便解析大纲并把素材归类到各章节,
# 从而跳过 writer_outline 的大纲生成步(见 graph after_material_pause 路由)。
# 补充检索(is_supplement)直接回写作、沿用原大纲,不在此整理。
if not is_supplement and (state.get("user_outline_raw") or "").strip():
from .writer_outline import build_user_outline_with_materials
try:
organized = await build_user_outline_with_materials(state, config, pkg)
except Exception:
logger.warning("researcher: 整理用户大纲失败(非致命),回退到大纲生成步", exc_info=True)
organized = None
if organized:
extra_updates["current_outline"] = organized
progress_events.append({
"type": "outline_ready",
"agent_name": researcher_label,
"message": f"已按你提供的大纲整理素材,共 {len(organized.get('sections', []))} 个章节",
})
return {
"material_package": pkg,
# 补充检索:直接回写作(沿用原大纲);常规检索:进入素材确认暂停点
"status": "writing_draft" if is_supplement else "awaiting_material_confirm",
"progress_events": [
{"type": "step_start", "agent_name": researcher_label, "message": step_start_msg},
*notices,
{"type": "materials_ready", "agent_name": researcher_label, "message": summary},
],
**extra_updates,
"progress_events": progress_events,
}
@@ -144,6 +144,10 @@ class AIWritingState(TypedDict):
# notebook=我的空间(仅笔记检索)。旧值 knowledge_base / skill 由 researcher
# 节点按 general 兼容处理。
material_source: str
# 跳过素材收集:用户在初始表单勾选「不收集素材,直接用通用知识写作」时为 True。
# researcher 节点在**首次**进入(无任何 pause 干预)时短路,跳过整个检索流程直接
# 去大纲规划;强制 loose 写作模式(没有素材可严格依据)。
skip_research: bool
# 用户在写作表单选定的主模型。大纲/写作/审核严格使用它(不回退默认)。
# 由 intent_parser 在首次 invocation 从 configurable.model_name 捕获进 state,
# 经 checkpoint 持久化 —— interrupt/resume 是新 invocation、其 config 不带原
@@ -437,6 +437,8 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
# 走写作配置专用 prompt,只负责帮用户把写作表单聊清并提交,不真正开始写作。
is_writing_setup = cfg.get("is_writing_setup", False)
writing_setup_article_types = cfg.get("valid_article_types") if is_writing_setup else None
# 快捷模式:跳过逐项选项澄清,给标题直接出大纲(见 build_writing_setup_prompt)。
writing_setup_quick = bool(cfg.get("writing_setup_quick", False)) if is_writing_setup else False
memory_injection_enabled = bool(cfg.get("memory_injection_enabled", True))
rag_mode_enabled = bool(cfg.get("rag_mode_enabled", False))
writing_mode = bool(cfg.get("writing_mode", False))
@@ -522,7 +524,7 @@ def _make_lead_agent(config: RunnableConfig, *, app_config: AppConfig):
*_build_middlewares(config, model_name=model_name, app_config=resolved_app_config),
WritingSetupRoundCapMiddleware(max_rounds=WRITING_SETUP_MAX_ROUNDS),
],
system_prompt=build_writing_setup_prompt(writing_setup_article_types),
system_prompt=build_writing_setup_prompt(writing_setup_article_types, quick_mode=writing_setup_quick),
state_schema=ThreadState,
)
@@ -1116,13 +1116,17 @@ def _build_bootstrap_section() -> str:
"""
def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
def build_writing_setup_prompt(article_types: list[dict] | None = None, *, quick_mode: bool = False) -> str:
"""写作配置 Q&A 的**独立**系统提示(不复用 lead-agent 超级体提示)。
关键:写作配置助手绝不能像 lead agent 那样「自己把任务做掉」——用户输入常常长得像一个
可执行任务(如「新能源汽车市场分析」),若复用 lead 提示 + 全套工具,模型会直接去研究/写
分析。这里用一份职责单一、口吻强硬的独立提示,配合「只挂 setup_writing 一个工具」,把模型
死死框在「聊清需求 → 调 setup_writing」上。
``quick_mode``(快捷模式):跳过「逐项选项澄清」——文章类型/字数/读者等一律取合理默认,
**不调用 ``ask_clarification``**,用户一给出标题/主题就**直接生成大纲**;大纲仍交用户确认,
确认后再调 ``setup_writing`` 提交。供前端「快捷模式」开关使用。
"""
if article_types:
type_lines = "\n".join(
@@ -1141,18 +1145,33 @@ def build_writing_setup_prompt(article_types: list[dict] | None = None) -> str:
"`article_type_detail` 里用一句话说明其写作侧重;不确定可留空,交给用户在表单里选。"
)
if quick_mode:
workflow_block = """【工作流程——快捷模式】
1. **不澄清、用默认**:**禁止**调用 `ask_clarification`。文章类型、目标字数、目标读者等一律自行取**合理默认**(字数默认 800,读者默认「普通读者」,文章类型按意图判断),**绝不**就这些选项反问用户。
2. **拿到标题/主题就直接出大纲**:用户一给出标题或写作主题,**立刻**以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与默认字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
else:
workflow_block = """【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:"""
quick_law = (
"\n- **快捷模式**:禁止调用 `ask_clarification` 反问选项;缺失项一律取合理默认,用户给出标题/主题后**立刻生成大纲**。"
if quick_mode
else ""
)
return f"""你是「AI 写作」功能的**配置助手**。你的职责分两段:① 先与用户把写作需求聊清楚,**先生成并与用户确认一份大纲**;② 待用户对大纲满意后,再调用 `setup_writing` 工具提交写作配置(含已确认的大纲),交用户开始写作。
【铁律——最高优先级】
- 你**绝对不撰写**文章正文、分析内容或任何成稿文字。即使用户的输入看起来就是一个写作任务(例如「新能源汽车市场分析」「帮我写一篇……」),你也**只产出「大纲」(仅结构与标题)与「写作配置」**,绝不替用户动笔、绝不直接完成这篇写作。
- 你只有两个工具:① `ask_clarification`(向用户提问澄清,需要时用);② `setup_writing`(提交写作配置)。**没有任何其它工具**,不要做检索、不要做研究、不要假装调用别的工具。
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。
- `setup_writing` **只能在用户明确确认大纲之后**才调用;在那之前绝不调用它。{quick_law}
【工作流程】
1. **聊清需求**:读用户输入,当关键项(文章类型、目标字数、目标读者)缺失或模糊时,调用 `ask_clarification` 提问——**优先给出 `options` 选项**让用户点选(如目标字数「800 / 1500 / 3000」、读者「普通读者 / 行业专家 / 政策决策者」)。澄清**最多 2 次**,信息够了就进入下一步,不要无休止追问。
2. **生成大纲(先于确认意图)**:信息足够后,直接以**普通回复文本**输出一份**多级层级**的中文大纲——用 Markdown 标题层级(`#` 一级、`##` 二级,必要时 `###` 三级),每节可用**一句话**点明这一节要写什么,但**绝不写正文**;大纲规模与目标字数匹配。**在回复的最后一句**明确请用户确认,例如:「以上是建议大纲,你看是否需要调整?确认后我就整理写作配置、开始写作。」这一步**不要调用任何工具**,输出完大纲就结束本轮、等待用户回应。
3. **按反馈灵活改大纲**:用户要求调整(增删章节、改层级、换角度等)就**重新输出修订后的完整大纲**,并再次在最后一句请其确认;如此反复,直到用户表示满意/确认。用户若只是补充信息也照常更新大纲再确认。
4. **提交配置**:用户确认大纲后,调用 `setup_writing` 提交:
{workflow_block}
- `user_intent`:精炼成一句话的写作目标(必填)。
- `article_type`:见下方【文章类型】——优先选已有类型的 key;都不贴切才自拟一个新的中文类型名。
- `article_type_detail`:**仅当 article_type 是新类型时**,用一句话说明该类型的写作侧重/特征;命中已有类型则留空。