feat: add ai writing imitation mode

This commit is contained in:
董宏凯 committed 2026-07-01 11:21:41 +08:00
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,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"})
@@ -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,
}
@@ -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]),不要删除或改动它们。"
@@ -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()
@@ -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 == "样文内容"