deerflow-code/frontend-web/src/open-canvas/components/ai-writing/AIWritingForm.tsx
2026-09-07 18:24:55 +08:00

627 lines
26 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = '';
if (!file) return;
setSampleUploading(true);
setSampleError('');
setSampleMessage('');
try {
const result = await extractAIWritingSample(file);
setSampleFilename(result.filename);
setSampleText(result.text);
setSampleMessage(result.truncated ? '已提取样文文本,内容较长已截断。' : '已提取样文文本。');
} catch (err) {
setSampleError(err instanceof Error ? err.message : '样文解析失败');
} finally {
setSampleUploading(false);
}
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!intent.trim()) return;
if (writingModeType === 'imitate' && !sampleText.trim()) {
setSampleError('请上传样文或粘贴样文文本');
return;
}
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
onSubmit({
userIntent: intent.trim(),
userOutline: outlineText.trim() || undefined,
writingModeType,
sampleText: writingModeType === 'imitate' ? sampleText.trim() : undefined,
sampleFilename: writingModeType === 'imitate' ? sampleFilename || '粘贴文本' : undefined,
imitateStrength,
preserveSampleStructure,
articleType,
targetAudience: hideAdvancedFields
? DEFAULT_AUDIENCE
: 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 (
<form onSubmit={handleSubmit} className="space-y-4 px-4 py-4">
<div className="rounded-2xl border border-[#ece5e4] bg-card p-5 shadow-sm dark:border-[#213D5E] dark:bg-[#02244C] dark:text-white [&_h3]:dark:text-white [&_label]:dark:text-white">
<div className="flex items-center gap-2.5 border-b border-[#ece5e4] pb-3 dark:border-[#213D5E]">
<SlidersHorizontal className="size-4 text-primary dark:text-white" />
<h3 className="text-[18px] font-semibold leading-none tracking-tight text-[#2f2f2f] dark:text-white">
写作配置选项
</h3>
</div>
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模式</label>
<div className="grid grid-cols-2 gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-1 dark:border-[#1B477E] dark:bg-[#022248]/40">
{WRITING_MODE_OPTIONS.map((opt) => (
<button
key={opt.value}
type="button"
disabled={disabled}
onClick={() => setWritingModeType(opt.value)}
className={cn(
'h-10 rounded-lg text-sm font-medium transition-colors',
writingModeType === opt.value
? 'bg-white text-primary shadow-sm dark:bg-[#0B376A] dark:text-white'
: 'text-muted-foreground hover:bg-white/70 dark:text-[#AACEFF]/70 dark:hover:bg-[#0B376A]/60',
)}
>
{opt.label}
</button>
))}
</div>
</div>
{writingModeType === 'imitate' && (
<div className="mt-4 space-y-3 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] p-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<div className="flex items-center justify-between gap-3">
<label className={FIELD_LABEL_CLASS}>样文</label>
<label
className={cn(
'inline-flex h-9 cursor-pointer items-center gap-2 rounded-lg border border-[#e8dedd] bg-white px-3 text-sm font-medium text-foreground shadow-sm transition-colors hover:bg-[#fff8f8]',
'dark:border-[#1B477E] dark:bg-[#022248] dark:text-[#AACEFF]',
disabled || sampleUploading ? 'pointer-events-none opacity-60' : '',
)}
>
{sampleUploading ? <Loader2 className="size-4 animate-spin" /> : <Upload className="size-4" />}
上传文件
<input
type="file"
accept=".doc,.docx,.pdf,.md,.markdown,.txt"
className="sr-only"
disabled={disabled || sampleUploading}
onChange={handleSampleFileChange}
/>
</label>
</div>
{sampleFilename && (
<div className="flex items-center gap-2 rounded-lg bg-white px-3 py-2 text-xs text-muted-foreground dark:bg-[#022248] dark:text-[#AACEFF]/80">
<FileText className="size-3.5" />
<span className="truncate">{sampleFilename}</span>
</div>
)}
<Textarea
placeholder="上传 Word/PDF/Markdown/TXT,或直接粘贴一篇样文。AI 会先分析文风,再按你的写作意图仿写一篇新文章。"
value={sampleText}
onChange={(e) => {
setSampleText(e.target.value.slice(0, MAX_SAMPLE_TEXT_LENGTH));
setSampleError('');
if (!sampleFilename) setSampleFilename('粘贴文本');
}}
disabled={disabled || sampleUploading}
rows={8}
maxLength={MAX_SAMPLE_TEXT_LENGTH}
className="max-h-[13.5rem] resize-none overflow-y-auto rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm [field-sizing:fixed]"
/>
<div className="flex items-center justify-between gap-3 text-xs text-muted-foreground/80">
<span>{sampleText.length} / {MAX_SAMPLE_TEXT_LENGTH} 字</span>
{sampleMessage && <span className="text-emerald-600">{sampleMessage}</span>}
{sampleError && <span className="text-destructive">{sampleError}</span>}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">仿写强度</label>
<Select
value={imitateStrength}
onValueChange={(v) => setImitateStrength(v as 'light' | 'medium' | 'strong')}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue />
</SelectTrigger>
<SelectContent>
{IMITATE_STRENGTH_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
<label className="flex items-center gap-2 pt-7 text-sm font-medium text-foreground">
<input
type="checkbox"
checked={preserveSampleStructure}
onChange={(e) => setPreserveSampleStructure(e.target.checked)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
沿用样文结构
</label>
</div>
</div>
)}
<div className="mt-4 space-y-2">
<div className="flex items-center justify-between">
<label className={FIELD_LABEL_CLASS}>
写作意图 <span className="text-primary dark:text-white">*</span>
</label>
<span className="text-sm text-muted-foreground dark:text-white/70">
{intent.length} / {MAX_INTENT_LENGTH} 字
</span>
</div>
<Textarea
placeholder="描述你想写的文章"
value={intent}
onChange={(e) => setIntent(e.target.value)}
disabled={disabled}
rows={5}
maxLength={MAX_INTENT_LENGTH}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
</div>
<div className="mt-4 space-y-2">
<button
type="button"
onClick={() => setShowOutline(!showOutline)}
disabled={disabled}
className="flex w-full items-center justify-between text-left"
>
<span className={FIELD_LABEL_CLASS}>
已有大纲
<span className="ml-1 text-sm font-normal text-muted-foreground dark:text-white/70">
(可选)
</span>
</span>
<span className="text-sm text-muted-foreground dark:text-white/70">
{showOutline ? '收起 ▴' : '我已有大纲,直接粘贴 ▾'}
</span>
</button>
{showOutline && (
<>
<Textarea
placeholder={
'粘贴你已有的大纲,将完全按你的大纲生成(支持一/二/三级目录)。例如:\n' +
'一、引言\n(一)背景\n(二)意义\n二、现状分析\n…'
}
value={outlineText}
onChange={(e) => setOutlineText(e.target.value)}
disabled={disabled}
rows={6}
className="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
/>
<p className="text-xs leading-relaxed text-muted-foreground/80">
填写后将跳过自动大纲规划,按你粘贴的大纲逐章生成;留空则由 AI 自动规划大纲。
</p>
</>
)}
</div>
<div className="mt-4 grid grid-cols-2 gap-4">
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>文章类型</label>
<Select
value={articleType}
onValueChange={handleArticleTypeChange}
disabled={disabled || typesLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{articleTypes.map((t) => (
<SelectItem key={t.key} value={t.key} className="text-sm">
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="min-w-0 space-y-2">
<label className={FIELD_LABEL_CLASS}>目标字数</label>
<input
type="number"
min={100}
step={100}
className={INPUT_CLASS}
placeholder="800"
value={wordCountInput}
onChange={(e) => setWordCountInput(e.target.value)}
onBlur={handleWordCountBlur}
disabled={disabled}
/>
</div>
</div>
{/* 不收集素材,直接用通用知识写作 */}
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<input
type="checkbox"
id="skip-research"
checked={skipResearch}
onChange={(e) => setSkipResearch(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="skip-research" className="cursor-pointer select-none">
<span className="text-sm font-medium text-foreground/90">不收集素材,直接用通用知识写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
勾选后跳过素材检索阶段(不弹素材确认),AI 直接用通用知识规划大纲并写作;检索类型与「严格依据素材」将被忽略。
</p>
</label>
</div>
{!skipResearch && (
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>检索方式</label>
<Select
value={materialSource}
onValueChange={(v) => handleMaterialSourceChange(v as MaterialSource)}
disabled={disabled}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder="选择检索方式" />
</SelectTrigger>
<SelectContent>
{MATERIAL_SOURCE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value} className="text-sm">
{opt.label}
<span className="ml-1 text-xs text-muted-foreground">— {opt.desc}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{materialSource === 'notebook' && (
<div className="mt-2 space-y-2">
{!studioToken && (
<Select
value={selectedNotebookId !== null ? String(selectedNotebookId) : ''}
onValueChange={handleNotebookChange}
disabled={disabled || notebooksLoading}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue placeholder={notebooksLoading ? '加载中…' : '选择空间'} />
</SelectTrigger>
<SelectContent>
{notebooks.map((nb) => (
<SelectItem key={nb.id} value={String(nb.id)} className="text-sm">
{nb.name}
</SelectItem>
))}
</SelectContent>
</Select>
)}
{selectedNotebookId !== null && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
{sourcesLoading
? '文档加载中…'
: `选择文档(可多选;不选则搜整个空间${selectedDocumentIds.length > 0 ? `,已选 ${selectedDocumentIds.length} 篇` : ''})`}
</p>
{!sourcesLoading && (
<div className="max-h-36 overflow-y-auto rounded-lg border border-[#e8dedd] bg-white divide-y divide-[#f0e8e7]">
{sources.filter((s) => s.document?.id != null).map((s) => {
const docId = s.document!.id;
return (
<label key={s.id} className="flex items-center gap-2 px-3 py-2 cursor-pointer hover:bg-[#f9f5f5] select-none">
<input
type="checkbox"
checked={selectedDocumentIds.includes(docId)}
onChange={() => toggleDocument(docId)}
disabled={disabled}
className="size-3.5 shrink-0 accent-primary"
/>
<span className="text-xs text-foreground truncate">
{s.document?.title ?? `文档 ${docId}`}
</span>
</label>
);
})}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
<div className="mt-4 space-y-2">
<label className={FIELD_LABEL_CLASS}>写作模型</label>
<Select
value={selectedModel?.name ?? ''}
onValueChange={setModelName}
disabled={disabled || modelsLoading || models.length === 0}
>
<SelectTrigger className={FIELD_TRIGGER_CLASS}>
<SelectValue
placeholder={
modelsLoading
? '模型加载中…'
: models.length > 0
? '选择模型…'
: '暂无可用模型'
}
/>
</SelectTrigger>
<SelectContent position="popper" side="bottom" sideOffset={4}>
{models.map((m) => (
<SelectItem key={m.name} value={m.name} className="text-sm">
{m.display_name || m.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="mt-4 flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3 dark:border-[#1B477E] dark:bg-[#022248]/40">
<input
type="checkbox"
id="add-references"
checked={addReferences}
onChange={(e) => setAddReferences(e.target.checked)}
disabled={disabled}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary"
/>
<label htmlFor="add-references" className="cursor-pointer select-none">
<span className="text-sm font-medium text-foreground/90">添加参考文献</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
开启后,正文引用素材处自动加角标 [n],文末按出现顺序生成「参考文献」列表(像论文一样)。
</p>
</label>
</div>
<Button
type="submit"
disabled={disabled || !intent.trim() || (writingModeType === 'imitate' && !sampleText.trim())}
className="mt-5 h-12 w-full gap-2 rounded-2xl text-base font-semibold shadow-lg shadow-primary/20 transition-all hover:shadow-primary/30 active:scale-[0.98]"
>
<PenLine className="size-4" />
开始 AI 写作
</Button>
</div>
<ResearchTemplatesPanel />
{!hideAdvancedFields && (
<>
<div className="flex items-start gap-2 rounded-xl border border-[#e8dedd] bg-[#f9f5f5] px-3 py-3">
<input
type="checkbox"
id="strict-mode"
checked={strictMode && !skipResearch}
onChange={(e) => setStrictMode(e.target.checked)}
disabled={disabled || skipResearch}
className="mt-0.5 size-3.5 shrink-0 cursor-pointer accent-primary disabled:cursor-not-allowed"
/>
<label htmlFor="strict-mode" className={cn('select-none', skipResearch ? 'cursor-not-allowed opacity-50' : 'cursor-pointer')}>
<span className="text-sm font-medium text-foreground/90">严格依据素材写作</span>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground/80">
{skipResearch
? '已选「不收集素材」,本项不适用:将直接用通用知识写作。'
: '勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。'}
</p>
</label>
</div>
<div className="space-y-2">
<label className="text-sm font-semibold text-foreground">目标读者</label>
<input
type="text"
className={INPUT_CLASS}
placeholder="普通读者 / 行业专家 / 政策决策者…"
value={audience}
onChange={(e) => setAudience(e.target.value)}
disabled={disabled}
/>
</div>
</>
)}
</form>
);
}