feat: refine ai writing search flow

This commit is contained in:
董宏凯 committed 2026-07-01 18:00:14 +08:00
1 parent 025e43f4c5
commit d21dbeb214
15 files changed
+260 -113

No files matched your search

@@ -47,8 +47,8 @@ const MAX_INTENT_LENGTH = 500;
const MAX_SAMPLE_TEXT_LENGTH = 120000;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口,不走技能编排' },
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库检索接口' },
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
];
@@ -162,7 +162,7 @@ export function AIWritingForm({
function handleMaterialSourceChange(val: MaterialSource) {
setMaterialSource(val);
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空
// studioToken 模式下空间和文档已锁定,切换素材来源时不清空。
if (val !== 'notebook' && !studioToken) {
setSelectedNotebookId(null);
setSelectedDocumentIds([]);
@@ -222,12 +222,10 @@ export function AIWritingForm({
writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
addReferences,
skipResearch,
// 跳过素材收集时检索类型无意义,归一为 general
materialSource: skipResearch ? 'general' : materialSource,
modelName: selectedModel?.name || modelName || undefined,
// 只有选了"我的空间"且未跳过素材收集才传笔记参数
noteSearchSpaceId: !skipResearch && materialSource === 'notebook' ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && materialSource === 'notebook' && selectedNotebookId != null ? selectedDocumentIds : undefined,
noteSearchSpaceId: !skipResearch ? (selectedNotebookId ?? undefined) : undefined,
noteSourceIds: !skipResearch && selectedNotebookId != null ? selectedDocumentIds : undefined,
});
}
@@ -454,82 +452,80 @@ export function AIWritingForm({
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索类型</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索类型" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索方式</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索方式" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{/* 选了"我的空间"才展示空间 / 文档选择器 */}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{/* studioToken 模式:空间已锁定,不展示选择器 */}
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
@@ -605,6 +605,8 @@ export function AIWritingPanel({
wordCountTarget={state.request?.wordCountTarget}
revisionCount={state.revisionCount}
skillSearchSteps={state.skillSearchSteps}
materialSource={state.request?.materialSource ?? 'general'}
canUseNotebookSearch={state.request?.noteSearchSpaceId != null}
skillSearchInProgress={state.status === 'researching' && state.skillSearchSteps.length > 0}
onSubmit={submitIntervention}
disabled={false}
@@ -5,6 +5,13 @@ import {
Trash2, BookOpen,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { cn } from '@/lib/utils';
import type {
AIMaterial, AIOutline, ReviewResult, ReviewIssue,
@@ -30,6 +37,8 @@ interface InterventionCardProps {
wordCountTarget?: number;
revisionCount: number;
skillSearchSteps?: SkillSearchStep[];
materialSource?: MaterialSource;
canUseNotebookSearch?: boolean;
/** 是否正在检索中(researching/awaiting 切换之间,前端用于流式展示步骤进行中) */
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
@@ -38,7 +47,8 @@ interface InterventionCardProps {
export function InterventionCard({
pauseEvent, materials, outline, reviewResult,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps, skillSearchInProgress, onSubmit, disabled,
draftTitle, draftWordCount, wordCountTarget, revisionCount, skillSearchSteps,
materialSource, canUseNotebookSearch, skillSearchInProgress, onSubmit, disabled,
}: InterventionCardProps) {
const pausePoint = pauseEvent.pausePoint;
@@ -47,6 +57,8 @@ export function InterventionCard({
<MaterialConfirmCard
materials={materials}
skillSearchSteps={skillSearchSteps ?? []}
initialMaterialSource={materialSource ?? 'general'}
canUseNotebookSearch={canUseNotebookSearch}
skillSearchInProgress={skillSearchInProgress}
onSubmit={onSubmit}
disabled={disabled}
@@ -126,17 +138,73 @@ function formatSelectedIssues(issues: ReviewIssue[], selectedIndices: Set<number
interface MaterialConfirmCardProps {
materials: AIMaterial[];
skillSearchSteps: SkillSearchStep[];
initialMaterialSource: MaterialSource;
canUseNotebookSearch?: boolean;
skillSearchInProgress?: boolean;
onSubmit: (payload: UserInterventionPayload) => void;
disabled?: boolean;
}
function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgress, onSubmit, disabled }: MaterialConfirmCardProps) {
type MaterialSource = NonNullable<UserInterventionPayload['materialSource']>;
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
{ value: 'general', label: '通用检索', desc: '素材收集专家按配置技能检索' },
{ value: 'knowledge_base', label: '知识库检索', desc: '直连内网知识库接口' },
{ value: 'notebook', label: '我的空间', desc: '检索当前笔记空间' },
];
function MaterialSourceSwitcher({
value,
onChange,
options,
disabled,
}: {
value: MaterialSource;
onChange: (value: MaterialSource) => void;
options: Array<{ value: MaterialSource; label: string; desc: string }>;
disabled?: boolean;
}) {
const current = options.find((opt) => opt.value === value) ?? options[0];
return (
<div className="flex flex-wrap items-center gap-2 rounded-md border border-input bg-muted/30 px-2.5 py-2">
<span className="text-xs font-medium text-muted-foreground">检索方式</span>
<Select value={current.value} onValueChange={(v) => onChange(v as MaterialSource)} disabled={disabled}>
<SelectTrigger className="h-7 w-[9rem] bg-background px-2 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="min-w-0 flex-1 text-xs text-muted-foreground">
{current.desc}
</span>
</div>
);
}
function MaterialConfirmCard({
materials,
skillSearchSteps,
initialMaterialSource,
canUseNotebookSearch,
skillSearchInProgress,
onSubmit,
disabled,
}: MaterialConfirmCardProps) {
const [selectedIds, setSelectedIds] = useState<Set<string>>(
() => new Set(materials.map((m) => m.id)),
);
const [userQuery, setUserQuery] = useState('');
const [materialSource, setMaterialSource] = useState<MaterialSource>(
initialMaterialSource === 'notebook' && !canUseNotebookSearch ? 'general' : initialMaterialSource,
);
const [submitting, setSubmitting] = useState(false);
const sourceOptions = MATERIAL_SOURCE_OPTIONS.filter((opt) => opt.value !== 'notebook' || canUseNotebookSearch);
async function handleConfirm() {
setSubmitting(true);
@@ -148,7 +216,12 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
setSubmitting(true);
// 带输入 = 后端在素材收集专家配置的技能中匹配检索、新素材追加到列表;
// 无输入 = 重新生成检索词、覆盖式重检
await onSubmit({ action: 're_search', approvedMaterialIds: [], userQuery: userQuery.trim() || undefined });
await onSubmit({
action: 're_search',
approvedMaterialIds: [],
userQuery: userQuery.trim() || undefined,
materialSource,
});
setSubmitting(false);
}
@@ -163,7 +236,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<div>
<p className="text-xs font-medium">未检索到任何素材</p>
<p className="text-xs text-muted-foreground mt-0.5">
素材收集专家本次没有检索到相关数据。你可以在输入框描述想检索的内容(自动匹配已配置的技能),或在无素材的情况下直接继续写作。
素材收集专家本次没有检索到相关数据。你可以切换检索方式并描述想检索的内容,或在无素材的情况下直接继续写作。
</p>
</div>
</div>
@@ -172,6 +245,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<SkillStepsCard steps={skillSearchSteps} inProgress={skillSearchInProgress} />
)}
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -211,7 +291,7 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
<p className="text-xs font-medium">需要您确认素材,才能继续</p>
<p className="text-xs text-muted-foreground mt-0.5">
已搜集到 {materials.length} 条素材(默认全选)。您可以取消勾选不相关的素材,
或在下方描述想补充检索的内容——系统会匹配素材收集专家配置的技能检索,并把新素材追加进列表。
或在下方切换检索方式并描述想补充检索的内容,系统会把新素材追加进列表。
</p>
</div>
</div>
@@ -226,6 +306,13 @@ function MaterialConfirmCard({ materials, skillSearchSteps, skillSearchInProgres
});
}} />
<MaterialSourceSwitcher
value={materialSource}
onChange={setMaterialSource}
options={sourceOptions}
disabled={disabled || submitting}
/>
<MaterialsToolbar
materials={materials}
selectedIds={selectedIds}
@@ -65,7 +65,7 @@ export function MaterialsToolbar({
{/* 单输入框 */}
<div className="space-y-1.5">
<label className="text-xs text-muted-foreground">
补充检索(可选)— 描述想检索的内容,自动匹配已配置的技能,新素材追加到列表
补充检索(可选)— 选择检索方式后描述想检索的内容,新素材追加到列表
</label>
<textarea
ref={textareaRef}
@@ -269,6 +269,7 @@ export function WritingSetupChat({
sampleFilename: writingModeType === 'imitate' ? formCtx?.sampleFilename || '粘贴文本' : undefined,
imitateStrength: writingModeType === 'imitate' ? formCtx?.imitateStrength ?? 'medium' : undefined,
preserveSampleStructure: writingModeType === 'imitate' ? formCtx?.preserveSampleStructure ?? true : undefined,
skipIntentRecognition: true,
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
@@ -283,9 +284,9 @@ export function WritingSetupChat({
materialSource,
modelName: chatModel || formCtx?.modelName || undefined,
noteSearchSpaceId:
materialSource === 'notebook' ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
!skipResearch ? (formCtx?.selectedNotebookId ?? undefined) : undefined,
noteSourceIds:
materialSource === 'notebook' && formCtx?.selectedNotebookId != null
!skipResearch && formCtx?.selectedNotebookId != null
? formCtx.selectedDocumentIds
: undefined,
};
@@ -426,16 +426,13 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
const stream = streamRef.current;
if (!stream) return;
try {
// 所见即所得:用户在表单选了什么 materialSource,前端就原样传过去。
// 两个合法值:general(通用检索,按素材收集专家配置的技能编排)/
// notebook(我的空间);旧值 knowledge_base / skill 后端按 general 兼容。
// 与 material_source 独立的 notebook_search_space_id / notebook_source_ids
// 控制是否真的去笔记后端拉素材(仅当用户选了"我的空间"模式时由表单填充)。
// 默认是 general(通用检索),但用户可以在开写前或素材确认后手动切换检索方式。
await stream.submit(
{
user_intent: request.userIntent,
// 用户自带大纲原文(留空则后端走自动规划)
user_outline_raw: request.userOutline?.trim() || '',
skip_intent_parser: request.skipIntentRecognition ?? false,
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
sample_text: request.sampleText?.trim() || '',
sample_filename: request.sampleFilename?.trim() || '',
@@ -547,6 +544,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
if (payload.approvedMaterialIds !== undefined) resumePayload.approvedMaterialIds = payload.approvedMaterialIds;
if (payload.extraKeywords !== undefined) resumePayload.extraKeywords = payload.extraKeywords;
if (payload.userQuery !== undefined) resumePayload.userQuery = payload.userQuery;
if (payload.materialSource !== undefined) resumePayload.materialSource = payload.materialSource;
if (payload.editedOutline !== undefined) resumePayload.editedOutline = payload.editedOutline;
if (payload.outlineFeedback !== undefined) resumePayload.outlineFeedback = payload.outlineFeedback;
if (payload.userRevisionNotes !== undefined) resumePayload.userRevisionNotes = payload.userRevisionNotes;
@@ -562,7 +560,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
seenInterruptRef.current = intr.id ?? JSON.stringify(intr.value);
}
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext });
dispatch({ type: 'intervention_submitted', record, nextStatus, pendingRevisionContext, materialSource: payload.materialSource });
try {
// resume 是新 invocation,其 config 默认不带最初的 model_name;而写作/审核节点
@@ -120,12 +120,6 @@ export function WritingFormProvider({
if (payload.target_audience?.trim()) setAudience(payload.target_audience.trim());
if (payload.writing_mode === 'strict') setStrictMode(true);
else if (payload.writing_mode === 'loose') setStrictMode(false);
// knowledge_base=知识库检索(直连内网 ES)保留;旧值 skill 归一为通用检索 general
if (payload.material_source === 'knowledge_base') {
setMaterialSource('knowledge_base');
} else if (payload.material_source === 'skill' || payload.material_source === 'general') {
setMaterialSource('general');
}
if (payload.outline?.trim()) {
setOutlineText(payload.outline);
setShowOutline(true);
@@ -85,7 +85,7 @@ export type Action =
| { type: 'handle_batch'; events: ProgressEvent[] }
| { type: 'start'; request: AIWritingRequest }
| { type: 'set_session'; sessionId: string }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext'] }
| { type: 'intervention_submitted'; record: CompletedIntervention; nextStatus: WritingStatus; pendingRevisionContext: AIWritingState['pendingRevisionContext']; materialSource?: AIWritingRequest['materialSource'] }
| { type: 'pause' }
| { type: 'resume_ui' }
// Restore history: rebuild state from the graph checkpoint values snapshot.
@@ -132,6 +132,9 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
...(ns === 'reviewing' ? { streamingReviewItems: [] } : {}),
// 进入「修改」:右侧画布恢复初始空占位(isRevising),不再显示旧稿/流式重写
...(ns === 'revising' ? { streamingDraftSections: [], streamingDraftTitle: '', isRevising: true } : {}),
...(action.materialSource && prev.request
? { request: { ...prev.request, materialSource: action.materialSource } }
: {}),
completedInterventions: [...prev.completedInterventions, action.record],
...(action.pendingRevisionContext ? { pendingRevisionContext: action.pendingRevisionContext } : {}),
};
@@ -534,7 +534,6 @@ export default function AIWritingPage({
<AIWritingHistoryProvider>
<AIWritingProvider>
<WritingFormProvider
initialMaterialSource={studioToken ?? initialNotebookId != null ? 'notebook' : undefined}
initialNotebookId={initialNotebookId}
initialDocumentIds={initialDocumentIds}
>
@@ -258,6 +258,8 @@ export interface UserInterventionPayload {
extraKeywords?: string[];
/** PAUSE-1 素材确认:用户单输入框写的自然语言(想补充检索的内容)。后端在素材收集专家配置的技能中匹配最相关的技能检索,新素材**追加**到列表。 */
userQuery?: string;
/** PAUSE-1 素材确认:本次补充/重新检索使用的检索方式。 */
materialSource?: "general" | "knowledge_base" | "notebook";
editedOutline?: AIOutline;
outlineFeedback?: string;
userRevisionNotes?: string;
@@ -281,6 +283,8 @@ export interface AIWritingRequest {
sampleFilename?: string;
imitateStrength?: "light" | "medium" | "strong";
preserveSampleStructure?: boolean;
/** 从初始化对话确认后进入正式写作时,意图字段已经结构化,可跳过后端二次识别。 */
skipIntentRecognition?: boolean;
articleType: string;
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
articleTypeDetail?: string;
@@ -58,6 +58,16 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
# 单输入框文本:可能是技能名 / 来源 / 检索意图,re_search 和 enable_skill_search 都用得到
user_query = (user_input.get("userQuery") or user_input.get("user_query") or "").strip()
requested_source = user_input.get("materialSource") or user_input.get("material_source")
fallback_source = state.get("material_source") or "general"
material_source = requested_source if requested_source in ("general", "knowledge_base", "notebook") else fallback_source
if material_source not in ("general", "knowledge_base", "notebook"):
material_source = "general"
source_label = {
"general": "通用检索",
"knowledge_base": "知识库检索",
"notebook": "我的空间",
}.get(material_source, "通用检索")
if action == "confirm":
approved_ids = set(user_input.get("approvedMaterialIds") or [m["id"] for m in pkg["materials"]])
@@ -84,9 +94,10 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
# enable_skill_search:旧版「启用技能检索」按钮的兼容入口——通用检索现在
# 本来就按 agent 配置技能编排,researcher 把它按 user_query 追加检索处理
if action == "enable_skill_search":
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新检索中…"
msg = f"按输入「{user_query[:30]}」使用{source_label}追加素材…" if user_query else f"使用{source_label}重新检索中…"
return {
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query},
"last_user_intervention": {**user_input, "action": "enable_skill_search", "user_query": user_query, "material_source": material_source},
"material_source": material_source,
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -94,9 +105,10 @@ async def pause_material_confirm(state: AIWritingState) -> dict:
# re_search:带 user_query = 在配置技能中匹配检索并**追加**素材;
# 不带输入 = 重新生成检索词、覆盖式重检(兼容旧版 extraKeywords 数组追加)
extra_kws = user_input.get("extraKeywords") or []
msg = f"按输入「{user_query[:30]}」匹配技能检索并追加素材…" if user_query else "重新搜索中..."
msg = f"按输入「{user_query[:30]}」使用{source_label}追加素材…" if user_query else f"使用{source_label}重新搜索中..."
return {
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query},
"last_user_intervention": {**user_input, "_extra_keywords": extra_kws, "user_query": user_query, "material_source": material_source},
"material_source": material_source,
"status": "researching",
"progress_events": [{"type": "resumed", "agent_name": "系统", "message": msg}],
}
@@ -204,6 +204,37 @@ async def intent_parser_node(state: AIWritingState, config: RunnableConfig) -> d
if not isinstance(preconfigured_wc, int) or preconfigured_wc <= 0:
preconfigured_wc = 800
if state.get("skip_intent_parser"):
writer = safe_get_stream_writer()
final_article_type, final_target_audience, final_word_count, final_refined_intent = _resolve_intent_fields(
state, fallback_result, fallback_result, valid_keys, default_key
)
writer({
"type": "intent_ready",
"article_type": final_article_type,
"target_audience": final_target_audience,
"word_count_target": final_word_count,
"refined_intent": final_refined_intent,
})
progress_events = [{
"type": "step_done",
"agent_name": "系统",
"message": "意图解析完成(已使用已确认配置)",
}]
updates: dict = {
"status": "researching",
"progress_events": progress_events,
"article_type": final_article_type,
"target_audience": final_target_audience,
"word_count_target": final_word_count,
}
persisted_model = (configurable.get("model_name") or "").strip()
if persisted_model:
updates["model_name"] = persisted_model
if final_refined_intent != state["user_intent"]:
updates["user_intent"] = final_refined_intent
return updates
user = _USER_TEMPLATE.format(
user_intent=state["user_intent"],
preconfigured_article_type=fallback_type,
@@ -428,8 +428,11 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 用户在写作表单选择的检索类型:general(通用检索,按 agent 配置技能编排)/
# knowledge_base(知识库检索,直连内网 ES 接口)/ notebook(我的空间)。
# 旧值 skill 一律按 general 兼容。
raw_source = (state.get("material_source") or "general").strip()
raw_source = (intervention.get("material_source") or state.get("material_source") or "general").strip()
material_source = raw_source if raw_source in ("notebook", "knowledge_base") else "general"
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
if material_source == "notebook" and not notebook_search_space_id:
material_source = "general"
# 用户指定的检索关键词数量(1~10),决定生成多少个检索词、检索多少个方向
keyword_count = _resolve_keyword_count(state.get("keyword_count"))
@@ -486,7 +489,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 用户输入分支:把整段自然语言作为唯一检索词,尊重原意,不再做关键词拆分。
# 若用户只说了「再追加 N 条素材」,effective_user_query 已回退为写作意图。
keywords: list[str] = [effective_user_query]
_emit(f"按你的输入「{effective_user_query[:30]}」匹配技能检索…")
if material_source == "knowledge_base":
_emit(f"按你的输入「{effective_user_query[:30]}」进行知识库检索…")
elif material_source == "notebook":
_emit(f"按你的输入「{effective_user_query[:30]}」检索我的空间…")
else:
_emit(f"按你的输入「{effective_user_query[:30]}」匹配技能检索…")
elif is_append_only:
# 兼容旧版追加模式:直接用用户填的关键词,不再调 LLM
keywords = _clean_keywords(extra_keywords, intent)
@@ -530,10 +538,9 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
keywords = list(dict.fromkeys(keywords + extra_keywords))
# Step 1.5: 笔记检索(如果传入了 notebook_search_space_id)
notebook_search_space_id: int | None = state.get("notebook_search_space_id")
notebook_materials: list[Material] = []
if notebook_search_space_id:
if material_source == "notebook" and notebook_search_space_id:
notebook_source_ids: list[int] = state.get("notebook_source_ids") or []
notebook_token: str = cfg.get("notebook_token") or ""
_emit("正在检索笔记内容…")
@@ -1132,7 +1139,12 @@ async def researcher_node(state: AIWritingState, config: RunnableConfig) -> dict
# 进度事件文案:用户输入 / 追加模式下让用户看到差异化提示
if is_user_query_search:
step_start_msg = f"按你的输入匹配技能检索并追加:{user_query[:40]}"
if material_source == "knowledge_base":
step_start_msg = f"按你的输入进行知识库检索并追加:{user_query[:40]}"
elif material_source == "notebook":
step_start_msg = f"按你的输入检索我的空间并追加:{user_query[:40]}"
else:
step_start_msg = f"按你的输入匹配技能检索并追加:{user_query[:40]}"
elif is_append_only:
step_start_msg = f"追加检索关键词:{', '.join(keywords[:3])}"
elif skill_steps:
@@ -186,7 +186,11 @@ async def _stream_sample_analysis(
]
full_text = ""
try:
model = get_model(config, model_name=model_name, disable_thinking=True)
# Keep thinking enabled for the sample analyst. Reasoning models may
# stream it through additional_kwargs.reasoning_content, while some
# local models emit literal <think>...</think> in content; both paths
# are split below just like intent/outline/draft streaming.
model = get_model(config, model_name=model_name, disable_thinking=False)
parser = ThinkTagParser()
async for chunk in model.astream(messages):
reasoning = reasoning_delta(chunk)
@@ -118,6 +118,8 @@ class UserInterventionPayload(TypedDict, total=False):
# PAUSE-1 素材确认 / enable_skill_search 共用:用户在单输入框里填的自然语言
# (可能是要使用的技能名、想检索的来源、或者一段补充检索意图)。
user_query: str
# PAUSE-1 素材确认卡切换的检索方式:general / knowledge_base / notebook。
material_source: str
edited_outline: Optional[Outline]
outline_feedback: str
user_revision_notes: str
@@ -146,6 +148,8 @@ class AIWritingState(TypedDict):
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
user_outline_raw: str
# True when setup chat has already produced structured intent fields.
skip_intent_parser: bool
# 写作入口模式:normal=普通写作 / imitate=样文仿写。仿写模式下先分析 sample_text,
# 得到 sample_style_profile,再将其注入大纲与草稿提示词。
writing_mode_type: str