feat: add ai writing imitation mode
This commit is contained in:
1 parent
9268c73776
commit
bb6b9bd84b
22 files changed
+714
-30
No files matched your search
@@ -0,0 +1,23 @@
|
||||
import { getBackendBaseURL } from '@/core/config';
|
||||
import { apiFetch } from '@/core/api';
|
||||
|
||||
export interface AIWritingSampleExtractResult {
|
||||
filename: string;
|
||||
text: string;
|
||||
char_count: number;
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export async function extractAIWritingSample(file: File): Promise<AIWritingSampleExtractResult> {
|
||||
const form = new FormData();
|
||||
form.append('file', file);
|
||||
const resp = await apiFetch(`${getBackendBaseURL()}/api/ai-writing/sample/extract`, {
|
||||
method: 'POST',
|
||||
body: form,
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const text = await resp.text().catch(() => '');
|
||||
throw new Error(text || `样文解析失败:${resp.status}`);
|
||||
}
|
||||
return resp.json();
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { PenLine, SlidersHorizontal } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { FileText, Loader2, PenLine, SlidersHorizontal, Upload } from 'lucide-react';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -17,6 +17,7 @@ import { useStudioNotebooks, useNotebookSources } from '@/core/studio/hooks';
|
||||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||
import { extractAIWritingSample } from '../../api/ai-writing-sample';
|
||||
|
||||
interface AIWritingFormProps {
|
||||
onSubmit: (req: AIWritingRequest) => void;
|
||||
@@ -41,7 +42,9 @@ const INPUT_CLASS =
|
||||
const DEFAULT_KEYWORD_COUNT = 1;
|
||||
const DEFAULT_AUDIENCE = '普通读者';
|
||||
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
|
||||
type WritingModeType = 'normal' | 'imitate';
|
||||
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: '用素材收集专家配置的技能检索(默认:知识库检索)' },
|
||||
@@ -49,6 +52,17 @@ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; des
|
||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
||||
];
|
||||
|
||||
const WRITING_MODE_OPTIONS: Array<{ value: WritingModeType; label: string }> = [
|
||||
{ value: 'normal', label: '普通写作' },
|
||||
{ value: 'imitate', label: '样文仿写' },
|
||||
];
|
||||
|
||||
const IMITATE_STRENGTH_OPTIONS: Array<{ value: 'light' | 'medium' | 'strong'; label: string }> = [
|
||||
{ value: 'light', label: '轻度' },
|
||||
{ value: 'medium', label: '中度' },
|
||||
{ value: 'strong', label: '高度' },
|
||||
];
|
||||
|
||||
const FIELD_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
|
||||
const FIELD_TRIGGER_CLASS =
|
||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||
@@ -61,6 +75,11 @@ export function AIWritingForm({
|
||||
}: AIWritingFormProps) {
|
||||
const {
|
||||
intent, setIntent,
|
||||
writingModeType, setWritingModeType,
|
||||
sampleText, setSampleText,
|
||||
sampleFilename, setSampleFilename,
|
||||
imitateStrength, setImitateStrength,
|
||||
preserveSampleStructure, setPreserveSampleStructure,
|
||||
showOutline, setShowOutline,
|
||||
outlineText, setOutlineText,
|
||||
articleType, setArticleType,
|
||||
@@ -77,6 +96,10 @@ export function AIWritingForm({
|
||||
modelName, setModelName,
|
||||
} = useWritingForm();
|
||||
|
||||
const [sampleUploading, setSampleUploading] = useState(false);
|
||||
const [sampleMessage, setSampleMessage] = useState('');
|
||||
const [sampleError, setSampleError] = useState('');
|
||||
|
||||
const { models, isLoading: modelsLoading } = useModels();
|
||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, studioToken);
|
||||
@@ -154,13 +177,41 @@ export function AIWritingForm({
|
||||
);
|
||||
}
|
||||
|
||||
async function handleSampleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = '';
|
||||
if (!file) return;
|
||||
setSampleUploading(true);
|
||||
setSampleError('');
|
||||
setSampleMessage('');
|
||||
try {
|
||||
const result = await extractAIWritingSample(file);
|
||||
setSampleFilename(result.filename);
|
||||
setSampleText(result.text);
|
||||
setSampleMessage(result.truncated ? '已提取样文文本,内容较长已截断。' : '已提取样文文本。');
|
||||
} catch (err) {
|
||||
setSampleError(err instanceof Error ? err.message : '样文解析失败');
|
||||
} finally {
|
||||
setSampleUploading(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!intent.trim()) return;
|
||||
if (writingModeType === 'imitate' && !sampleText.trim()) {
|
||||
setSampleError('请上传样文或粘贴样文文本');
|
||||
return;
|
||||
}
|
||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||
onSubmit({
|
||||
userIntent: intent.trim(),
|
||||
userOutline: outlineText.trim() || undefined,
|
||||
writingModeType,
|
||||
sampleText: writingModeType === 'imitate' ? sampleText.trim() : undefined,
|
||||
sampleFilename: writingModeType === 'imitate' ? sampleFilename || '粘贴文本' : undefined,
|
||||
imitateStrength,
|
||||
preserveSampleStructure,
|
||||
articleType,
|
||||
targetAudience: hideAdvancedFields
|
||||
? DEFAULT_AUDIENCE
|
||||
@@ -190,6 +241,106 @@ export function AIWritingForm({
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<label className={FIELD_LABEL_CLASS}>写作模式</label>
|
||||
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-1 dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
{WRITING_MODE_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
onClick={() => setWritingModeType(opt.value)}
|
||||
className={cn(
|
||||
'h-10 rounded-lg text-sm font-medium transition-colors',
|
||||
writingModeType === opt.value
|
||||
? 'bg-white text-primary shadow-sm dark:bg-[#0B376A] dark:text-white'
|
||||
: 'text-muted-foreground hover:bg-white/70 dark:text-[#AACEFF]/70 dark:hover:bg-[#0B376A]/60',
|
||||
)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{writingModeType === 'imitate' && (
|
||||
<div className="mt-4 space-y-3 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<label className={FIELD_LABEL_CLASS}>样文</label>
|
||||
<label
|
||||
className={cn(
|
||||
'inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg border border-[#e8dedd] bg-white px-3 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-[#fff8f8]',
|
||||
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
|
||||
disabled || sampleUploading ? 'pointer-events-none opacity-60' : '',
|
||||
)}
|
||||
>
|
||||
{sampleUploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
|
||||
上传文件
|
||||
<input
|
||||
type="file"
|
||||
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
|
||||
className="sr-only"
|
||||
disabled={disabled || sampleUploading}
|
||||
onChange={handleSampleFileChange}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{sampleFilename && (
|
||||
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-xs text-muted-foreground dark:bg-[#022248] dark:text-[#AACEFF]/80">
|
||||
<FileText className="size-3.5" />
|
||||
<span className="truncate">{sampleFilename}</span>
|
||||
</div>
|
||||
)}
|
||||
<Textarea
|
||||
placeholder="上传 Word/PDF/Markdown/TXT,或直接粘贴一篇样文。AI 会先分析文风,再按你的写作意图仿写一篇新文章。"
|
||||
value={sampleText}
|
||||
onChange={(e) => {
|
||||
setSampleText(e.target.value.slice(0, MAX_SAMPLE_TEXT_LENGTH));
|
||||
setSampleError('');
|
||||
if (!sampleFilename) setSampleFilename('粘贴文本');
|
||||
}}
|
||||
disabled={disabled || sampleUploading}
|
||||
rows={8}
|
||||
maxLength={MAX_SAMPLE_TEXT_LENGTH}
|
||||
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground/80">
|
||||
<span>{sampleText.length} / {MAX_SAMPLE_TEXT_LENGTH} 字</span>
|
||||
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
|
||||
{sampleError && <span className="text-destructive">{sampleError}</span>}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-semibold text-foreground">仿写强度</label>
|
||||
<Select
|
||||
value={imitateStrength}
|
||||
onValueChange={(v) => setImitateStrength(v as 'light' | 'medium' | 'strong')}
|
||||
disabled={disabled}
|
||||
>
|
||||
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 pt-7 text-sm font-medium text-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={preserveSampleStructure}
|
||||
onChange={(e) => setPreserveSampleStructure(e.target.checked)}
|
||||
disabled={disabled}
|
||||
className="size-3.5 shrink-0 accent-primary"
|
||||
/>
|
||||
沿用样文结构
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className={FIELD_LABEL_CLASS}>
|
||||
@@ -428,7 +579,7 @@ export function AIWritingForm({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
disabled={disabled || !intent.trim() || (writingModeType === 'imitate' && !sampleText.trim())}
|
||||
className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
|
||||
>
|
||||
<PenLine className="size-4" />
|
||||
|
||||
@@ -10,6 +10,7 @@ export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '未完成', color: 'text-slate-400' },
|
||||
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
|
||||
background: { label: '挂起中', color: 'text-sky-500' },
|
||||
analyzing_sample: { label: '样文分析', color: 'text-cyan-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
done: { label: '完成', color: 'text-green-500' },
|
||||
|
||||
@@ -54,6 +54,7 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
analyzing_sample: '样文分析师分析文风中…',
|
||||
researching: '素材收集专家搜集素材中…',
|
||||
awaiting_material_confirm: '等待确认素材',
|
||||
writing_outline: '作家规划大纲中…',
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AIWritingTimelineProps {
|
||||
const DISPLAYED_TYPES = new Set([
|
||||
'step_start',
|
||||
'step_done',
|
||||
'sample_profile_ready',
|
||||
'materials_ready',
|
||||
'outline_ready',
|
||||
'draft_ready',
|
||||
|
||||
@@ -26,6 +26,7 @@ export function DraftSync({ status, markdown, title }: DraftSyncProps) {
|
||||
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
||||
// writing_draft / revising 才只读。
|
||||
const generating =
|
||||
status === 'analyzing_sample' ||
|
||||
status === 'researching' ||
|
||||
status === 'writing_outline' ||
|
||||
streaming;
|
||||
|
||||
@@ -21,6 +21,7 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
}
|
||||
|
||||
const AGENT_COLOR: Record<string, string> = {
|
||||
样文分析师: 'text-cyan-500',
|
||||
素材收集专家: 'text-blue-500',
|
||||
作家: 'text-purple-500',
|
||||
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
|
||||
|
||||
@@ -140,11 +140,18 @@ export function WritingSetupChat({
|
||||
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
||||
const skipResearch = formCtx?.skipResearch ?? false;
|
||||
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
|
||||
const writingModeType = formCtx?.writingModeType === 'imitate' ? 'imitate' : 'normal';
|
||||
const sampleText = formCtx?.sampleText?.trim() ?? '';
|
||||
|
||||
const request: AIWritingRequest = {
|
||||
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
||||
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
|
||||
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
|
||||
writingModeType,
|
||||
sampleText: writingModeType === 'imitate' ? sampleText : undefined,
|
||||
sampleFilename: writingModeType === 'imitate' ? formCtx?.sampleFilename || '粘贴文本' : undefined,
|
||||
imitateStrength: writingModeType === 'imitate' ? formCtx?.imitateStrength ?? 'medium' : undefined,
|
||||
preserveSampleStructure: writingModeType === 'imitate' ? formCtx?.preserveSampleStructure ?? true : undefined,
|
||||
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
|
||||
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
|
||||
@@ -170,6 +177,10 @@ export function WritingSetupChat({
|
||||
toast.error('写作意图为空,请先补充意图');
|
||||
return;
|
||||
}
|
||||
if (writingModeType === 'imitate' && !sampleText) {
|
||||
toast.error('仿写模式需要先上传或粘贴样文');
|
||||
return;
|
||||
}
|
||||
await aiWriting.startWriting(request);
|
||||
},
|
||||
[aiWriting, applyProposal, formCtx, chatModel],
|
||||
|
||||
@@ -217,10 +217,10 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
||||
|
||||
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
|
||||
// 素材收集阶段单独渲染,以便透传实时进度文案
|
||||
if (status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
if (status === 'analyzing_sample' || status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<MaterialsSkeleton researchProgress={researchProgress} />
|
||||
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +436,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
user_intent: request.userIntent,
|
||||
// 用户自带大纲原文(留空则后端走自动规划)
|
||||
user_outline_raw: request.userOutline?.trim() || '',
|
||||
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
|
||||
sample_text: request.sampleText?.trim() || '',
|
||||
sample_filename: request.sampleFilename?.trim() || '',
|
||||
sample_style_profile: null,
|
||||
imitate_strength: request.imitateStrength ?? 'medium',
|
||||
preserve_sample_structure: request.preserveSampleStructure ?? true,
|
||||
article_type: request.articleType,
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
||||
article_type_detail: request.articleTypeDetail?.trim() || '',
|
||||
@@ -452,7 +458,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
|
||||
drafts: [],
|
||||
progress_events: [],
|
||||
material_package: null,
|
||||
|
||||
@@ -26,6 +26,16 @@ export interface WritingFormValue {
|
||||
setShowOutline: (v: boolean) => void;
|
||||
outlineText: string;
|
||||
setOutlineText: (v: string) => void;
|
||||
writingModeType: 'normal' | 'imitate';
|
||||
setWritingModeType: (v: 'normal' | 'imitate') => void;
|
||||
sampleText: string;
|
||||
setSampleText: (v: string) => void;
|
||||
sampleFilename: string;
|
||||
setSampleFilename: (v: string) => void;
|
||||
imitateStrength: 'light' | 'medium' | 'strong';
|
||||
setImitateStrength: (v: 'light' | 'medium' | 'strong') => void;
|
||||
preserveSampleStructure: boolean;
|
||||
setPreserveSampleStructure: (v: boolean) => void;
|
||||
articleType: string;
|
||||
setArticleType: (v: string) => void;
|
||||
wordCountInput: string;
|
||||
@@ -83,6 +93,11 @@ export function WritingFormProvider({
|
||||
const [formExpanded, setFormExpanded] = useState(false);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
const [outlineText, setOutlineText] = useState('');
|
||||
const [writingModeType, setWritingModeType] = useState<'normal' | 'imitate'>('normal');
|
||||
const [sampleText, setSampleText] = useState('');
|
||||
const [sampleFilename, setSampleFilename] = useState('');
|
||||
const [imitateStrength, setImitateStrength] = useState<'light' | 'medium' | 'strong'>('medium');
|
||||
const [preserveSampleStructure, setPreserveSampleStructure] = useState(true);
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
@@ -123,6 +138,11 @@ export function WritingFormProvider({
|
||||
formExpanded, setFormExpanded,
|
||||
showOutline, setShowOutline,
|
||||
outlineText, setOutlineText,
|
||||
writingModeType, setWritingModeType,
|
||||
sampleText, setSampleText,
|
||||
sampleFilename, setSampleFilename,
|
||||
imitateStrength, setImitateStrength,
|
||||
preserveSampleStructure, setPreserveSampleStructure,
|
||||
articleType, setArticleType,
|
||||
wordCountInput, setWordCountInput,
|
||||
audience, setAudience,
|
||||
@@ -135,7 +155,7 @@ export function WritingFormProvider({
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, writingModeType, sampleText, sampleFilename, imitateStrength, preserveSampleStructure, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -51,6 +51,7 @@ const INITIAL_STATE: AIWritingState = {
|
||||
currentDraftMarkdown: '',
|
||||
currentDraftTitle: '',
|
||||
reviewResult: null,
|
||||
sampleStyleProfile: null,
|
||||
revisionCount: 0,
|
||||
currentPause: null,
|
||||
completedInterventions: [],
|
||||
@@ -101,7 +102,11 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
return action.events.reduce(handleEvent, prev);
|
||||
|
||||
case 'start':
|
||||
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
status: action.request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
|
||||
request: action.request,
|
||||
};
|
||||
|
||||
case 'set_session':
|
||||
return { ...prev, sessionId: action.sessionId };
|
||||
@@ -239,6 +244,13 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'research_progress':
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'sample_profile_ready':
|
||||
return {
|
||||
...base,
|
||||
sampleStyleProfile: event.sampleStyleProfile ?? null,
|
||||
status: 'researching',
|
||||
};
|
||||
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
@@ -712,6 +724,23 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
if (out.sample_style_profile && !out.sampleStyleProfile) {
|
||||
const p = out.sample_style_profile as Record<string, unknown>;
|
||||
out.sampleStyleProfile = {
|
||||
summary: p.summary as string | undefined,
|
||||
articleTypeGuess: (p.article_type_guess ?? p.articleTypeGuess) as string | undefined,
|
||||
tone: p.tone as string | undefined,
|
||||
structure: (p.structure as string[] | undefined) ?? [],
|
||||
languageFeatures: (p.language_features ?? p.languageFeatures ?? []) as string[],
|
||||
sentenceStyle: (p.sentence_style ?? p.sentenceStyle) as string | undefined,
|
||||
openingStyle: (p.opening_style ?? p.openingStyle) as string | undefined,
|
||||
endingStyle: (p.ending_style ?? p.endingStyle) as string | undefined,
|
||||
imitationGuidance: (p.imitation_guidance ?? p.imitationGuidance ?? []) as string[],
|
||||
avoidCopying: (p.avoid_copying ?? p.avoidCopying ?? []) as string[],
|
||||
sampleOutline: (p.sample_outline ?? p.sampleOutline ?? []) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
@@ -753,6 +782,9 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
|
||||
const draft = (values.current_draft && typeof values.current_draft === 'object')
|
||||
? (values.current_draft as Record<string, unknown>) : null;
|
||||
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
|
||||
const sampleProfileEvent = values.sample_style_profile
|
||||
? normalizeProgressDict({ type: 'sample_profile_ready', sample_style_profile: values.sample_style_profile }, sessionId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
@@ -764,6 +796,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
|
||||
currentDraftTitle: String(draft?.title ?? ''),
|
||||
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
|
||||
reviewResult: normalizeReviewResult(values.review_result),
|
||||
sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null,
|
||||
progressEvents,
|
||||
completedInterventions,
|
||||
};
|
||||
@@ -799,12 +832,14 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
|
||||
const allowed: ProgressEventType[] = [
|
||||
'intent_chunk', 'intent_ready',
|
||||
'sample_profile_ready',
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
'model_switch',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
if (type === 'sample_profile_ready') return normalizeProgressDict(o, sessionId);
|
||||
|
||||
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
|
||||
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
|
||||
@@ -933,6 +968,12 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
|
||||
export type AIWritingValues = {
|
||||
user_intent?: string;
|
||||
user_outline_raw?: string;
|
||||
writing_mode_type?: string;
|
||||
sample_text?: string;
|
||||
sample_filename?: string;
|
||||
sample_style_profile?: unknown;
|
||||
imitate_strength?: string;
|
||||
preserve_sample_structure?: boolean;
|
||||
article_type?: string;
|
||||
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
||||
article_type_detail?: string;
|
||||
|
||||
@@ -135,10 +135,27 @@ export interface ReviewResult {
|
||||
revisionNotes: ReviewIssue[];
|
||||
}
|
||||
|
||||
// ── 样文仿写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SampleStyleProfile {
|
||||
summary?: string;
|
||||
articleTypeGuess?: string;
|
||||
tone?: string;
|
||||
structure?: string[];
|
||||
languageFeatures?: string[];
|
||||
sentenceStyle?: string;
|
||||
openingStyle?: string;
|
||||
endingStyle?: string;
|
||||
imitationGuidance?: string[];
|
||||
avoidCopying?: string[];
|
||||
sampleOutline?: string[];
|
||||
}
|
||||
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| "idle"
|
||||
| "analyzing_sample"
|
||||
| "researching"
|
||||
| "awaiting_material_confirm"
|
||||
| "writing_outline"
|
||||
@@ -158,6 +175,7 @@ export type ProgressEventType =
|
||||
| "connected"
|
||||
| "step_start"
|
||||
| "step_done"
|
||||
| "sample_profile_ready"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "research_progress"
|
||||
@@ -226,6 +244,8 @@ export interface ProgressEvent {
|
||||
sectionIndex?: number;
|
||||
sectionTitle?: string;
|
||||
};
|
||||
// sample_profile_ready:样文仿写的文风画像
|
||||
sampleStyleProfile?: SampleStyleProfile;
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
@@ -253,6 +273,13 @@ export interface AIWritingRequest {
|
||||
userIntent: string;
|
||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||
userOutline?: string;
|
||||
/** 写作入口模式:normal=普通写作 / imitate=样文仿写 */
|
||||
writingModeType?: "normal" | "imitate";
|
||||
/** 样文原文(上传提取或直接粘贴),仅样文仿写模式使用 */
|
||||
sampleText?: string;
|
||||
sampleFilename?: string;
|
||||
imitateStrength?: "light" | "medium" | "strong";
|
||||
preserveSampleStructure?: boolean;
|
||||
articleType: string;
|
||||
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
||||
articleTypeDetail?: string;
|
||||
@@ -356,6 +383,7 @@ export interface AIWritingState {
|
||||
currentDraftMarkdown: string;
|
||||
currentDraftTitle: string;
|
||||
reviewResult: ReviewResult | null;
|
||||
sampleStyleProfile: SampleStyleProfile | null;
|
||||
revisionCount: number;
|
||||
currentPause: ProgressEvent | null;
|
||||
completedInterventions: CompletedIntervention[];
|
||||
|
||||
@@ -273,6 +273,8 @@ The AI-writing graph (`packages/harness/deerflow/agents/ai_writing/`) has four s
|
||||
|
||||
Seeding mirrors the roundtable pattern: verified `config.yaml` + `SOUL.md` ship in `app/gateway/routers/_ai_writing_seed_assets/<id>/`; `_ai_writing_seed.py::ensure_ai_writing_functional_agents()` copies them to `.deer-flow/agents/` when missing (never overwrites local edits), called from the startup lifespan (before `_sync_legacy_agents`, so the agents appear in `/api/agents` on first boot) and self-heals from `GET /api/ai-writing/sessions` + `GET /api/ai-writing/article-types`. Tests: `tests/test_ai_writing_seed.py`, `tests/test_ai_writing_agent_runner.py`.
|
||||
|
||||
**Sample imitation mode (样文仿写).** The initial writing form supports `writing_mode_type=imitate`: the user can paste sample text or upload Word/PDF/Markdown/TXT. `POST /api/ai-writing/sample/extract` converts supported document files to Markdown via `deerflow.utils.file_conversion` and returns capped plain text to the frontend. The LangGraph entry point now starts at `sample_analyzer` before `intent_parser`; non-imitation sessions return immediately, while imitation sessions analyze up to 12k chars into `sample_style_profile` (`SampleStyleProfile` in `state.py`) and emit `sample_profile_ready`. `writer_outline` and `writer_draft` call `render_sample_style_section(state)` so both outline planning and section drafting receive the same style profile, imitation strength (`light|medium|strong`), optional structure-preservation preference, and guardrails: learn structure/tone/paragraph rhythm/sentence style, but do not copy sample sentences, proprietary facts, people, organizations, cases, or data. Frontend state mirrors this with `analyzing_sample`, uploaded/pasted sample controls, history/timeline restoration of `sampleStyleProfile`, and the setup-chat direct-start path preserves the same fields. Tests live with the knowledge-base AI-writing tests (`tests/test_ai_writing_knowledge_base.py`).
|
||||
|
||||
**Skill-driven retrieval (检索类型).** `material_source` has three values: `general` (通用检索, default), `knowledge_base` (知识库检索), and `notebook` (我的空间); legacy `skill` is normalized to `general` by the researcher node. **`knowledge_base` 直连内网 ES 接口(= 旧版「内网直连检索」)** — it bypasses skill orchestration entirely and calls `IntranetSearchProvider` over `ai_writing.intranet_search_url` (concurrent over the generated keywords; `intranet_knowledge_base`/`intranet_search_timeout` apply). **An empty/unconfigured `intranet_search_url` does NOT disable it** — the empty string is passed straight to `IntranetSearchProvider`, which falls back to its built-in default endpoint (`intranet_stub._DEFAULT_ENDPOINT`), exactly like the legacy `get_search_provider(search_provider=intranet)` path. So 内网直连检索 works out of the box without configuring an address (configure `intranet_search_url` only to point at a different endpoint). This is the "弱模型识别不了技能" escape hatch — the old no-skill retrieval path restored as an explicit option (weak models like deepseek-chat often fail to invoke configured skills). Tests: `tests/test_ai_writing_knowledge_base.py`. General retrieval is orchestrated from the **skills configured on the `ai-writing-researcher` agent** (`config.yaml → skills`, editable from the agent management page): the built-in `knowledge-base-search` skill (知识库检索) runs the native keyword fan-out web/intranet search (the old default path), while any other configured skill searches with a `description`-targeted query; skills run in configured order with early-stop at `max_materials`, and an empty/unresolvable skills config falls back to the built-in knowledge search so retrieval always works. The skill itself is seeded from `_ai_writing_seed_assets/skills/knowledge-base-search/SKILL.md` into `skills/public/` (never overwrites), and a pre-existing researcher `config.yaml` lacking the `skills` key is patched in place with the default (explicit `skills: []` is respected). **PAUSE-1 input append flow**: when the user types natural language into the material-confirm input (`re_search`/legacy `enable_skill_search` + `user_query`), the node matches the most relevant configured skill via `SkillPicker(candidates=…)` (skipped when only one skill is configured), searches with the user's words, and **appends** the new materials to the existing package (URL-dedup, original order preserved, no LLM re-rank/truncation, 100-item hard cap). **Skill-failure fault tolerance**: a configured skill that times out / raises / has all its keyword searches fail (vs. legitimately returning 0 results) no longer just silently moves on — the researcher node emits a `skill_search_step` of type `skill_error` (carrying the error `reason`), and if the configured skills collected **no** new materials at all it runs a `fallback` pass over other available skills (`_fallback_candidate_skills`: the built-in `knowledge-base-search` native path first, then other enabled catalog skills not yet tried) instead of erroring out. The frontend `SkillStepsCard` renders `skill_error` (rose) and `fallback` (amber) so the timeline shows "技能报错 → 改用其他技能搜集". Tests: `tests/test_ai_writing_material_source.py`.
|
||||
|
||||
**Skill-call timeout** is hardcoded **300s (5 min)** — `researcher.py` `_SKILL_SEARCH_TIMEOUT_SECONDS` (per-skill wrapper) and `_skill_agent.py` `_REAL_AGENT_TIMEOUT_SECONDS` (whole real-agent skill run). The intranet ES fallback (`IntranetSearchProvider`) gets a longer **600s (10 min)** default (`intranet_stub.py`; overridable via `ai_writing.intranet_search_timeout`) — that interface is the slow one, so our own fallback timeout is generous to avoid being blamed for "检不出".
|
||||
|
||||
@@ -130,6 +130,9 @@ FastAPI application providing REST endpoints for frontend integration:
|
||||
| `GET /api/threads/{id}/uploads/list` | List uploaded files |
|
||||
| `DELETE /api/threads/{id}` | Delete DeerFlow-managed local thread data after LangGraph thread deletion; unexpected failures are logged server-side and return a generic 500 detail |
|
||||
| `GET /api/threads/{id}/artifacts/{path}` | Serve generated artifacts |
|
||||
| `POST /api/ai-writing/sample/extract` | Extract text from Word/PDF/Markdown/TXT samples for AI-writing imitation mode |
|
||||
|
||||
AI writing supports a sample-imitation mode: the frontend can upload or paste a sample article, the Gateway extracts text for document files, and the `ai_writing` graph runs a `sample_analyzer` node before normal intent/research/outline/draft stages. The resulting style profile is injected into outline and draft prompts with explicit guardrails against copying source sentences, facts, names, or data.
|
||||
|
||||
### Custom Agents
|
||||
|
||||
|
||||
@@ -13,20 +13,30 @@ GET /api/ai-writing/article-types 列出所有文章类型
|
||||
POST /api/ai-writing/article-types 新增文章类型
|
||||
PATCH /api/ai-writing/article-types/{id} 更新文章类型
|
||||
DELETE /api/ai-writing/article-types/{id} 删除文章类型
|
||||
POST /api/ai-writing/sample/extract 提取样文仿写上传文件文本
|
||||
POST /api/ai-writing/admin/cleanup (admin)手动清理会话
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.gateway.authz import require_auth
|
||||
from deerflow.utils.file_conversion import CONVERTIBLE_EXTENSIONS, convert_file_to_markdown
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/ai-writing", tags=["ai-writing"])
|
||||
|
||||
_SAMPLE_TEXT_EXTENSIONS = {".txt", ".md", ".markdown"}
|
||||
_MAX_SAMPLE_BYTES = 20 * 1024 * 1024
|
||||
_MAX_SAMPLE_TEXT_CHARS = 120_000
|
||||
|
||||
# ── 请求/响应模型 ─────────────────────────────────────────────────────────────
|
||||
|
||||
class RenameSessionRequest(BaseModel):
|
||||
@@ -67,6 +77,13 @@ class SaveTranscriptRequest(BaseModel):
|
||||
transcript: dict
|
||||
|
||||
|
||||
class SampleExtractResponse(BaseModel):
|
||||
filename: str
|
||||
text: str
|
||||
char_count: int
|
||||
truncated: bool = False
|
||||
|
||||
|
||||
class ArticleTypeRequest(BaseModel):
|
||||
key: str
|
||||
label: str
|
||||
@@ -154,6 +171,52 @@ class SessionResponse(BaseModel):
|
||||
updated_at: str
|
||||
|
||||
|
||||
async def _extract_sample_text(file: UploadFile) -> tuple[str, str]:
|
||||
name = file.filename or "sample"
|
||||
ext = Path(name).suffix.lower()
|
||||
raw = await file.read()
|
||||
if not raw:
|
||||
raise HTTPException(status_code=400, detail="文件为空")
|
||||
if len(raw) > _MAX_SAMPLE_BYTES:
|
||||
raise HTTPException(status_code=413, detail=f"文件过大,最大支持 {_MAX_SAMPLE_BYTES // (1024 * 1024)}MB")
|
||||
|
||||
if ext in _SAMPLE_TEXT_EXTENSIONS:
|
||||
text = raw.decode("utf-8", errors="ignore")
|
||||
elif ext in CONVERTIBLE_EXTENSIONS:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
tmp_path = Path(tmp) / f"sample{ext}"
|
||||
tmp_path.write_bytes(raw)
|
||||
md_path = await convert_file_to_markdown(tmp_path)
|
||||
if md_path is None:
|
||||
raise HTTPException(status_code=422, detail=f"无法解析文件:{name}")
|
||||
text = md_path.read_text(encoding="utf-8", errors="ignore")
|
||||
else:
|
||||
raise HTTPException(status_code=415, detail=f"不支持的文件类型:{ext or '未知'}(支持 doc/docx/pdf/md/txt)")
|
||||
|
||||
text = text.strip()
|
||||
if not text:
|
||||
raise HTTPException(status_code=422, detail="文件内容为空或无法提取文本")
|
||||
return name, text
|
||||
|
||||
|
||||
@router.post("/sample/extract", response_model=SampleExtractResponse)
|
||||
@require_auth
|
||||
async def extract_sample_article(request: Request, file: UploadFile = File(...)) -> SampleExtractResponse:
|
||||
"""Extract text from a sample article for AI-writing imitation mode."""
|
||||
_ = request
|
||||
name, text = await _extract_sample_text(file)
|
||||
char_count = len(text)
|
||||
truncated = char_count > _MAX_SAMPLE_TEXT_CHARS
|
||||
if truncated:
|
||||
text = text[:_MAX_SAMPLE_TEXT_CHARS]
|
||||
return SampleExtractResponse(
|
||||
filename=name,
|
||||
text=text,
|
||||
char_count=char_count,
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
|
||||
# ── 路由 ──────────────────────────────────────────────────────────────────────
|
||||
@router.get("/sessions", response_model=list[SessionResponse])
|
||||
async def list_sessions(request: Request):
|
||||
|
||||
+7
-4
@@ -7,12 +7,13 @@ from langchain_core.runnables import RunnableConfig
|
||||
from langgraph.graph import END, StateGraph
|
||||
from langgraph.types import interrupt
|
||||
|
||||
from .state import AIWritingState
|
||||
from .nodes.editor import editor_node
|
||||
from .nodes.intent_parser import intent_parser_node
|
||||
from .nodes.researcher import researcher_node
|
||||
from .nodes.writer_outline import writer_outline_node
|
||||
from .nodes.sample_analyzer import sample_analyzer_node
|
||||
from .nodes.writer_draft import writer_draft_node
|
||||
from .nodes.editor import editor_node
|
||||
from .nodes.writer_outline import writer_outline_node
|
||||
from .state import AIWritingState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -414,6 +415,7 @@ def make_ai_writing_graph(config=None):
|
||||
"""
|
||||
g = StateGraph(AIWritingState)
|
||||
|
||||
g.add_node("sample_analyzer", sample_analyzer_node)
|
||||
g.add_node("intent_parser", intent_parser_node)
|
||||
g.add_node("researcher", researcher_node)
|
||||
g.add_node("pause_material", pause_material_confirm)
|
||||
@@ -425,7 +427,8 @@ def make_ai_writing_graph(config=None):
|
||||
g.add_node("editor", editor_node)
|
||||
g.add_node("pause_review", pause_review_confirm)
|
||||
|
||||
g.set_entry_point("intent_parser")
|
||||
g.set_entry_point("sample_analyzer")
|
||||
g.add_edge("sample_analyzer", "intent_parser")
|
||||
g.add_edge("intent_parser", "researcher")
|
||||
g.add_conditional_edges("researcher", after_researcher,
|
||||
{"pause_material": "pause_material", "writer_draft": "writer_draft", "writer_outline": "writer_outline"})
|
||||
|
||||
+204
@@ -0,0 +1,204 @@
|
||||
"""样文仿写:分析用户上传/粘贴的样文,提取可复用的文风画像。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, SampleStyleProfile
|
||||
from ._utils import count_words, llm_json
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_MAX_SAMPLE_ANALYSIS_CHARS = 12000
|
||||
|
||||
SAMPLE_ANALYZE_SYSTEM = """你是一名资深中文编辑和文风分析师。
|
||||
你的任务:阅读用户提供的样文,提取「可用于仿写的文风画像」,供后续写作模型模仿其结构、语气和表达节奏。
|
||||
|
||||
【重要边界】
|
||||
- 你不是改写样文本身,而是分析它的写作风格。
|
||||
- 不要复述样文的具体事实、数字、人名、机构名,除非用于概括文章结构。
|
||||
- 后续写作必须避免直接照抄样文,因此你要明确指出哪些表达方式可以学、哪些原文内容不能复制。
|
||||
|
||||
【输出要求】
|
||||
- 只输出一个 JSON 对象,不要解释文字,不要 markdown 代码块。
|
||||
- 必须使用英文双引号。
|
||||
- 字段结构:
|
||||
{
|
||||
"summary": "一句话概括样文风格",
|
||||
"article_type_guess": "推测文章类型",
|
||||
"tone": "语气/立场/情绪特征",
|
||||
"structure": ["篇章结构特征1", "篇章结构特征2"],
|
||||
"language_features": ["遣词造句特征1", "修辞/表达特征2"],
|
||||
"sentence_style": "句式、长短句、节奏特征",
|
||||
"opening_style": "开头方式",
|
||||
"ending_style": "结尾方式",
|
||||
"imitation_guidance": ["后续仿写应遵守的建议1", "建议2"],
|
||||
"avoid_copying": ["不得复制的内容/风险1", "风险2"],
|
||||
"sample_outline": ["样文结构骨架1", "样文结构骨架2"]
|
||||
}"""
|
||||
|
||||
SAMPLE_ANALYZE_USER = """样文文件名:{filename}
|
||||
样文字数:约 {word_count} 字
|
||||
|
||||
样文内容(可能已截断):
|
||||
{sample_text}
|
||||
|
||||
请提取可用于后续仿写的文风画像,只输出 JSON。"""
|
||||
|
||||
|
||||
def _is_imitate_mode(state: AIWritingState) -> bool:
|
||||
mode = (state.get("writing_mode_type") or "").strip().lower()
|
||||
return mode == "imitate"
|
||||
|
||||
|
||||
def _compact_list(values: object, limit: int = 6) -> list[str]:
|
||||
if not isinstance(values, list):
|
||||
return []
|
||||
out: list[str] = []
|
||||
for item in values:
|
||||
text = str(item or "").strip()
|
||||
if text:
|
||||
out.append(text[:120])
|
||||
if len(out) >= limit:
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _normalize_profile(raw: dict, sample_text: str) -> SampleStyleProfile:
|
||||
"""规整 LLM 输出,避免缺字段影响后续提示词。"""
|
||||
first_lines = [ln.strip() for ln in sample_text.splitlines() if ln.strip()][:5]
|
||||
fallback_outline = first_lines or ["起承转合清晰", "围绕主题分段展开"]
|
||||
profile: SampleStyleProfile = {
|
||||
"summary": str(raw.get("summary") or "样文表达较为完整,结构清晰,适合提取篇章节奏与措辞习惯。")[:200],
|
||||
"article_type_guess": str(raw.get("article_type_guess") or raw.get("articleTypeGuess") or "")[:80],
|
||||
"tone": str(raw.get("tone") or "稳健、清晰")[:120],
|
||||
"structure": _compact_list(raw.get("structure"), 8) or ["先交代背景或问题,再分层展开,最后收束观点。"],
|
||||
"language_features": _compact_list(raw.get("language_features") or raw.get("languageFeatures"), 8) or ["表达相对凝练,注重段落之间的衔接。"],
|
||||
"sentence_style": str(raw.get("sentence_style") or raw.get("sentenceStyle") or "长短句结合,段落节奏平稳。")[:160],
|
||||
"opening_style": str(raw.get("opening_style") or raw.get("openingStyle") or "开头直接引入主题。")[:160],
|
||||
"ending_style": str(raw.get("ending_style") or raw.get("endingStyle") or "结尾总结全文并给出判断或展望。")[:160],
|
||||
"imitation_guidance": _compact_list(raw.get("imitation_guidance") or raw.get("imitationGuidance"), 8) or ["模仿篇章结构、语气和句式节奏,不搬运样文事实。"],
|
||||
"avoid_copying": _compact_list(raw.get("avoid_copying") or raw.get("avoidCopying"), 8) or ["不得连续照抄样文原句或复用样文中的专有事实。"],
|
||||
"sample_outline": _compact_list(raw.get("sample_outline") or raw.get("sampleOutline"), 10) or fallback_outline,
|
||||
}
|
||||
return profile
|
||||
|
||||
|
||||
def _fallback_profile(sample_text: str) -> SampleStyleProfile:
|
||||
"""模型失败时的确定性兜底画像。"""
|
||||
paragraphs = [p.strip() for p in re.split(r"\n\s*\n+", sample_text) if p.strip()]
|
||||
avg_len = round(sum(len(p) for p in paragraphs) / max(len(paragraphs), 1))
|
||||
return {
|
||||
"summary": "已根据样文段落和篇幅提取基础仿写约束。",
|
||||
"article_type_guess": "",
|
||||
"tone": "保持样文的正式程度和表达密度。",
|
||||
"structure": [
|
||||
f"样文约 {len(paragraphs)} 个自然段,平均每段约 {avg_len} 字。",
|
||||
"仿写时保留相近的段落推进节奏。",
|
||||
],
|
||||
"language_features": ["保留样文的用词正式程度、连接方式和论述密度。"],
|
||||
"sentence_style": "参照样文长短句比例,避免明显口语化或突兀转换。",
|
||||
"opening_style": "参照样文开头的切入方式,但改写为新主题。",
|
||||
"ending_style": "参照样文结尾的收束方式,形成新主题下的总结。",
|
||||
"imitation_guidance": ["学习样文结构和语气,所有事实内容以用户新要求为准。"],
|
||||
"avoid_copying": ["不要连续复用样文原句,不要照搬样文专有事实、案例和数据。"],
|
||||
"sample_outline": [p[:80] for p in paragraphs[:8]] or ["围绕主题分层展开"],
|
||||
}
|
||||
|
||||
|
||||
def render_sample_style_section(state: AIWritingState) -> str:
|
||||
"""把文风画像渲染成可注入大纲/写作提示词的约束片段。"""
|
||||
if not _is_imitate_mode(state):
|
||||
return ""
|
||||
profile = state.get("sample_style_profile") or {}
|
||||
if not profile:
|
||||
return ""
|
||||
strength = (state.get("imitate_strength") or "medium").strip().lower()
|
||||
strength_label = {
|
||||
"light": "轻度:只参考语气和部分表达节奏,结构可按新主题调整。",
|
||||
"medium": "中度:同时参考语气、段落节奏和篇章推进方式。",
|
||||
"strong": "高度:尽量贴近样文结构骨架、标题组织和表达密度,但仍不得照抄原文。",
|
||||
}.get(strength, "中度:同时参考语气、段落节奏和篇章推进方式。")
|
||||
preserve = "是" if state.get("preserve_sample_structure") else "否"
|
||||
compact = {
|
||||
"summary": profile.get("summary", ""),
|
||||
"tone": profile.get("tone", ""),
|
||||
"structure": profile.get("structure", []),
|
||||
"language_features": profile.get("language_features", []),
|
||||
"sentence_style": profile.get("sentence_style", ""),
|
||||
"opening_style": profile.get("opening_style", ""),
|
||||
"ending_style": profile.get("ending_style", ""),
|
||||
"imitation_guidance": profile.get("imitation_guidance", []),
|
||||
"avoid_copying": profile.get("avoid_copying", []),
|
||||
"sample_outline": profile.get("sample_outline", []),
|
||||
}
|
||||
return (
|
||||
"\n\n【样文仿写要求】\n"
|
||||
f"- 仿写强度:{strength_label}\n"
|
||||
f"- 是否尽量沿用样文结构:{preserve}\n"
|
||||
"- 仿写目标:学习样文的篇章结构、语气、段落节奏、句式和表达密度;新文章主题、事实与论点必须服务用户的新要求。\n"
|
||||
"- 严禁:不得连续照抄样文原句,不得搬运样文中的专有事实、数据、人物、机构或案例。\n"
|
||||
"文风画像 JSON:\n"
|
||||
f"{json.dumps(compact, ensure_ascii=False)}"
|
||||
)
|
||||
|
||||
|
||||
async def sample_analyzer_node(state: AIWritingState, config: RunnableConfig) -> dict:
|
||||
if not _is_imitate_mode(state):
|
||||
return {}
|
||||
|
||||
sample_text = (state.get("sample_text") or "").strip()
|
||||
filename = (state.get("sample_filename") or "粘贴文本").strip()
|
||||
if not sample_text:
|
||||
return {
|
||||
"writing_mode_type": "normal",
|
||||
"status": "researching",
|
||||
"progress_events": [{
|
||||
"type": "notice",
|
||||
"agent_name": "系统",
|
||||
"title": "样文为空",
|
||||
"detail": "未收到可分析的样文内容,已按普通写作继续。",
|
||||
}],
|
||||
}
|
||||
|
||||
clipped = sample_text[:_MAX_SAMPLE_ANALYSIS_CHARS]
|
||||
fallback = _fallback_profile(clipped)
|
||||
configurable = (config or {}).get("configurable", {}) or {}
|
||||
model_name = (state.get("model_name") or configurable.get("model_name") or "").strip() or None
|
||||
raw = await llm_json(
|
||||
SAMPLE_ANALYZE_SYSTEM,
|
||||
SAMPLE_ANALYZE_USER.format(
|
||||
filename=filename,
|
||||
word_count=count_words(sample_text),
|
||||
sample_text=clipped,
|
||||
),
|
||||
fallback,
|
||||
config,
|
||||
model_name=model_name,
|
||||
disable_thinking=True,
|
||||
)
|
||||
profile = _normalize_profile(raw, clipped)
|
||||
events = [
|
||||
{"type": "step_start", "agent_name": "样文分析师", "message": "正在分析样文风格"},
|
||||
{
|
||||
"type": "sample_profile_ready",
|
||||
"agent_name": "样文分析师",
|
||||
"message": profile.get("summary") or "样文风格分析完成",
|
||||
"sample_style_profile": profile,
|
||||
},
|
||||
]
|
||||
if raw.get("_llm_error"):
|
||||
events.append({
|
||||
"type": "notice",
|
||||
"agent_name": "样文分析师",
|
||||
"title": "样文分析模型异常",
|
||||
"detail": f"已使用基础画像兜底。原因:{raw['_llm_error']}",
|
||||
})
|
||||
return {
|
||||
"sample_style_profile": profile,
|
||||
"status": "researching",
|
||||
"progress_events": events,
|
||||
}
|
||||
+32
-12
@@ -8,19 +8,21 @@ import re
|
||||
from langchain_core.messages import HumanMessage, SystemMessage
|
||||
from langchain_core.runnables import RunnableConfig
|
||||
|
||||
from ..state import AIWritingState, DraftArticle
|
||||
from ..prompts.writer_prompts import (
|
||||
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
|
||||
DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_USER, DRAFT_REVISE_SECTION_USER,
|
||||
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
||||
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
|
||||
)
|
||||
# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。
|
||||
# writer_outline 不反向 import 本模块,无循环。
|
||||
from .writer_outline import (
|
||||
_parse_outline as _revise_parse_outline,
|
||||
_materials_summary as _revise_materials_summary,
|
||||
DRAFT_COMPRESS_USER,
|
||||
DRAFT_EXPAND_USER,
|
||||
DRAFT_REVISE_SCOPE_SYSTEM,
|
||||
DRAFT_REVISE_SCOPE_USER,
|
||||
DRAFT_REVISE_SECTION_USER,
|
||||
DRAFT_SECTION_SYSTEM,
|
||||
DRAFT_SECTION_USER,
|
||||
OUTLINE_REVISE_SYSTEM,
|
||||
OUTLINE_REVISE_USER,
|
||||
SECTION_BLOCKED_MARKER,
|
||||
WRITER_MODE_LOOSE,
|
||||
WRITER_MODE_STRICT,
|
||||
)
|
||||
from ..state import AIWritingState, DraftArticle
|
||||
from ._agent_runner import (
|
||||
WRITER_AGENT_ID,
|
||||
WritingAgentUnavailable,
|
||||
@@ -36,7 +38,22 @@ from ._model_fallback import (
|
||||
emit_model_switch_event,
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import resolve_writing_model_name, _content_to_text, count_words, llm_json, reasoning_delta, ThinkTagParser, safe_get_stream_writer
|
||||
from ._utils import (
|
||||
ThinkTagParser,
|
||||
_content_to_text,
|
||||
count_words,
|
||||
llm_json,
|
||||
reasoning_delta,
|
||||
resolve_writing_model_name,
|
||||
safe_get_stream_writer,
|
||||
)
|
||||
from .sample_analyzer import render_sample_style_section
|
||||
from .writer_outline import (
|
||||
_materials_summary as _revise_materials_summary,
|
||||
)
|
||||
from .writer_outline import (
|
||||
_parse_outline as _revise_parse_outline,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -480,6 +497,7 @@ async def _stream_revise_section(
|
||||
existing_text=existing_text,
|
||||
note=note,
|
||||
)
|
||||
msg += render_sample_style_section(state)
|
||||
if add_references:
|
||||
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
||||
|
||||
@@ -807,6 +825,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
min_words=sec_min_words,
|
||||
max_words=sec_max_words,
|
||||
)
|
||||
user_msg += render_sample_style_section(state)
|
||||
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
|
||||
if add_references:
|
||||
user_msg += _CITATION_DIRECTIVE
|
||||
@@ -987,6 +1006,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
||||
existing_text=section_bufs[idx],
|
||||
target_words=sec_target,
|
||||
)
|
||||
msg += render_sample_style_section(state)
|
||||
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
|
||||
if add_references:
|
||||
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
||||
|
||||
+6
-4
@@ -35,6 +35,7 @@ from ._model_fallback import (
|
||||
fallback_chain,
|
||||
)
|
||||
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
|
||||
from .sample_analyzer import render_sample_style_section
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -362,6 +363,7 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
word_count_target=state.get("word_count_target", 800),
|
||||
materials_summary=_materials_summary(pkg),
|
||||
)
|
||||
sample_style_section = render_sample_style_section(state)
|
||||
if is_revise:
|
||||
system_prompt = OUTLINE_REVISE_SYSTEM
|
||||
revise_args = dict(
|
||||
@@ -369,14 +371,14 @@ async def writer_outline_node(state: AIWritingState, config: RunnableConfig) ->
|
||||
current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2),
|
||||
feedback=feedback,
|
||||
)
|
||||
user_prompt = OUTLINE_REVISE_USER.format(**revise_args)
|
||||
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args)
|
||||
user_prompt = OUTLINE_REVISE_USER.format(**revise_args) + sample_style_section
|
||||
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args) + sample_style_section
|
||||
else:
|
||||
system_prompt = OUTLINE_SYSTEM
|
||||
feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
|
||||
gen_args = dict(fmt_args, feedback_section=feedback_section)
|
||||
user_prompt = OUTLINE_USER.format(**gen_args)
|
||||
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args)
|
||||
user_prompt = OUTLINE_USER.format(**gen_args) + sample_style_section
|
||||
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args) + sample_style_section
|
||||
|
||||
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
|
||||
writer = safe_get_stream_writer()
|
||||
|
||||
+27
-1
@@ -1,5 +1,5 @@
|
||||
from typing import Annotated, List, Literal, Optional, TypedDict
|
||||
import operator
|
||||
from typing import Annotated, List, Literal, Optional, TypedDict
|
||||
|
||||
# ── 素材 ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -74,6 +74,23 @@ class DraftArticle(TypedDict):
|
||||
word_count: int
|
||||
revision_count: int
|
||||
|
||||
|
||||
# ── 样文仿写 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class SampleStyleProfile(TypedDict, total=False):
|
||||
summary: str
|
||||
article_type_guess: str
|
||||
tone: str
|
||||
structure: List[str]
|
||||
language_features: List[str]
|
||||
sentence_style: str
|
||||
opening_style: str
|
||||
ending_style: str
|
||||
imitation_guidance: List[str]
|
||||
avoid_copying: List[str]
|
||||
sample_outline: List[str]
|
||||
|
||||
|
||||
# ── 编辑审核 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
class ReviewIssue(TypedDict):
|
||||
@@ -109,6 +126,7 @@ class UserInterventionPayload(TypedDict, total=False):
|
||||
# ── 主状态 ────────────────────────────────────────────────────────────────────
|
||||
|
||||
WritingStatus = Literal[
|
||||
"analyzing_sample",
|
||||
"researching", "awaiting_material_confirm",
|
||||
"writing_outline", "awaiting_outline_confirm",
|
||||
"writing_draft", "awaiting_section_help", "awaiting_draft_confirm",
|
||||
@@ -128,6 +146,14 @@ class AIWritingState(TypedDict):
|
||||
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
|
||||
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
||||
user_outline_raw: str
|
||||
# 写作入口模式:normal=普通写作 / imitate=样文仿写。仿写模式下先分析 sample_text,
|
||||
# 得到 sample_style_profile,再将其注入大纲与草稿提示词。
|
||||
writing_mode_type: str
|
||||
sample_text: str
|
||||
sample_filename: str
|
||||
sample_style_profile: Optional[SampleStyleProfile]
|
||||
imitate_strength: str
|
||||
preserve_sample_structure: bool
|
||||
article_type: str
|
||||
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
|
||||
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
|
||||
|
||||
@@ -5,11 +5,15 @@
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from io import BytesIO
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from fastapi import UploadFile
|
||||
|
||||
from app.gateway.routers.ai_writing import _extract_sample_text
|
||||
from deerflow.agents.ai_writing.nodes import researcher as R
|
||||
from deerflow.agents.ai_writing.nodes import sample_analyzer as S
|
||||
from deerflow.agents.ai_writing.search.base import SearchResult
|
||||
|
||||
|
||||
@@ -107,3 +111,75 @@ async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
|
||||
# 不抛异常即证明没走 _SkillProviderShouldNotRun
|
||||
out = await R.researcher_node(_state(), {"configurable": {}})
|
||||
assert out["material_package"]["materials"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sample_analyzer_skips_normal_mode():
|
||||
out = await S.sample_analyzer_node({"writing_mode_type": "normal"}, {"configurable": {}})
|
||||
|
||||
assert out == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sample_analyzer_builds_style_profile(monkeypatch):
|
||||
async def fake_llm_json(*_args, **_kwargs):
|
||||
return {
|
||||
"summary": "短段落、评论式表达",
|
||||
"tone": "克制、坚定",
|
||||
"structure": ["先提出问题", "再分层论证"],
|
||||
"language_features": ["多用判断句"],
|
||||
"sentence_style": "短句为主",
|
||||
"opening_style": "开门见山",
|
||||
"ending_style": "总结升华",
|
||||
"imitation_guidance": ["保持评论式节奏"],
|
||||
"avoid_copying": ["不要照抄原句"],
|
||||
"sample_outline": ["问题提出", "原因分析"],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(S, "llm_json", fake_llm_json)
|
||||
|
||||
out = await S.sample_analyzer_node({
|
||||
"writing_mode_type": "imitate",
|
||||
"sample_text": "这是样文第一段。\n\n这是样文第二段。",
|
||||
"sample_filename": "demo.md",
|
||||
"user_intent": "写一篇新文章",
|
||||
}, {"configurable": {"model_name": "m1"}})
|
||||
|
||||
assert out["status"] == "researching"
|
||||
assert out["sample_style_profile"]["summary"] == "短段落、评论式表达"
|
||||
assert out["sample_style_profile"]["tone"] == "克制、坚定"
|
||||
assert any(e["type"] == "sample_profile_ready" for e in out["progress_events"])
|
||||
|
||||
|
||||
def test_render_sample_style_section_contains_copy_guardrails():
|
||||
section = S.render_sample_style_section({
|
||||
"writing_mode_type": "imitate",
|
||||
"imitate_strength": "strong",
|
||||
"preserve_sample_structure": True,
|
||||
"sample_style_profile": {
|
||||
"summary": "评论体",
|
||||
"tone": "正式",
|
||||
"structure": ["开头提出问题"],
|
||||
"language_features": ["短句"],
|
||||
"sentence_style": "长短句结合",
|
||||
"opening_style": "开门见山",
|
||||
"ending_style": "总结",
|
||||
"imitation_guidance": ["保留节奏"],
|
||||
"avoid_copying": ["不要照抄"],
|
||||
"sample_outline": ["引入", "展开"],
|
||||
},
|
||||
})
|
||||
|
||||
assert "样文仿写要求" in section
|
||||
assert "不得连续照抄样文原句" in section
|
||||
assert "评论体" in section
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_extract_sample_text_reads_txt_upload():
|
||||
upload = UploadFile(file=BytesIO(" 样文内容 ".encode("utf-8")), filename="sample.txt")
|
||||
|
||||
name, text = await _extract_sample_text(upload)
|
||||
|
||||
assert name == "sample.txt"
|
||||
assert text == "样文内容"
|
||||
Reference in new issue
Block a user