From bb6b9bd84b55544433d575b52fd1c826d681603c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=91=A3=E5=AE=8F=E5=87=AF?= Date: Wed, 1 Jul 2026 11:21:41 +0800 Subject: [PATCH] feat: add ai writing imitation mode --- .../src/open-canvas/api/ai-writing-sample.ts | 23 ++ .../components/ai-writing/AIWritingForm.tsx | 157 +++++++++++++- .../ai-writing/AIWritingHistoryDropdown.tsx | 1 + .../components/ai-writing/AIWritingPanel.tsx | 1 + .../ai-writing/AIWritingTimeline.tsx | 1 + .../components/ai-writing/DraftSync.tsx | 1 + .../components/ai-writing/TimelineStep.tsx | 1 + .../ai-writing/WritingSetupChat.tsx | 11 + .../ai-writing/WritingSkeletonCard.tsx | 4 +- .../open-canvas/contexts/AIWritingContext.tsx | 8 +- .../contexts/WritingFormContext.tsx | 22 +- .../open-canvas/hooks/useAIWritingStream.ts | 43 +++- .../src/open-canvas/types/ai-writing.ts | 28 +++ offline-backend-20260512/backend/CLAUDE.md | 2 + offline-backend-20260512/backend/README.md | 3 + .../backend/app/gateway/routers/ai_writing.py | 65 +++++- .../deerflow/agents/ai_writing/graph.py | 11 +- .../ai_writing/nodes/sample_analyzer.py | 204 ++++++++++++++++++ .../agents/ai_writing/nodes/writer_draft.py | 44 ++-- .../agents/ai_writing/nodes/writer_outline.py | 10 +- .../deerflow/agents/ai_writing/state.py | 28 ++- .../tests/test_ai_writing_knowledge_base.py | 76 +++++++ 22 files changed, 714 insertions(+), 30 deletions(-) create mode 100644 frontend-web/src/open-canvas/api/ai-writing-sample.ts create mode 100644 offline-backend-20260512/backend/packages/harness/deerflow/agents/ai_writing/nodes/sample_analyzer.py diff --git a/frontend-web/src/open-canvas/api/ai-writing-sample.ts b/frontend-web/src/open-canvas/api/ai-writing-sample.ts new file mode 100644 index 00000000..d438fa82 --- /dev/null +++ b/frontend-web/src/open-canvas/api/ai-writing-sample.ts @@ -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 { + 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(); +} diff --git a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx index 9b95b7ff..2b456993 100644 --- a/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx +++ b/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx @@ -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) { + 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({ +
+ +
+ {WRITING_MODE_OPTIONS.map((opt) => ( + + ))} +
+
+ + {writingModeType === 'imitate' && ( +
+
+ + +
+ {sampleFilename && ( +
+ + {sampleFilename} +
+ )} +