270 lines
9.2 KiB
TypeScript
270 lines
9.2 KiB
TypeScript
import { useEffect, useState } from 'react';
|
||
import { PenLine } from 'lucide-react';
|
||
|
||
import { Button } from '@/components/ui/button';
|
||
import { Textarea } from '@/components/ui/textarea';
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from '@/components/ui/select';
|
||
import { getBackendBaseURL } from '@/core/config';
|
||
import { apiFetch } from '@/core/api';
|
||
|
||
import type { AIWritingRequest } from '../../types/ai-writing';
|
||
import { useArticleTypes } from '../../hooks/useArticleTypes';
|
||
|
||
interface AIWritingFormProps {
|
||
onSubmit: (req: AIWritingRequest) => void;
|
||
disabled?: boolean;
|
||
/** workspace 入口:隐藏检索关键词数量、严格模式、目标读者 */
|
||
hideAdvancedFields?: boolean;
|
||
}
|
||
|
||
interface ModelOption {
|
||
name: string;
|
||
displayName: string;
|
||
}
|
||
|
||
const INPUT_CLASS =
|
||
'flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-xs shadow-sm ' +
|
||
'placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 ' +
|
||
'focus-visible:ring-ring disabled:opacity-50';
|
||
|
||
const DEFAULT_KEYWORD_COUNT = 6;
|
||
const DEFAULT_AUDIENCE = '普通读者';
|
||
|
||
export function AIWritingForm({
|
||
onSubmit,
|
||
disabled,
|
||
hideAdvancedFields = false,
|
||
}: AIWritingFormProps) {
|
||
const [intent, setIntent] = useState('');
|
||
const [articleType, setArticleType] = useState('analysis');
|
||
const [audience, setAudience] = useState('普通读者');
|
||
const [wordCountInput, setWordCountInput] = useState('800');
|
||
const [keywordCountInput, setKeywordCountInput] = useState('1');
|
||
const [strictMode, setStrictMode] = useState(false);
|
||
const [modelName, setModelName] = useState('');
|
||
const [models, setModels] = useState<ModelOption[]>([]);
|
||
|
||
const { articleTypes, loading: typesLoading, refresh: refreshTypes } = useArticleTypes();
|
||
|
||
// 初始化时加载文章类型
|
||
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));
|
||
}
|
||
}
|
||
|
||
useEffect(() => {
|
||
apiFetch(`${getBackendBaseURL()}/api/models`)
|
||
.then((r) => r.json())
|
||
.then((data) => {
|
||
const list: ModelOption[] = (data.models ?? []).map(
|
||
(m: { name: string; display_name?: string }) => ({
|
||
name: m.name,
|
||
displayName: m.display_name || m.name,
|
||
}),
|
||
);
|
||
setModels(list);
|
||
if (list.length > 0 && !modelName) {
|
||
setModelName(list[0].name);
|
||
}
|
||
})
|
||
.catch(() => {});
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
function handleWordCountBlur() {
|
||
const num = parseInt(wordCountInput, 10);
|
||
if (isNaN(num) || num < 100) {
|
||
setWordCountInput('800');
|
||
} else {
|
||
setWordCountInput(String(num));
|
||
}
|
||
}
|
||
|
||
// 检索关键词数量限定 1~10,非法值回退默认 6
|
||
function handleKeywordCountBlur() {
|
||
const num = parseInt(keywordCountInput, 10);
|
||
if (isNaN(num)) {
|
||
setKeywordCountInput('1');
|
||
} else {
|
||
setKeywordCountInput(String(Math.max(1, Math.min(10, num))));
|
||
}
|
||
}
|
||
|
||
function handleSubmit(e: React.FormEvent) {
|
||
e.preventDefault();
|
||
if (!intent.trim()) return;
|
||
const wordCount = Math.max(100, parseInt(wordCountInput, 10) || 800);
|
||
const keywordCount = hideAdvancedFields
|
||
? DEFAULT_KEYWORD_COUNT
|
||
: Math.max(1, Math.min(10, parseInt(keywordCountInput, 10) || DEFAULT_KEYWORD_COUNT));
|
||
onSubmit({
|
||
userIntent: intent.trim(),
|
||
articleType,
|
||
targetAudience: hideAdvancedFields
|
||
? DEFAULT_AUDIENCE
|
||
: audience.trim() || DEFAULT_AUDIENCE,
|
||
wordCountTarget: wordCount,
|
||
keywordCount,
|
||
writingMode: hideAdvancedFields ? 'loose' : strictMode ? 'strict' : 'loose',
|
||
modelName: modelName || undefined,
|
||
});
|
||
}
|
||
|
||
return (
|
||
<form onSubmit={handleSubmit} className="space-y-3 px-4 py-4">
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">写作意图</label>
|
||
<Textarea
|
||
placeholder="描述你想写的文章,例如:写一篇分析今年 AI 大模型发展趋势的文章,重点关注国内企业的布局…"
|
||
value={intent}
|
||
onChange={(e) => setIntent(e.target.value)}
|
||
disabled={disabled}
|
||
rows={4}
|
||
className="resize-none text-sm"
|
||
/>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">文章类型</label>
|
||
<Select
|
||
value={articleType}
|
||
onValueChange={handleArticleTypeChange}
|
||
disabled={disabled || typesLoading}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder={typesLoading ? '加载中…' : '选择类型'} />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{articleTypes.map((t) => (
|
||
<SelectItem key={t.key} value={t.key} className="text-xs">
|
||
{t.label}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">目标字数(字)</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>
|
||
|
||
{!hideAdvancedFields && (
|
||
<>
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">检索关键词数量(1~10)</label>
|
||
<input
|
||
type="number"
|
||
min={1}
|
||
max={10}
|
||
step={1}
|
||
className={INPUT_CLASS}
|
||
placeholder="6"
|
||
value={keywordCountInput}
|
||
onChange={(e) => setKeywordCountInput(e.target.value)}
|
||
onBlur={handleKeywordCountBlur}
|
||
disabled={disabled}
|
||
/>
|
||
<p className="text-[11px] text-muted-foreground/70">
|
||
素材收集专家用几个关键词检索,越多素材越全面但耗时更长。
|
||
</p>
|
||
</div>
|
||
|
||
<div className="flex items-start gap-2 rounded-md border bg-muted/20 px-3 py-2">
|
||
<input
|
||
type="checkbox"
|
||
id="strict-mode"
|
||
checked={strictMode}
|
||
onChange={(e) => setStrictMode(e.target.checked)}
|
||
disabled={disabled}
|
||
className="mt-0.5 size-3.5 shrink-0 accent-primary cursor-pointer"
|
||
/>
|
||
<label htmlFor="strict-mode" className="cursor-pointer select-none">
|
||
<span className="text-xs font-medium text-foreground/80">严格依据素材写作</span>
|
||
<p className="text-[11px] text-muted-foreground/70 mt-0.5 leading-relaxed">
|
||
勾选后作家只依据检索素材写作,素材不足以支撑某章节时会暂停询问你;不勾选则可结合通用知识补充论述。
|
||
</p>
|
||
</label>
|
||
</div>
|
||
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">目标读者</label>
|
||
<input
|
||
type="text"
|
||
className={INPUT_CLASS}
|
||
placeholder="普通读者 / 行业专家 / 政策决策者…"
|
||
value={audience}
|
||
onChange={(e) => setAudience(e.target.value)}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{models.length > 0 && (
|
||
<div className="space-y-1.5">
|
||
<label className="text-xs font-medium text-muted-foreground">写作模型</label>
|
||
<Select
|
||
value={modelName}
|
||
onValueChange={setModelName}
|
||
disabled={disabled}
|
||
>
|
||
<SelectTrigger className="h-8 text-xs">
|
||
<SelectValue placeholder="选择模型…" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
{models.map((m) => (
|
||
<SelectItem key={m.name} value={m.name} className="text-xs">
|
||
{m.displayName}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
</div>
|
||
)}
|
||
|
||
<Button type="submit" disabled={disabled || !intent.trim()} className="w-full h-8 text-xs gap-1.5">
|
||
<PenLine className="size-3.5" />
|
||
开始 AI 写作
|
||
</Button>
|
||
</form>
|
||
);
|
||
}
|