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'; import { cn } from '@/lib/utils'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { useModels } from '@/core/models/hooks'; 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'; import { ResearchTemplatesPanel } from './ResearchTemplatesPanel'; interface AIWritingFormProps { onSubmit: (req: AIWritingRequest) => void; disabled?: boolean; /** workspace 入口:隐藏严格模式、目标读者 */ hideAdvancedFields?: boolean; /** 预选空间 ID(传入时自动切换素材来源为「我的空间」) */ initialNotebookId?: number; /** 预选文档 ID 列表 */ initialDocumentIds?: number[]; /** 外部直接提供的 studio bearer token,跳过 getTokenByEmail */ studioToken?: string; } const INPUT_CLASS = 'flex h-12 w-full rounded-xl border border-[#e8dedd] bg-white px-4 py-2 text-sm shadow-sm ' + 'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' + 'focus-visible:ring-ring disabled:opacity-50 ' + 'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF] dark:placeholder:text-[#566B7F] ' + 'dark:[color-scheme:dark] dark:focus-visible:border-[#3b82f6] dark:focus-visible:ring-[#3b82f6]/30'; 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: '素材收集专家按配置技能检索' }, { value: 'knowledge_base', 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_TRIGGER_CLASS = 'h-12 w-full rounded-xl border-[#e8dedd] bg-white text-sm shadow-sm'; export function AIWritingForm({ onSubmit, disabled, hideAdvancedFields = false, studioToken, }: AIWritingFormProps) { const { intent, setIntent, writingModeType, setWritingModeType, sampleText, setSampleText, sampleFilename, setSampleFilename, imitateStrength, setImitateStrength, preserveSampleStructure, setPreserveSampleStructure, showOutline, setShowOutline, outlineText, setOutlineText, articleType, setArticleType, audience, setAudience, wordCountInput, setWordCountInput, strictMode, setStrictMode, addReferences, setAddReferences, materialSource, setMaterialSource, skipResearch, setSkipResearch, // 笔记来源(search_space_id + document_id 列表)已上提到 WritingFormContext, // 以便左栏「笔记本」点击可直接设为素材来源(需求 0616 #5)。 selectedNotebookId, setSelectedNotebookId, selectedDocumentIds, setSelectedDocumentIds, 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); const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes(); // 首次 /api/models 返回后默认选第一项;配置变更导致当前值不在列表时也回退到首项。 useEffect(() => { if (models.length === 0) return; const stillExists = models.some((m) => m.name === modelName); if (!modelName || !stillExists) { setModelName(models[0].name); } }, [models, modelName]); const selectedModel = useMemo( () => models.find((m) => m.name === modelName) ?? models[0], [models, modelName], ); // 初始化时加载文章类型 useEffect(() => { refreshTypes(); }, [refreshTypes]); // 类型加载完成后:若当前选中值不在列表中则切换为第一项 useEffect(() => { if (articleTypes.length === 0) return; const keys = articleTypes.map((t) => t.key); if (!keys.includes(articleType)) { const first = articleTypes[0]; setArticleType(first.key); setWordCountInput(String(first.default_word_count)); } }, [articleTypes]); // eslint-disable-line react-hooks/exhaustive-deps // 切换文章类型时自动填入该类型的默认字数 function handleArticleTypeChange(key: string) { setArticleType(key); const found = articleTypes.find((t) => t.key === key); if (found) { setWordCountInput(String(found.default_word_count)); } } function handleWordCountBlur() { const num = parseInt(wordCountInput, 10); if (isNaN(num) || num < 100) { setWordCountInput('800'); } else { setWordCountInput(String(num)); } } function handleNotebookChange(val: string) { setSelectedNotebookId(val ? Number(val) : null); setSelectedDocumentIds([]); } function handleMaterialSourceChange(val: MaterialSource) { setMaterialSource(val); // studioToken 模式下空间和文档已锁定,切换素材来源时不清空。 if (val !== 'notebook' && !studioToken) { setSelectedNotebookId(null); setSelectedDocumentIds([]); } } function toggleDocument(docId: number) { setSelectedDocumentIds( selectedDocumentIds.includes(docId) ? selectedDocumentIds.filter((id) => id !== docId) : [...selectedDocumentIds, docId], ); } 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 : audience.trim() || DEFAULT_AUDIENCE, wordCountTarget: wordCount, keywordCount: DEFAULT_KEYWORD_COUNT, // 跳过素材收集时强制 loose(无素材可严格依据,后端也会强制) writingMode: skipResearch ? 'loose' : hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose', addReferences, skipResearch, materialSource: skipResearch ? 'general' : materialSource, modelName: selectedModel?.name || modelName || undefined, noteSearchSpaceId: !skipResearch ? (selectedNotebookId ?? undefined) : undefined, noteSourceIds: !skipResearch && selectedNotebookId != null ? selectedDocumentIds : undefined, }); } return (

写作配置选项

{WRITING_MODE_OPTIONS.map((opt) => ( ))}
{writingModeType === 'imitate' && (
{sampleFilename && (
{sampleFilename}
)}