feat: refine ai writing search flow
This commit is contained in:
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;
|
||||
|
||||
+16
-4
@@ -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}],
|
||||
}
|
||||
|
||||
+31
@@ -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,
|
||||
|
||||
+17
-5
@@ -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:
|
||||
|
||||
+5
-1
@@ -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
|
||||
|
||||
Reference in new issue
Block a user