feat: add ai writing imitation mode
This commit is contained in:
1 parent
9268c73776
commit
bb6b9bd84b
22 files changed
+714
-30
No files matched your search
@@ -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<AIWritingSampleExtractResult> {
|
||||
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();
|
||||
}
|
||||
@@ -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<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
|
||||
@@ -190,6 +241,106 @@ export function AIWritingForm({
|
||||
</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="resize-none rounded-2xl border-[#e8dedd] bg-white px-4 py-3 text-sm leading-6 shadow-sm"
|
||||
/>
|
||||
<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}>
|
||||
@@ -428,7 +579,7 @@ export function AIWritingForm({
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={disabled || !intent.trim()}
|
||||
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" />
|
||||
|
||||
@@ -10,6 +10,7 @@ export const STATUS_LABELS: Record<string, { label: string; color: string }> = {
|
||||
in_progress: { label: '未完成', color: 'text-slate-400' },
|
||||
// 后台挂起中:后端正自动驱动到定稿(离页/刷新/切号不打断)。
|
||||
background: { label: '挂起中', color: 'text-sky-500' },
|
||||
analyzing_sample: { label: '样文分析', color: 'text-cyan-500' },
|
||||
draft_ready: { label: '草稿', color: 'text-amber-500' },
|
||||
review_ready: { label: '审核', color: 'text-purple-500' },
|
||||
done: { label: '完成', color: 'text-green-500' },
|
||||
|
||||
@@ -54,6 +54,7 @@ import type { AIWritingSessionSummary } from '../../api/ai-writing-sessions';
|
||||
|
||||
const STATUS_LABEL: Record<string, string> = {
|
||||
idle: '就绪',
|
||||
analyzing_sample: '样文分析师分析文风中…',
|
||||
researching: '素材收集专家搜集素材中…',
|
||||
awaiting_material_confirm: '等待确认素材',
|
||||
writing_outline: '作家规划大纲中…',
|
||||
|
||||
@@ -13,6 +13,7 @@ interface AIWritingTimelineProps {
|
||||
const DISPLAYED_TYPES = new Set([
|
||||
'step_start',
|
||||
'step_done',
|
||||
'sample_profile_ready',
|
||||
'materials_ready',
|
||||
'outline_ready',
|
||||
'draft_ready',
|
||||
|
||||
@@ -26,6 +26,7 @@ export function DraftSync({ status, markdown, title }: DraftSyncProps) {
|
||||
// 右侧保持编辑器(富文本)视图,不再切回只读 markdown。仅真正在改写正文的
|
||||
// writing_draft / revising 才只读。
|
||||
const generating =
|
||||
status === 'analyzing_sample' ||
|
||||
status === 'researching' ||
|
||||
status === 'writing_outline' ||
|
||||
streaming;
|
||||
|
||||
@@ -21,6 +21,7 @@ function statusOf(event: ProgressEvent, isLast: boolean): StepStatus {
|
||||
}
|
||||
|
||||
const AGENT_COLOR: Record<string, string> = {
|
||||
样文分析师: 'text-cyan-500',
|
||||
素材收集专家: 'text-blue-500',
|
||||
作家: 'text-purple-500',
|
||||
// 内置智能体模式(ai_writing.use_builtin_agents.outliner)下大纲阶段的默认 agent 名
|
||||
|
||||
@@ -140,11 +140,18 @@ export function WritingSetupChat({
|
||||
: Math.max(100, parseInt(formCtx?.wordCountInput ?? '', 10) || 800);
|
||||
const skipResearch = formCtx?.skipResearch ?? false;
|
||||
const materialSource = skipResearch ? 'general' : (formCtx?.materialSource ?? 'general');
|
||||
const writingModeType = formCtx?.writingModeType === 'imitate' ? 'imitate' : 'normal';
|
||||
const sampleText = formCtx?.sampleText?.trim() ?? '';
|
||||
|
||||
const request: AIWritingRequest = {
|
||||
userIntent: payload.user_intent?.trim() || formCtx?.intent?.trim() || '',
|
||||
// 已与用户确认的大纲:作为用户大纲传入,后端据此规划(留空则后端自动规划)
|
||||
userOutline: payload.outline?.trim() || formCtx?.outlineText?.trim() || undefined,
|
||||
writingModeType,
|
||||
sampleText: writingModeType === 'imitate' ? sampleText : undefined,
|
||||
sampleFilename: writingModeType === 'imitate' ? formCtx?.sampleFilename || '粘贴文本' : undefined,
|
||||
imitateStrength: writingModeType === 'imitate' ? formCtx?.imitateStrength ?? 'medium' : undefined,
|
||||
preserveSampleStructure: writingModeType === 'imitate' ? formCtx?.preserveSampleStructure ?? true : undefined,
|
||||
articleType: payload.article_type?.trim() || formCtx?.articleType || 'analysis',
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用(命中已有类型时为空)。
|
||||
articleTypeDetail: payload.article_type_detail?.trim() || undefined,
|
||||
@@ -170,6 +177,10 @@ export function WritingSetupChat({
|
||||
toast.error('写作意图为空,请先补充意图');
|
||||
return;
|
||||
}
|
||||
if (writingModeType === 'imitate' && !sampleText) {
|
||||
toast.error('仿写模式需要先上传或粘贴样文');
|
||||
return;
|
||||
}
|
||||
await aiWriting.startWriting(request);
|
||||
},
|
||||
[aiWriting, applyProposal, formCtx, chatModel],
|
||||
|
||||
@@ -217,10 +217,10 @@ const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
||||
|
||||
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
|
||||
// 素材收集阶段单独渲染,以便透传实时进度文案
|
||||
if (status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
if (status === 'analyzing_sample' || status === 'researching' || status === 'awaiting_material_confirm') {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||||
<MaterialsSkeleton researchProgress={researchProgress} />
|
||||
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +436,12 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
user_intent: request.userIntent,
|
||||
// 用户自带大纲原文(留空则后端走自动规划)
|
||||
user_outline_raw: request.userOutline?.trim() || '',
|
||||
writing_mode_type: request.writingModeType === 'imitate' ? 'imitate' : 'normal',
|
||||
sample_text: request.sampleText?.trim() || '',
|
||||
sample_filename: request.sampleFilename?.trim() || '',
|
||||
sample_style_profile: null,
|
||||
imitate_strength: request.imitateStrength ?? 'medium',
|
||||
preserve_sample_structure: request.preserveSampleStructure ?? true,
|
||||
article_type: request.articleType,
|
||||
// 新(列表外)文章类型的一句话特征,仅本次写作临时使用;命中已有类型时为空。
|
||||
article_type_detail: request.articleTypeDetail?.trim() || '',
|
||||
@@ -452,7 +458,7 @@ export function AIWritingProvider({ children }: { children: ReactNode }) {
|
||||
notebook_source_ids: request.noteSourceIds ?? null,
|
||||
revision_count: 0,
|
||||
max_revisions: 3,
|
||||
status: 'researching',
|
||||
status: request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
|
||||
drafts: [],
|
||||
progress_events: [],
|
||||
material_package: null,
|
||||
|
||||
@@ -26,6 +26,16 @@ export interface WritingFormValue {
|
||||
setShowOutline: (v: boolean) => void;
|
||||
outlineText: string;
|
||||
setOutlineText: (v: string) => void;
|
||||
writingModeType: 'normal' | 'imitate';
|
||||
setWritingModeType: (v: 'normal' | 'imitate') => void;
|
||||
sampleText: string;
|
||||
setSampleText: (v: string) => void;
|
||||
sampleFilename: string;
|
||||
setSampleFilename: (v: string) => void;
|
||||
imitateStrength: 'light' | 'medium' | 'strong';
|
||||
setImitateStrength: (v: 'light' | 'medium' | 'strong') => void;
|
||||
preserveSampleStructure: boolean;
|
||||
setPreserveSampleStructure: (v: boolean) => void;
|
||||
articleType: string;
|
||||
setArticleType: (v: string) => void;
|
||||
wordCountInput: string;
|
||||
@@ -83,6 +93,11 @@ export function WritingFormProvider({
|
||||
const [formExpanded, setFormExpanded] = useState(false);
|
||||
const [showOutline, setShowOutline] = useState(true);
|
||||
const [outlineText, setOutlineText] = useState('');
|
||||
const [writingModeType, setWritingModeType] = useState<'normal' | 'imitate'>('normal');
|
||||
const [sampleText, setSampleText] = useState('');
|
||||
const [sampleFilename, setSampleFilename] = useState('');
|
||||
const [imitateStrength, setImitateStrength] = useState<'light' | 'medium' | 'strong'>('medium');
|
||||
const [preserveSampleStructure, setPreserveSampleStructure] = useState(true);
|
||||
const [articleType, setArticleType] = useState('analysis');
|
||||
const [wordCountInput, setWordCountInput] = useState('800');
|
||||
const [audience, setAudience] = useState('普通读者');
|
||||
@@ -123,6 +138,11 @@ export function WritingFormProvider({
|
||||
formExpanded, setFormExpanded,
|
||||
showOutline, setShowOutline,
|
||||
outlineText, setOutlineText,
|
||||
writingModeType, setWritingModeType,
|
||||
sampleText, setSampleText,
|
||||
sampleFilename, setSampleFilename,
|
||||
imitateStrength, setImitateStrength,
|
||||
preserveSampleStructure, setPreserveSampleStructure,
|
||||
articleType, setArticleType,
|
||||
wordCountInput, setWordCountInput,
|
||||
audience, setAudience,
|
||||
@@ -135,7 +155,7 @@ export function WritingFormProvider({
|
||||
modelName, setModelName,
|
||||
applyProposal,
|
||||
}),
|
||||
[intent, formExpanded, showOutline, outlineText, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
[intent, formExpanded, showOutline, outlineText, writingModeType, sampleText, sampleFilename, imitateStrength, preserveSampleStructure, articleType, wordCountInput, audience, strictMode, addReferences, materialSource, skipResearch, selectedNotebookId, selectedDocumentIds, modelName, applyProposal],
|
||||
);
|
||||
|
||||
return <WritingFormContext.Provider value={value}>{children}</WritingFormContext.Provider>;
|
||||
|
||||
@@ -51,6 +51,7 @@ const INITIAL_STATE: AIWritingState = {
|
||||
currentDraftMarkdown: '',
|
||||
currentDraftTitle: '',
|
||||
reviewResult: null,
|
||||
sampleStyleProfile: null,
|
||||
revisionCount: 0,
|
||||
currentPause: null,
|
||||
completedInterventions: [],
|
||||
@@ -101,7 +102,11 @@ export function reducer(prev: AIWritingState, action: Action): AIWritingState {
|
||||
return action.events.reduce(handleEvent, prev);
|
||||
|
||||
case 'start':
|
||||
return { ...INITIAL_STATE, status: 'researching', request: action.request };
|
||||
return {
|
||||
...INITIAL_STATE,
|
||||
status: action.request.writingModeType === 'imitate' ? 'analyzing_sample' : 'researching',
|
||||
request: action.request,
|
||||
};
|
||||
|
||||
case 'set_session':
|
||||
return { ...prev, sessionId: action.sessionId };
|
||||
@@ -239,6 +244,13 @@ function handleEvent(prev: AIWritingState, event: ProgressEvent): AIWritingState
|
||||
case 'research_progress':
|
||||
return { ...base, researchProgress: event.message ?? '' };
|
||||
|
||||
case 'sample_profile_ready':
|
||||
return {
|
||||
...base,
|
||||
sampleStyleProfile: event.sampleStyleProfile ?? null,
|
||||
status: 'researching',
|
||||
};
|
||||
|
||||
case 'resumed':
|
||||
return { ...base, currentPause: null };
|
||||
|
||||
@@ -712,6 +724,23 @@ export function normalizeProgressDict(parsed: Record<string, unknown>, sessionId
|
||||
if (out.review_result) out.reviewResult = normalizeReviewResult(out.review_result);
|
||||
if (out.reviewResult && !out.review_result) out.reviewResult = normalizeReviewResult(out.reviewResult);
|
||||
|
||||
if (out.sample_style_profile && !out.sampleStyleProfile) {
|
||||
const p = out.sample_style_profile as Record<string, unknown>;
|
||||
out.sampleStyleProfile = {
|
||||
summary: p.summary as string | undefined,
|
||||
articleTypeGuess: (p.article_type_guess ?? p.articleTypeGuess) as string | undefined,
|
||||
tone: p.tone as string | undefined,
|
||||
structure: (p.structure as string[] | undefined) ?? [],
|
||||
languageFeatures: (p.language_features ?? p.languageFeatures ?? []) as string[],
|
||||
sentenceStyle: (p.sentence_style ?? p.sentenceStyle) as string | undefined,
|
||||
openingStyle: (p.opening_style ?? p.openingStyle) as string | undefined,
|
||||
endingStyle: (p.ending_style ?? p.endingStyle) as string | undefined,
|
||||
imitationGuidance: (p.imitation_guidance ?? p.imitationGuidance ?? []) as string[],
|
||||
avoidCopying: (p.avoid_copying ?? p.avoidCopying ?? []) as string[],
|
||||
sampleOutline: (p.sample_outline ?? p.sampleOutline ?? []) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
if ('category' in out) out.reviewItemCategory = out.category;
|
||||
if ('score' in out) out.reviewItemScore = out.score;
|
||||
if ('issues' in out && Array.isArray(out.issues)) {
|
||||
@@ -753,6 +782,9 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
|
||||
const draft = (values.current_draft && typeof values.current_draft === 'object')
|
||||
? (values.current_draft as Record<string, unknown>) : null;
|
||||
const { progressEvents, completedInterventions } = buildRehydrateTimeline(values, sessionId);
|
||||
const sampleProfileEvent = values.sample_style_profile
|
||||
? normalizeProgressDict({ type: 'sample_profile_ready', sample_style_profile: values.sample_style_profile }, sessionId)
|
||||
: null;
|
||||
|
||||
return {
|
||||
sessionId,
|
||||
@@ -764,6 +796,7 @@ export function buildRehydrateSnapshot(values: AIWritingValues, sessionId: strin
|
||||
currentDraftTitle: String(draft?.title ?? ''),
|
||||
revisionCount: Number(draft?.revision_count ?? draft?.revisionCount ?? 0),
|
||||
reviewResult: normalizeReviewResult(values.review_result),
|
||||
sampleStyleProfile: sampleProfileEvent?.sampleStyleProfile ?? null,
|
||||
progressEvents,
|
||||
completedInterventions,
|
||||
};
|
||||
@@ -799,12 +832,14 @@ export function customToProgressEvent(raw: unknown, sessionId: string): Progress
|
||||
|
||||
const allowed: ProgressEventType[] = [
|
||||
'intent_chunk', 'intent_ready',
|
||||
'sample_profile_ready',
|
||||
'research_progress', 'outline_chunk', 'draft_chunk',
|
||||
'review_chunk', 'review_item_done',
|
||||
'skill_search_step', 'skill_select_chunk',
|
||||
'model_switch',
|
||||
];
|
||||
if (!allowed.includes(type as ProgressEventType)) return null;
|
||||
if (type === 'sample_profile_ready') return normalizeProgressDict(o, sessionId);
|
||||
|
||||
// 共通:原样保留所有字段(is_thinking / text / section_index / section_title / category / score / issues)
|
||||
const merged: Record<string, unknown> = { ...o, sessionId, message: o.message ?? '' };
|
||||
@@ -933,6 +968,12 @@ export function interruptToEvent(value: unknown, sessionId: string): ProgressEve
|
||||
export type AIWritingValues = {
|
||||
user_intent?: string;
|
||||
user_outline_raw?: string;
|
||||
writing_mode_type?: string;
|
||||
sample_text?: string;
|
||||
sample_filename?: string;
|
||||
sample_style_profile?: unknown;
|
||||
imitate_strength?: string;
|
||||
preserve_sample_structure?: boolean;
|
||||
article_type?: string;
|
||||
/** 新(列表外)文章类型的一句话特征,仅本次写作临时使用。 */
|
||||
article_type_detail?: string;
|
||||
|
||||
@@ -135,10 +135,27 @@ export interface ReviewResult {
|
||||
revisionNotes: ReviewIssue[];
|
||||
}
|
||||
|
||||
// ── 样文仿写 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface SampleStyleProfile {
|
||||
summary?: string;
|
||||
articleTypeGuess?: string;
|
||||
tone?: string;
|
||||
structure?: string[];
|
||||
languageFeatures?: string[];
|
||||
sentenceStyle?: string;
|
||||
openingStyle?: string;
|
||||
endingStyle?: string;
|
||||
imitationGuidance?: string[];
|
||||
avoidCopying?: string[];
|
||||
sampleOutline?: string[];
|
||||
}
|
||||
|
||||
// ── 写作状态 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export type WritingStatus =
|
||||
| "idle"
|
||||
| "analyzing_sample"
|
||||
| "researching"
|
||||
| "awaiting_material_confirm"
|
||||
| "writing_outline"
|
||||
@@ -158,6 +175,7 @@ export type ProgressEventType =
|
||||
| "connected"
|
||||
| "step_start"
|
||||
| "step_done"
|
||||
| "sample_profile_ready"
|
||||
| "intent_chunk"
|
||||
| "intent_ready"
|
||||
| "research_progress"
|
||||
@@ -226,6 +244,8 @@ export interface ProgressEvent {
|
||||
sectionIndex?: number;
|
||||
sectionTitle?: string;
|
||||
};
|
||||
// sample_profile_ready:样文仿写的文风画像
|
||||
sampleStyleProfile?: SampleStyleProfile;
|
||||
}
|
||||
|
||||
// ── 用户干预提交体 ────────────────────────────────────────────────────────────
|
||||
@@ -253,6 +273,13 @@ export interface AIWritingRequest {
|
||||
userIntent: string;
|
||||
/** 用户粘贴的「已有大纲」原文(可选)。非空时后端按此大纲生成,不再自动规划 */
|
||||
userOutline?: string;
|
||||
/** 写作入口模式:normal=普通写作 / imitate=样文仿写 */
|
||||
writingModeType?: "normal" | "imitate";
|
||||
/** 样文原文(上传提取或直接粘贴),仅样文仿写模式使用 */
|
||||
sampleText?: string;
|
||||
sampleFilename?: string;
|
||||
imitateStrength?: "light" | "medium" | "strong";
|
||||
preserveSampleStructure?: boolean;
|
||||
articleType: string;
|
||||
/** 当 articleType 是已有类型库里没有的新类型时,配置助手识别的一句话特征说明(仅本次写作临时使用,不入库)。 */
|
||||
articleTypeDetail?: string;
|
||||
@@ -356,6 +383,7 @@ export interface AIWritingState {
|
||||
currentDraftMarkdown: string;
|
||||
currentDraftTitle: string;
|
||||
reviewResult: ReviewResult | null;
|
||||
sampleStyleProfile: SampleStyleProfile | null;
|
||||
revisionCount: number;
|
||||
currentPause: ProgressEvent | null;
|
||||
completedInterventions: CompletedIntervention[];
|
||||
|
||||
Reference in new issue
Block a user