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 { useEffect, useMemo, useState } from 'react';
|
||||||
import { PenLine, SlidersHorizontal } from 'lucide-react';
|
import { FileText, Loader2, PenLine, SlidersHorizontal, Upload } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Textarea } from '@/components/ui/textarea';
|
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 type { AIWritingRequest } from '../../types/ai-writing';
|
||||||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||||||
import { useWritingForm } from '../../contexts/WritingFormContext';
|
import { useWritingForm } from '../../contexts/WritingFormContext';
|
||||||
|
import { extractAIWritingSample } from '../../api/ai-writing-sample';
|
||||||
|
|
||||||
interface AIWritingFormProps {
|
interface AIWritingFormProps {
|
||||||
onSubmit: (req: AIWritingRequest) => void;
|
onSubmit: (req: AIWritingRequest) => void;
|
||||||
@@ -41,7 +42,9 @@ const INPUT_CLASS =
|
|||||||
const DEFAULT_KEYWORD_COUNT = 1;
|
const DEFAULT_KEYWORD_COUNT = 1;
|
||||||
const DEFAULT_AUDIENCE = '普通读者';
|
const DEFAULT_AUDIENCE = '普通读者';
|
||||||
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
|
type MaterialSource = 'general' | 'knowledge_base' | 'notebook';
|
||||||
|
type WritingModeType = 'normal' | 'imitate';
|
||||||
const MAX_INTENT_LENGTH = 500;
|
const MAX_INTENT_LENGTH = 500;
|
||||||
|
const MAX_SAMPLE_TEXT_LENGTH = 120000;
|
||||||
|
|
||||||
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; desc: string }> = [
|
||||||
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
|
{ value: 'general', label: '通用检索', desc: '用素材收集专家配置的技能检索(默认:知识库检索)' },
|
||||||
@@ -49,6 +52,17 @@ const MATERIAL_SOURCE_OPTIONS: Array<{ value: MaterialSource; label: string; des
|
|||||||
{ value: 'notebook', label: '我的空间', desc: '从空间中检索素材' },
|
{ 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_LABEL_CLASS = 'text-[16px] font-semibold leading-none text-[#222] dark:text-[#AACEFF]';
|
||||||
const FIELD_TRIGGER_CLASS =
|
const FIELD_TRIGGER_CLASS =
|
||||||
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm';
|
||||||
@@ -61,6 +75,11 @@ export function AIWritingForm({
|
|||||||
}: AIWritingFormProps) {
|
}: AIWritingFormProps) {
|
||||||
const {
|
const {
|
||||||
intent, setIntent,
|
intent, setIntent,
|
||||||
|
writingModeType, setWritingModeType,
|
||||||
|
sampleText, setSampleText,
|
||||||
|
sampleFilename, setSampleFilename,
|
||||||
|
imitateStrength, setImitateStrength,
|
||||||
|
preserveSampleStructure, setPreserveSampleStructure,
|
||||||
showOutline, setShowOutline,
|
showOutline, setShowOutline,
|
||||||
outlineText, setOutlineText,
|
outlineText, setOutlineText,
|
||||||
articleType, setArticleType,
|
articleType, setArticleType,
|
||||||
@@ -77,6 +96,10 @@ export function AIWritingForm({
|
|||||||
modelName, setModelName,
|
modelName, setModelName,
|
||||||
} = useWritingForm();
|
} = useWritingForm();
|
||||||
|
|
||||||
|
const [sampleUploading, setSampleUploading] = useState(false);
|
||||||
|
const [sampleMessage, setSampleMessage] = useState('');
|
||||||
|
const [sampleError, setSampleError] = useState('');
|
||||||
|
|
||||||
const { models, isLoading: modelsLoading } = useModels();
|
const { models, isLoading: modelsLoading } = useModels();
|
||||||
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
const { notebooks, isLoading: notebooksLoading } = useStudioNotebooks(!studioToken);
|
||||||
const { sources, isLoading: sourcesLoading } = useNotebookSources(selectedNotebookId, 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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!intent.trim()) return;
|
if (!intent.trim()) return;
|
||||||
|
if (writingModeType === 'imitate' && !sampleText.trim()) {
|
||||||
|
setSampleError('请上传样文或粘贴样文文本');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||||||
onSubmit({
|
onSubmit({
|
||||||
userIntent: intent.trim(),
|
userIntent: intent.trim(),
|
||||||
userOutline: outlineText.trim() || undefined,
|
userOutline: outlineText.trim() || undefined,
|
||||||
|
writingModeType,
|
||||||
|
sampleText: writingModeType === 'imitate' ? sampleText.trim() : undefined,
|
||||||
|
sampleFilename: writingModeType === 'imitate' ? sampleFilename || '粘贴文本' : undefined,
|
||||||
|
imitateStrength,
|
||||||
|
preserveSampleStructure,
|
||||||
articleType,
|
articleType,
|
||||||
targetAudience: hideAdvancedFields
|
targetAudience: hideAdvancedFields
|
||||||
? DEFAULT_AUDIENCE
|
? DEFAULT_AUDIENCE
|
||||||
@@ -190,6 +241,106 @@ export function AIWritingForm({
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</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="mt-4 space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className={FIELD_LABEL_CLASS}>
|
<label className={FIELD_LABEL_CLASS}>
|
||||||
@@ -428,7 +579,7 @@ export function AIWritingForm({
|
|||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
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]"
|
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" />
|
<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' },
|
in_progress: { label: '未完成', color: 'text-slate-400' },
|
||||||
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
|
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
|
||||||
background: { label: '挂起中', color: 'text-sky-500' },
|
background: { label: '挂起中', color: 'text-sky-500' },
|
||||||
|
analyzing_sample: { label: '样文分析', color: 'text-cyan-500' },
|
||||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||||
done: { label: '完成', color: 'text-green-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> = {
|
const STATUS_LABEL: Record<string, string> = {
|
||||||
idle: '就绪',
|
idle: '就绪',
|
||||||
|
analyzing_sample: '样文分析师分析文风中…',
|
||||||
researching: '素材收集专家搜集素材中…',
|
researching: '素材收集专家搜集素材中…',
|
||||||
awaiting_material_confirm: '等待确认素材',
|
awaiting_material_confirm: '等待确认素材',
|
||||||
writing_outline: '作家规划大纲中…',
|
writing_outline: '作家规划大纲中…',
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ interface AIWritingTimelineProps {
|
|||||||
const DISPLAYED_TYPES = new Set([
|
const DISPLAYED_TYPES = new Set([
|
||||||
'step_start',
|
'step_start',
|
||||||
'step_done',
|
'step_done',
|
||||||
|
'sample_profile_ready',
|
||||||
'materials_ready',
|
'materials_ready',
|
||||||
'outline_ready',
|
'outline_ready',
|
||||||
'draft_ready',
|
'draft_ready',
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export function DraftSync({ status, markdown, title }: DraftSyncProps) {
|
|||||||
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
||||||
// writing_draft / revising 才只读。
|
// writing_draft / revising 才只读。
|
||||||
const generating =
|
const generating =
|
||||||
|
status === 'analyzing_sample' ||
|
||||||
status === 'researching' ||
|
status === 'researching' ||
|
||||||
status === 'writing_outline' ||
|
status === 'writing_outline' ||
|
||||||
streaming;
|
streaming;
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const AGENT_COLOR: Record<string, string> = {
|
const AGENT_COLOR: Record<string, string> = {
|
||||||
|
样文分析师: 'text-cyan-500',
|
||||||
素材收集专家: 'text-blue-500',
|
素材收集专家: 'text-blue-500',
|
||||||
作家: 'text-purple-500',
|
作家: 'text-purple-500',
|
||||||
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
|
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
|
||||||
|
|||||||
@@ -140,11 +140,18 @@ export function WritingSetupChat({
|
|||||||
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
||||||
const skipResearch = formCtx?.skipResearch ?? false;
|
const skipResearch = formCtx?.skipResearch ?? false;
|
||||||
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
|
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
|
||||||
|
const writingModeType = formCtx?.writingModeType === 'imitate' ? 'imitate' : 'normal';
|
||||||
|
const sampleText = formCtx?.sampleText?.trim() ?? '';
|
||||||
|
|
||||||
const request: AIWritingRequest = {
|
const request: AIWritingRequest = {
|
||||||
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
||||||
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
|
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
|
||||||
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
|
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',
|
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
|
||||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
|
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
|
||||||
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
|
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
|
||||||
@@ -170,6 +177,10 @@ export function WritingSetupChat({
|
|||||||
toast.error('写作意图为空,请先补充意图');
|
toast.error('写作意图为空,请先补充意图');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (writingModeType === 'imitate' && !sampleText) {
|
||||||
|
toast.error('仿写模式需要先上传或粘贴样文');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await aiWriting.startWriting(request);
|
await aiWriting.startWriting(request);
|
||||||
},
|
},
|
||||||
[aiWriting, applyProposal, formCtx, chatModel],
|
[aiWriting, applyProposal, formCtx, chatModel],
|
||||||
|
|||||||
@@ -217,10 +217,10 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
|||||||
|
|
||||||
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
|
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 (
|
return (
|
||||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||||
<MaterialsSkeleton researchProgress={researchProgress} />
|
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -436,6 +436,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
user_intent: request.userIntent,
|
user_intent: request.userIntent,
|
||||||
// 用户自带大纲原文(留空则后端走自动规划)
|
// 用户自带大纲原文(留空则后端走自动规划)
|
||||||
user_outline_raw: request.userOutline?.trim() || '',
|
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: request.articleType,
|
||||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
||||||
article_type_detail: request.articleTypeDetail?.trim() || '',
|
article_type_detail: request.articleTypeDetail?.trim() || '',
|
||||||
@@ -452,7 +458,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
|||||||
notebook_source_ids: request.noteSourceIds ?? null,
|
notebook_source_ids: request.noteSourceIds ?? null,
|
||||||
revision_count: 0,
|
revision_count: 0,
|
||||||
max_revisions: 3,
|
max_revisions: 3,
|
||||||
status: 'researching',
|
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
|
||||||
drafts: [],
|
drafts: [],
|
||||||
progress_events: [],
|
progress_events: [],
|
||||||
material_package: null,
|
material_package: null,
|
||||||
|
|||||||
@@ -26,6 +26,16 @@ export interface WritingFormValue {
|
|||||||
setShowOutline: (v: boolean) => void;
|
setShowOutline: (v: boolean) => void;
|
||||||
outlineText: string;
|
outlineText: string;
|
||||||
setOutlineText: (v: string) => void;
|
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;
|
articleType: string;
|
||||||
setArticleType: (v: string) => void;
|
setArticleType: (v: string) => void;
|
||||||
wordCountInput: string;
|
wordCountInput: string;
|
||||||
@@ -83,6 +93,11 @@ export function WritingFormProvider({
|
|||||||
const [formExpanded, setFormExpanded] = useState(false);
|
const [formExpanded, setFormExpanded] = useState(false);
|
||||||
const [showOutline, setShowOutline] = useState(true);
|
const [showOutline, setShowOutline] = useState(true);
|
||||||
const [outlineText, setOutlineText] = useState('');
|
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 [articleType, setArticleType] = useState('analysis');
|
||||||
const [wordCountInput, setWordCountInput] = useState('800');
|
const [wordCountInput, setWordCountInput] = useState('800');
|
||||||
const [audience, setAudience] = useState('普通读者');
|
const [audience, setAudience] = useState('普通读者');
|
||||||
@@ -123,6 +138,11 @@ export function WritingFormProvider({
|
|||||||
formExpanded, setFormExpanded,
|
formExpanded, setFormExpanded,
|
||||||
showOutline, setShowOutline,
|
showOutline, setShowOutline,
|
||||||
outlineText, setOutlineText,
|
outlineText, setOutlineText,
|
||||||
|
writingModeType, setWritingModeType,
|
||||||
|
sampleText, setSampleText,
|
||||||
|
sampleFilename, setSampleFilename,
|
||||||
|
imitateStrength, setImitateStrength,
|
||||||
|
preserveSampleStructure, setPreserveSampleStructure,
|
||||||
articleType, setArticleType,
|
articleType, setArticleType,
|
||||||
wordCountInput, setWordCountInput,
|
wordCountInput, setWordCountInput,
|
||||||
audience, setAudience,
|
audience, setAudience,
|
||||||
@@ -135,7 +155,7 @@ export function WritingFormProvider({
|
|||||||
modelName, setModelName,
|
modelName, setModelName,
|
||||||
applyProposal,
|
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>;
|
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ const INITIAL_STATE: AIWritingState = {
|
|||||||
currentDraftMarkdown: '',
|
currentDraftMarkdown: '',
|
||||||
currentDraftTitle: '',
|
currentDraftTitle: '',
|
||||||
reviewResult: null,
|
reviewResult: null,
|
||||||
|
sampleStyleProfile: null,
|
||||||
revisionCount: 0,
|
revisionCount: 0,
|
||||||
currentPause: null,
|
currentPause: null,
|
||||||
completedInterventions: [],
|
completedInterventions: [],
|
||||||
@@ -101,7 +102,11 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
|||||||
return action.events.reduce(handleEvent, prev);
|
return action.events.reduce(handleEvent, prev);
|
||||||
|
|
||||||
case 'start':
|
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':
|
case 'set_session':
|
||||||
return { ...prev, sessionId: action.sessionId };
|
return { ...prev, sessionId: action.sessionId };
|
||||||
@@ -239,6 +244,13 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
|||||||
case 'research_progress':
|
case 'research_progress':
|
||||||
return { ...base, researchProgress: event.message ?? '' };
|
return { ...base, researchProgress: event.message ?? '' };
|
||||||
|
|
||||||
|
case 'sample_profile_ready':
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
sampleStyleProfile: event.sampleStyleProfile ?? null,
|
||||||
|
status: 'researching',
|
||||||
|
};
|
||||||
|
|
||||||
case 'resumed':
|
case 'resumed':
|
||||||
return { ...base, currentPause: null };
|
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.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
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 ('category' in out) out.reviewItemCategory = out.category;
|
||||||
if ('score' in out) out.reviewItemScore = out.score;
|
if ('score' in out) out.reviewItemScore = out.score;
|
||||||
if ('issues' in out && Array.isArray(out.issues)) {
|
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')
|
const draft = (values.current_draft && typeof values.current_draft === 'object')
|
||||||
? (values.current_draft as Record<string, unknown>) : null;
|
? (values.current_draft as Record<string, unknown>) : null;
|
||||||
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
|
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 {
|
return {
|
||||||
sessionId,
|
sessionId,
|
||||||
@@ -764,6 +796,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
|
|||||||
currentDraftTitle: String(draft?.title ?? ''),
|
currentDraftTitle: String(draft?.title ?? ''),
|
||||||
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
|
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
|
||||||
reviewResult: normalizeReviewResult(values.review_result),
|
reviewResult: normalizeReviewResult(values.review_result),
|
||||||
|
sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null,
|
||||||
progressEvents,
|
progressEvents,
|
||||||
completedInterventions,
|
completedInterventions,
|
||||||
};
|
};
|
||||||
@@ -799,12 +832,14 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
|||||||
|
|
||||||
const allowed: ProgressEventType[] = [
|
const allowed: ProgressEventType[] = [
|
||||||
'intent_chunk', 'intent_ready',
|
'intent_chunk', 'intent_ready',
|
||||||
|
'sample_profile_ready',
|
||||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||||
'review_chunk', 'review_item_done',
|
'review_chunk', 'review_item_done',
|
||||||
'skill_search_step', 'skill_select_chunk',
|
'skill_search_step', 'skill_select_chunk',
|
||||||
'model_switch',
|
'model_switch',
|
||||||
];
|
];
|
||||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
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)
|
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
|
||||||
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
|
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 = {
|
export type AIWritingValues = {
|
||||||
user_intent?: string;
|
user_intent?: string;
|
||||||
user_outline_raw?: 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?: string;
|
||||||
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
||||||
article_type_detail?: string;
|
article_type_detail?: string;
|
||||||
|
|||||||
@@ -135,10 +135,27 @@ export interface ReviewResult {
|
|||||||
revisionNotes: ReviewIssue[];
|
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 =
|
export type WritingStatus =
|
||||||
| "idle"
|
| "idle"
|
||||||
|
| "analyzing_sample"
|
||||||
| "researching"
|
| "researching"
|
||||||
| "awaiting_material_confirm"
|
| "awaiting_material_confirm"
|
||||||
| "writing_outline"
|
| "writing_outline"
|
||||||
@@ -158,6 +175,7 @@ export type ProgressEventType =
|
|||||||
| "connected"
|
| "connected"
|
||||||
| "step_start"
|
| "step_start"
|
||||||
| "step_done"
|
| "step_done"
|
||||||
|
| "sample_profile_ready"
|
||||||
| "intent_chunk"
|
| "intent_chunk"
|
||||||
| "intent_ready"
|
| "intent_ready"
|
||||||
| "research_progress"
|
| "research_progress"
|
||||||
@@ -226,6 +244,8 @@ export interface ProgressEvent {
|
|||||||
sectionIndex?: number;
|
sectionIndex?: number;
|
||||||
sectionTitle?: string;
|
sectionTitle?: string;
|
||||||
};
|
};
|
||||||
|
// sample_profile_ready:样文仿写的文风画像
|
||||||
|
sampleStyleProfile?: SampleStyleProfile;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||||
@@ -253,6 +273,13 @@ export interface AIWritingRequest {
|
|||||||
userIntent: string;
|
userIntent: string;
|
||||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||||
userOutline?: string;
|
userOutline?: string;
|
||||||
|
/** 写作入口模式:normal=普通写作 / imitate=样文仿写 */
|
||||||
|
writingModeType?: "normal" | "imitate";
|
||||||
|
/** 样文原文(上传提取或直接粘贴),仅样文仿写模式使用 */
|
||||||
|
sampleText?: string;
|
||||||
|
sampleFilename?: string;
|
||||||
|
imitateStrength?: "light" | "medium" | "strong";
|
||||||
|
preserveSampleStructure?: boolean;
|
||||||
articleType: string;
|
articleType: string;
|
||||||
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
||||||
articleTypeDetail?: string;
|
articleTypeDetail?: string;
|
||||||
@@ -356,6 +383,7 @@ export interface AIWritingState {
|
|||||||
currentDraftMarkdown: string;
|
currentDraftMarkdown: string;
|
||||||
currentDraftTitle: string;
|
currentDraftTitle: string;
|
||||||
reviewResult: ReviewResult | null;
|
reviewResult: ReviewResult | null;
|
||||||
|
sampleStyleProfile: SampleStyleProfile | null;
|
||||||
revisionCount: number;
|
revisionCount: number;
|
||||||
currentPause: ProgressEvent | null;
|
currentPause: ProgressEvent | null;
|
||||||
completedInterventions: CompletedIntervention[];
|
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`.
|
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-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 "检不出".
|
**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 |
|
| `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 |
|
| `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 |
|
| `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
|
### Custom Agents
|
||||||
|
|
||||||
|
|||||||
@@ -13,20 +13,30 @@ GET /api/ai-writing/article-types 列出所有文章类型
|
|||||||
POST /api/ai-writing/article-types 新增文章类型
|
POST /api/ai-writing/article-types 新增文章类型
|
||||||
PATCH /api/ai-writing/article-types/{id} 更新文章类型
|
PATCH /api/ai-writing/article-types/{id} 更新文章类型
|
||||||
DELETE /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)手动清理会话
|
POST /api/ai-writing/admin/cleanup (admin)手动清理会话
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
|
||||||
from pydantic import BaseModel, Field
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/ai-writing", tags=["ai-writing"])
|
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):
|
class RenameSessionRequest(BaseModel):
|
||||||
@@ -67,6 +77,13 @@ class SaveTranscriptRequest(BaseModel):
|
|||||||
transcript: dict
|
transcript: dict
|
||||||
|
|
||||||
|
|
||||||
|
class SampleExtractResponse(BaseModel):
|
||||||
|
filename: str
|
||||||
|
text: str
|
||||||
|
char_count: int
|
||||||
|
truncated: bool = False
|
||||||
|
|
||||||
|
|
||||||
class ArticleTypeRequest(BaseModel):
|
class ArticleTypeRequest(BaseModel):
|
||||||
key: str
|
key: str
|
||||||
label: str
|
label: str
|
||||||
@@ -154,6 +171,52 @@ class SessionResponse(BaseModel):
|
|||||||
updated_at: str
|
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])
|
@router.get("/sessions", response_model=list[SessionResponse])
|
||||||
async def list_sessions(request: Request):
|
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.graph import END, StateGraph
|
||||||
from langgraph.types import interrupt
|
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.intent_parser import intent_parser_node
|
||||||
from .nodes.researcher import researcher_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.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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -414,6 +415,7 @@ def make_ai_writing_graph(config=None):
|
|||||||
"""
|
"""
|
||||||
g = StateGraph(AIWritingState)
|
g = StateGraph(AIWritingState)
|
||||||
|
|
||||||
|
g.add_node("sample_analyzer", sample_analyzer_node)
|
||||||
g.add_node("intent_parser", intent_parser_node)
|
g.add_node("intent_parser", intent_parser_node)
|
||||||
g.add_node("researcher", researcher_node)
|
g.add_node("researcher", researcher_node)
|
||||||
g.add_node("pause_material", pause_material_confirm)
|
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("editor", editor_node)
|
||||||
g.add_node("pause_review", pause_review_confirm)
|
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_edge("intent_parser", "researcher")
|
||||||
g.add_conditional_edges("researcher", after_researcher,
|
g.add_conditional_edges("researcher", after_researcher,
|
||||||
{"pause_material": "pause_material", "writer_draft": "writer_draft", "writer_outline": "writer_outline"})
|
{"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.messages import HumanMessage, SystemMessage
|
||||||
from langchain_core.runnables import RunnableConfig
|
from langchain_core.runnables import RunnableConfig
|
||||||
|
|
||||||
from ..state import AIWritingState, DraftArticle
|
|
||||||
from ..prompts.writer_prompts import (
|
from ..prompts.writer_prompts import (
|
||||||
DRAFT_SECTION_SYSTEM, DRAFT_SECTION_USER, DRAFT_EXPAND_USER, DRAFT_COMPRESS_USER,
|
DRAFT_COMPRESS_USER,
|
||||||
DRAFT_REVISE_SCOPE_SYSTEM, DRAFT_REVISE_SCOPE_USER, DRAFT_REVISE_SECTION_USER,
|
DRAFT_EXPAND_USER,
|
||||||
OUTLINE_REVISE_SYSTEM, OUTLINE_REVISE_USER,
|
DRAFT_REVISE_SCOPE_SYSTEM,
|
||||||
WRITER_MODE_LOOSE, WRITER_MODE_STRICT, SECTION_BLOCKED_MARKER,
|
DRAFT_REVISE_SCOPE_USER,
|
||||||
)
|
DRAFT_REVISE_SECTION_USER,
|
||||||
# 复用大纲节点的解析/素材摘要:修订模式下先据意见更新大纲(含标题),再重写正文。
|
DRAFT_SECTION_SYSTEM,
|
||||||
# writer_outline 不反向 import 本模块,无循环。
|
DRAFT_SECTION_USER,
|
||||||
from .writer_outline import (
|
OUTLINE_REVISE_SYSTEM,
|
||||||
_parse_outline as _revise_parse_outline,
|
OUTLINE_REVISE_USER,
|
||||||
_materials_summary as _revise_materials_summary,
|
SECTION_BLOCKED_MARKER,
|
||||||
|
WRITER_MODE_LOOSE,
|
||||||
|
WRITER_MODE_STRICT,
|
||||||
)
|
)
|
||||||
|
from ..state import AIWritingState, DraftArticle
|
||||||
from ._agent_runner import (
|
from ._agent_runner import (
|
||||||
WRITER_AGENT_ID,
|
WRITER_AGENT_ID,
|
||||||
WritingAgentUnavailable,
|
WritingAgentUnavailable,
|
||||||
@@ -36,7 +38,22 @@ from ._model_fallback import (
|
|||||||
emit_model_switch_event,
|
emit_model_switch_event,
|
||||||
fallback_chain,
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -480,6 +497,7 @@ async def _stream_revise_section(
|
|||||||
existing_text=existing_text,
|
existing_text=existing_text,
|
||||||
note=note,
|
note=note,
|
||||||
)
|
)
|
||||||
|
msg += render_sample_style_section(state)
|
||||||
if add_references:
|
if add_references:
|
||||||
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
||||||
|
|
||||||
@@ -807,6 +825,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
min_words=sec_min_words,
|
min_words=sec_min_words,
|
||||||
max_words=sec_max_words,
|
max_words=sec_max_words,
|
||||||
)
|
)
|
||||||
|
user_msg += render_sample_style_section(state)
|
||||||
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
|
# 开启参考文献时,追加角标引用要求(两条路径——内置 agent 与旧路径——共用 user_msg)。
|
||||||
if add_references:
|
if add_references:
|
||||||
user_msg += _CITATION_DIRECTIVE
|
user_msg += _CITATION_DIRECTIVE
|
||||||
@@ -987,6 +1006,7 @@ async def writer_draft_node(state: AIWritingState, config: RunnableConfig) -> di
|
|||||||
existing_text=section_bufs[idx],
|
existing_text=section_bufs[idx],
|
||||||
target_words=sec_target,
|
target_words=sec_target,
|
||||||
)
|
)
|
||||||
|
msg += render_sample_style_section(state)
|
||||||
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
|
# 扩写/压缩时保留正文里已有的引用角标 [n],避免重排后文献缺失。
|
||||||
if add_references:
|
if add_references:
|
||||||
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
msg += "\n\n注意:保留原文中已有的引用角标(形如 [1]、[2]),不要删除或改动它们。"
|
||||||
|
|||||||
+6
-4
@@ -35,6 +35,7 @@ from ._model_fallback import (
|
|||||||
fallback_chain,
|
fallback_chain,
|
||||||
)
|
)
|
||||||
from ._utils import ThinkTagParser, _content_to_text, parse_json, reasoning_delta, resolve_writing_model_name, safe_get_stream_writer
|
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__)
|
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),
|
word_count_target=state.get("word_count_target", 800),
|
||||||
materials_summary=_materials_summary(pkg),
|
materials_summary=_materials_summary(pkg),
|
||||||
)
|
)
|
||||||
|
sample_style_section = render_sample_style_section(state)
|
||||||
if is_revise:
|
if is_revise:
|
||||||
system_prompt = OUTLINE_REVISE_SYSTEM
|
system_prompt = OUTLINE_REVISE_SYSTEM
|
||||||
revise_args = dict(
|
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),
|
current_outline=json.dumps(current_outline, ensure_ascii=False, indent=2),
|
||||||
feedback=feedback,
|
feedback=feedback,
|
||||||
)
|
)
|
||||||
user_prompt = 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)
|
agent_payload = _AGENT_OUTLINE_REVISE_USER.format(**revise_args) + sample_style_section
|
||||||
else:
|
else:
|
||||||
system_prompt = OUTLINE_SYSTEM
|
system_prompt = OUTLINE_SYSTEM
|
||||||
feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
|
feedback_section = f"\n重新规划要求:{feedback}" if feedback else ""
|
||||||
gen_args = dict(fmt_args, feedback_section=feedback_section)
|
gen_args = dict(fmt_args, feedback_section=feedback_section)
|
||||||
user_prompt = OUTLINE_USER.format(**gen_args)
|
user_prompt = OUTLINE_USER.format(**gen_args) + sample_style_section
|
||||||
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args)
|
agent_payload = _AGENT_OUTLINE_USER.format(**gen_args) + sample_style_section
|
||||||
|
|
||||||
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
|
# 流式推送:阶段 2 起统一走 LangGraph 原生 custom 通道(get_stream_writer)。
|
||||||
writer = safe_get_stream_writer()
|
writer = safe_get_stream_writer()
|
||||||
|
|||||||
+27
-1
@@ -1,5 +1,5 @@
|
|||||||
from typing import Annotated, List, Literal, Optional, TypedDict
|
|
||||||
import operator
|
import operator
|
||||||
|
from typing import Annotated, List, Literal, Optional, TypedDict
|
||||||
|
|
||||||
# ── 素材 ──────────────────────────────────────────────────────────────────────
|
# ── 素材 ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -74,6 +74,23 @@ class DraftArticle(TypedDict):
|
|||||||
word_count: int
|
word_count: int
|
||||||
revision_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):
|
class ReviewIssue(TypedDict):
|
||||||
@@ -109,6 +126,7 @@ class UserInterventionPayload(TypedDict, total=False):
|
|||||||
# ── 主状态 ────────────────────────────────────────────────────────────────────
|
# ── 主状态 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
WritingStatus = Literal[
|
WritingStatus = Literal[
|
||||||
|
"analyzing_sample",
|
||||||
"researching", "awaiting_material_confirm",
|
"researching", "awaiting_material_confirm",
|
||||||
"writing_outline", "awaiting_outline_confirm",
|
"writing_outline", "awaiting_outline_confirm",
|
||||||
"writing_draft", "awaiting_section_help", "awaiting_draft_confirm",
|
"writing_draft", "awaiting_section_help", "awaiting_draft_confirm",
|
||||||
@@ -128,6 +146,14 @@ class AIWritingState(TypedDict):
|
|||||||
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
|
# 用户在写作表单粘贴的「已有大纲」原文(可选)。非空时 writer_outline 跳过 LLM 规划,
|
||||||
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
# 直接解析这份大纲并按用户结构生成,做到「完全按我的大纲写」。留空走正常自动规划。
|
||||||
user_outline_raw: str
|
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: str
|
||||||
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
|
# 当 article_type 是「已有类型库里没有」的新类型时,配置助手识别出的一句话特征说明。
|
||||||
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
|
# 仅本次写作临时使用(不入库):在大纲生成时作为 hint 附在类型后,影响全文结构与侧重。
|
||||||
|
|||||||
@@ -5,11 +5,15 @@
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
import pytest
|
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 researcher as R
|
||||||
|
from deerflow.agents.ai_writing.nodes import sample_analyzer as S
|
||||||
from deerflow.agents.ai_writing.search.base import SearchResult
|
from deerflow.agents.ai_writing.search.base import SearchResult
|
||||||
|
|
||||||
|
|
||||||
@@ -107,3 +111,75 @@ async def test_knowledge_base_does_not_orchestrate_skills(monkeypatch):
|
|||||||
# 不抛异常即证明没走 _SkillProviderShouldNotRun
|
# 不抛异常即证明没走 _SkillProviderShouldNotRun
|
||||||
out = await R.researcher_node(_state(), {"configurable": {}})
|
out = await R.researcher_node(_state(), {"configurable": {}})
|
||||||
assert out["material_package"]["materials"]
|
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