627 lines
26 KiB
TypeScript
627 lines
26 KiB
TypeScript
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>
|
||
);
|
||
}
|