即时创作模式UI 优化
All checks were successful
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 56s

This commit is contained in:
Song367 2026-03-12 17:28:06 +08:00
parent 61fca0ebaf
commit 18dd726fb3
8 changed files with 830 additions and 814 deletions

View File

@ -30,7 +30,6 @@ import {
Users, Users,
X X
} from 'lucide-react'; } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx'; import { Document, Packer, Paragraph, TextRun, HeadingLevel, AlignmentType } from 'docx';
import { saveAs } from 'file-saver'; import { saveAs } from 'file-saver';
import { cn } from './lib/utils'; import { cn } from './lib/utils';
@ -47,8 +46,10 @@ import {
import { parseUploadedSourceFile } from './services/fileParsing'; import { parseUploadedSourceFile } from './services/fileParsing';
import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow'; import { getResumeStartIndex, getVersionsToGenerate, isConversionAbortedError } from './lib/conversionWorkflow';
import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api'; import { AuthUser, loadCurrentDraft, login, logout, register, restoreSession, saveCurrentDraft } from './services/api';
import { splitScriptPreviewBlocks } from './lib/scriptPreview'; import { normalizeSceneHeadingMarkers, splitScriptPreviewBlocks } from './lib/scriptPreview';
import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft'; import { parseSavedConversionEpisodeResults, sanitizeSavedConversionEpisodeResults } from './lib/conversionDraft';
import { parseFinalizedOutline } from './lib/finalizedOutline';
import { getDisplayedModeLabel } from './lib/modeSource';
type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71'; type ScriptType = '\u77ed\u5267' | '\u7535\u89c6\u5267' | '\u7535\u5f71';
type ScriptFormat = '全部' | '场景' | '人物' | '台词' | '动作' | '镜头提示'; type ScriptFormat = '全部' | '场景' | '人物' | '台词' | '动作' | '镜头提示';
@ -307,11 +308,20 @@ function parseCharacterProfileCards(text: string): CharacterPreviewCard[] {
export default function App() { export default function App() {
const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion'); const [activeTab, setActiveTab] = useState<'conversion' | 'creation' | 'finalized'>('conversion');
const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion'); const [previousTab, setPreviousTab] = useState<'conversion' | 'creation'>('conversion');
const [finalizedScript, setFinalizedScript] = useState<string>(() => localStorage.getItem('scriptflow_finalized_script') || ''); const [finalizedScripts, setFinalizedScripts] = useState<{ conversion: string; creation: string }>(() => {
const legacy = localStorage.getItem('scriptflow_finalized_script') || '';
return {
conversion: localStorage.getItem('scriptflow_finalized_script_conversion') || legacy,
creation: localStorage.getItem('scriptflow_finalized_script_creation') || legacy,
};
});
const finalizedScript = previousTab === 'creation' ? finalizedScripts.creation : finalizedScripts.conversion;
useEffect(() => { useEffect(() => {
localStorage.setItem('scriptflow_finalized_script_conversion', finalizedScripts.conversion);
localStorage.setItem('scriptflow_finalized_script_creation', finalizedScripts.creation);
localStorage.setItem('scriptflow_finalized_script', finalizedScript); localStorage.setItem('scriptflow_finalized_script', finalizedScript);
}, [finalizedScript]); }, [finalizedScripts, finalizedScript]);
// Common States (Three-level Filters) // Common States (Three-level Filters)
const [scriptType, setScriptType] = useState<ScriptType>('\u77ed\u5267'); const [scriptType, setScriptType] = useState<ScriptType>('\u77ed\u5267');
@ -372,6 +382,7 @@ export default function App() {
const conversionFileInputRef = useRef<HTMLInputElement | null>(null); const conversionFileInputRef = useRef<HTMLInputElement | null>(null);
const [isSyncing, setIsSyncing] = useState(false); const [isSyncing, setIsSyncing] = useState(false);
const [isGeneratingAll, setIsGeneratingAll] = useState(false); const [isGeneratingAll, setIsGeneratingAll] = useState(false);
const [editingCreationScriptKey, setEditingCreationScriptKey] = useState<string | null>(null);
const [finalizedFilter, setFinalizedFilter] = useState<ScriptFormat>('全部'); const [finalizedFilter, setFinalizedFilter] = useState<ScriptFormat>('全部');
// Conversion Settings States // Conversion Settings States
@ -462,6 +473,8 @@ export default function App() {
conversionOutline, conversionOutline,
conversionCharacterProfiles, conversionCharacterProfiles,
finalizedScript, finalizedScript,
finalizedScriptConversion: finalizedScripts.conversion,
finalizedScriptCreation: finalizedScripts.creation,
}, },
}); });
@ -481,7 +494,11 @@ export default function App() {
setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : ''); setConversionWorldview(typeof draft.conversionWorldview === 'string' ? draft.conversionWorldview : '');
setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : ''); setConversionOutline(typeof draft.conversionOutline === 'string' ? draft.conversionOutline : '');
setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : ''); setConversionCharacterProfiles(typeof draft.conversionCharacterProfiles === 'string' ? draft.conversionCharacterProfiles : '');
setFinalizedScript(typeof draft.finalizedScript === 'string' ? draft.finalizedScript : ''); const legacyFinalizedScript = typeof draft.finalizedScript === 'string' ? draft.finalizedScript : '';
setFinalizedScripts({
conversion: typeof draft.finalizedScriptConversion === 'string' ? draft.finalizedScriptConversion : legacyFinalizedScript,
creation: typeof draft.finalizedScriptCreation === 'string' ? draft.finalizedScriptCreation : legacyFinalizedScript,
});
setEditingConversionVersionKey(null); setEditingConversionVersionKey(null);
}; };
@ -886,7 +903,7 @@ export default function App() {
scriptToFinalize = finalizedPages.map((p) => { scriptToFinalize = finalizedPages.map((p) => {
const script = p.scripts[p.selectedScriptIndex!]; const script = p.scripts[p.selectedScriptIndex!];
const originalIndex = creationPages.indexOf(p); const originalIndex = creationPages.indexOf(p);
return `## ?${originalIndex + 1}? return `## ${originalIndex + 1}
${script}`; ${script}`;
}).join('\n\n---\n\n'); }).join('\n\n---\n\n');
@ -899,7 +916,7 @@ ${script}`;
} }
setTimeout(() => { setTimeout(() => {
setFinalizedScript(scriptToFinalize); setFinalizedScripts((prev) => ({ ...prev, [activeTab as 'conversion' | 'creation']: scriptToFinalize }));
setIsFinalizing(false); setIsFinalizing(false);
setIsEditingFinalized(false); setIsEditingFinalized(false);
setActiveTab('finalized'); setActiveTab('finalized');
@ -978,6 +995,20 @@ ${script}`;
setCreationPages(newPages); setCreationPages(newPages);
}; };
const updateCreationScriptContent = (pageIndex: number, scriptIndex: number, content: string) => {
setCreationPages((prevPages) => prevPages.map((page, idx) => {
if (idx !== pageIndex) return page;
const nextScripts = [...page.scripts];
nextScripts[scriptIndex] = normalizeSceneHeadingMarkers(content);
return {
...page,
scripts: nextScripts
};
}));
};
const syncCurrentPage = async () => { const syncCurrentPage = async () => {
const currentPage = creationPages[currentPageIndex]; const currentPage = creationPages[currentPageIndex];
if (!currentPage || !currentPage.story.trim()) return; if (!currentPage || !currentPage.story.trim()) return;
@ -989,6 +1020,7 @@ ${script}`;
return; return;
} }
setEditingCreationScriptKey(null);
setIsSyncing(true); setIsSyncing(true);
// Initialize scripts with empty strings to show loading state // Initialize scripts with empty strings to show loading state
@ -1044,6 +1076,7 @@ ${script}`;
return; return;
} }
setEditingCreationScriptKey(null);
setIsGeneratingAll(true); setIsGeneratingAll(true);
const results = await generateAllScripts( const results = await generateAllScripts(
creationPages, creationPages,
@ -1062,7 +1095,7 @@ ${script}`;
const exportAllScripts = () => { const exportAllScripts = () => {
const fullScript = creationPages.map((p, i) => { const fullScript = creationPages.map((p, i) => {
const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210'); const script = p.selectedScriptIndex !== null ? p.scripts[p.selectedScriptIndex] : (p.scripts[0] || '\u672a\u751f\u6210');
return `## ?${i + 1} ? return `## ${i + 1}
${script}`; ${script}`;
}).join('\n\n---\n\n'); }).join('\n\n---\n\n');
@ -1075,35 +1108,41 @@ ${script}`;
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
}; };
const sidebarItems = React.useMemo(() => { const finalizedOutline = React.useMemo(() => parseFinalizedOutline(finalizedScript), [finalizedScript]);
const items: { type: 'episode' | 'scene', text: string, id: string }[] = []; const sidebarItems = React.useMemo(() => finalizedOutline.sidebarItems, [finalizedOutline]);
const lines = finalizedScript.split('\n'); const [expandedEpisodes, setExpandedEpisodes] = useState<Set<string>>(() => new Set());
let sceneIdx = 0; const [expandedScenes, setExpandedScenes] = useState<Set<string>>(() => new Set());
let episodeIdx = 0;
useEffect(() => {
lines.forEach((line) => { const nextEpisodes = new Set<string>();
const trimmed = line.trim(); const nextScenes = new Set<string>();
if (!trimmed) return;
finalizedOutline.episodes.forEach((episode) => {
// Episode Detection nextEpisodes.add(episode.id);
if (trimmed.startsWith('## ?') && trimmed.includes('?')) { episode.scenes.slice(0, 2).forEach((scene) => nextScenes.add(scene.id));
items.push({
type: 'episode',
text: trimmed.replace(/^[#*]+/, '').trim(),
id: `episode-${episodeIdx++}`
});
}
// Scene Detection
else if ((trimmed.startsWith('## 场景') || trimmed.startsWith('场景') || trimmed.startsWith('SCENE') || (trimmed.startsWith('#') && !trimmed.startsWith('##'))) && trimmed.length < 80) {
items.push({
type: 'scene',
text: trimmed.replace(/^[#*]+/, '').trim(),
id: `scene-${sceneIdx++}`
});
}
}); });
return items;
}, [finalizedScript]); setExpandedEpisodes(nextEpisodes);
setExpandedScenes(nextScenes);
}, [finalizedScript, finalizedOutline]);
const toggleEpisodeCard = (episodeId: string) => {
setExpandedEpisodes((prev) => {
const next = new Set(prev);
if (next.has(episodeId)) next.delete(episodeId);
else next.add(episodeId);
return next;
});
};
const toggleSceneCard = (sceneId: string) => {
setExpandedScenes((prev) => {
const next = new Set(prev);
if (next.has(sceneId)) next.delete(sceneId);
else next.add(sceneId);
return next;
});
};
const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles; const activeCharacterProfiles = activeTab === 'conversion' ? conversionCharacterProfiles : creationCharacterProfiles;
const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing; const isCharacterProfilesEditing = activeTab === 'conversion' ? isConversionCharacterProfilesEditing : isCreationCharacterProfilesEditing;
@ -1134,11 +1173,11 @@ ${script}`;
const fullScript = finalizedPages.map((p) => { const fullScript = finalizedPages.map((p) => {
const script = p.scripts[p.selectedScriptIndex!]; const script = p.scripts[p.selectedScriptIndex!];
const originalIndex = creationPages.indexOf(p); const originalIndex = creationPages.indexOf(p);
return `## ?${originalIndex + 1} ? return `## ${originalIndex + 1}
${script}`; ${script}`;
}).join('\n\n---\n\n'); }).join('\n\n---\n\n');
setFinalizedScript(fullScript); setFinalizedScripts((prev) => ({ ...prev, creation: fullScript }));
setActiveTab('finalized'); setActiveTab('finalized');
}; };
@ -1236,7 +1275,7 @@ ${script}`;
{/* Header */} {/* Header */}
<header className="h-16 border-b border-[#D2D2D7]/30 flex items-center justify-between px-8 bg-white/70 backdrop-blur-xl z-10"> <header className="h-16 border-b border-[#D2D2D7]/30 flex items-center justify-between px-8 bg-white/70 backdrop-blur-xl z-10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h1 className="text-lg font-semibold tracking-tight">ScriptFlow <span className="text-[#0071E3]">{activeTab === 'conversion' ? 'AI 剧本转换工具' : '即时创作模式'}</span></h1> <h1 className="text-lg font-semibold tracking-tight">ScriptFlow <span className="text-[#0071E3]">{getDisplayedModeLabel(activeTab, previousTab)}</span></h1>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@ -1524,7 +1563,7 @@ ${script}`;
}} }}
value={finalizedScript} value={finalizedScript}
onChange={(e) => { onChange={(e) => {
setFinalizedScript(e.target.value); setFinalizedScripts((prev) => ({ ...prev, [previousTab]: e.target.value }));
e.target.style.height = 'auto'; e.target.style.height = 'auto';
e.target.style.height = e.target.scrollHeight + 'px'; e.target.style.height = e.target.scrollHeight + 'px';
}} }}
@ -1534,103 +1573,80 @@ ${script}`;
/> />
</div> </div>
) : ( ) : (
<div className="w-full max-w-[800px] bg-white shadow-[0_20px_50px_rgba(0,0,0,0.1)] rounded-sm min-h-[1123px] h-auto relative mx-auto my-8"> <div className="w-full max-w-[1100px] mx-auto my-8 px-2 space-y-5">
{/* Page Content Container */} {finalizedOutline.episodes.map((episode) => {
<div className="w-full p-[80px] relative z-10"> const episodeExpanded = expandedEpisodes.has(episode.id);
{/* Script Content */}
<div className="font-mono text-[14px] leading-[1.6] text-[#1D1D1F] prose prose-sm max-w-none break-words whitespace-pre-wrap">
<ReactMarkdown
components={{
p: ({ children }) => {
try {
const childrenArray = React.Children.toArray(children);
const text = childrenArray.map(c => c?.toString() || '').join('');
const trimmedText = text.trim();
// 1. Scene Detection
const isScene = (trimmedText.includes('SCENE') || trimmedText.includes('场景') || trimmedText.startsWith('#')) && trimmedText.length < 80;
if (isScene) {
// We need to find the index of this scene in the global list to match the sidebar ID
// This is a bit expensive but necessary for navigation
const allScenes = finalizedScript.split('\n')
.filter(line => {
const t = line.trim();
return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80;
});
const sceneIdx = allScenes.findIndex(s => s.includes(trimmedText) || trimmedText.includes(s.replace(/^[#*]+/, '').trim()));
const sceneTitle = trimmedText.replace(/^[#*]+/, '').trim();
return (
<div id={`scene-${sceneIdx >= 0 ? sceneIdx : 'unknown'}`} className="mt-10 mb-6 py-2 border-b-2 border-black/5 font-bold uppercase tracking-wide text-lg">
{sceneTitle}
</div>
);
}
return <p className="mb-4 text-justify leading-relaxed">{children}</p>;
} catch (e) {
return <p className="mb-4 text-justify leading-relaxed">{children}</p>;
}
},
h1: ({children}) => <h1 className="text-2xl font-bold mb-6 text-center border-b-2 border-black/10 pb-2">{children}</h1>,
h2: ({children}) => {
const text = React.Children.toArray(children).map(c => c?.toString() || '').join('');
let id = '';
if (text.startsWith('?') && text.includes('?')) {
const allEpisodes = finalizedScript.split('\n').filter(l => l.trim().startsWith('## ?') && l.trim().includes('?'));
const idx = allEpisodes.findIndex(e => e.includes(text));
id = `episode-${idx}`;
} else if (text.startsWith('场景') || text.includes('SCENE')) {
const allScenes = finalizedScript.split('\n')
.filter(line => {
const t = line.trim();
return (t.startsWith('## 场景') || t.startsWith('场景') || t.startsWith('SCENE') || (t.startsWith('#') && !t.startsWith('##'))) && t.length < 80;
});
const idx = allScenes.findIndex(s => s.includes(text));
id = `scene-${idx}`;
}
return <h2 id={id} className="text-xl font-bold mt-10 mb-4 border-b border-black/5 pb-1">{children}</h2>;
},
h3: ({children}) => <h3 className="text-lg font-bold mt-8 mb-3">{children}</h3>
}}
>
{finalizedFilter === '全部'
? finalizedScript
: finalizedScript.split('\n').filter(line => {
const trimmedLine = line.trim();
if (!trimmedLine) return false;
// 1. Scene
const isScene = trimmedLine.includes('SCENE') || trimmedLine.includes('场景') || trimmedLine.startsWith('#');
// 2. Character
// Rule: Contains colon, len <= 20, not a scene
const isChar = !isScene && (trimmedLine.includes('?') || trimmedLine.includes(':')) && trimmedLine.length <= 20;
// 3. Dialogue
// Rule: Starts with quote
const isDialogue = !isScene && (trimmedLine.startsWith('?') || trimmedLine.startsWith('\"'));
// 4. Camera
// Rule: Keywords
const cameraKeywords = ['镜头', '特写', '远景', '近景', '中景', '全景', '推镜', '拉镜', '切换'];
const isCamera = !isScene && cameraKeywords.some(k => trimmedLine.includes(k));
// 5. Action
// Rule: None of the above
const isAction = !isScene && !isChar && !isDialogue && !isCamera;
if (finalizedFilter === '场景' && !isScene) return false; return (
if (finalizedFilter === '人物' && !isChar) return false; <section
if (finalizedFilter === '台词' && !isDialogue && !isChar) return false; // Keep character names with dialogue for context key={episode.id}
if (finalizedFilter === '动作' && !isAction) return false; id={episode.id}
if (finalizedFilter === '镜头提示' && !isCamera) return false; className="rounded-[20px] border border-[#D2D2D7]/40 bg-white shadow-[0_14px_42px_rgba(15,23,42,0.06)] overflow-hidden"
return true; >
}).join('\n\n') <button
} type="button"
</ReactMarkdown> onClick={() => toggleEpisodeCard(episode.id)}
</div> className="w-full px-6 py-5 flex items-center justify-between gap-4 bg-[linear-gradient(135deg,rgba(0,113,227,0.06),rgba(255,255,255,0.9))] hover:bg-[linear-gradient(135deg,rgba(0,113,227,0.10),rgba(255,255,255,0.96))] transition-colors"
</div> >
<div className="text-left">
<h3 className="text-[20px] leading-tight font-semibold text-[#1D1D1F]">{episode.title}</h3>
<p className="mt-1 text-[12px] font-medium text-[#6E6E73]">{'\u573a\u666f ' + episode.scenes.length + ' \u00b7 \u5b57\u6570 ' + episode.wordCount}</p>
</div>
<ChevronDown
size={18}
className={cn('text-[#6E6E73] transition-transform', episodeExpanded ? 'rotate-180' : '')}
/>
</button>
{episodeExpanded && (
<div className="px-5 pb-5 space-y-3">
{episode.scenes.map((scene, sceneIndex) => {
const sceneExpanded = expandedScenes.has(scene.id);
const sceneWordCount = scene.content.replace(/\s+/g, '').length;
return (
<article
key={scene.id}
id={scene.id}
className="rounded-2xl border border-[#E5E5EA] bg-[#FAFAFC] overflow-hidden"
>
<button
type="button"
onClick={() => toggleSceneCard(scene.id)}
className="w-full px-4 py-3 flex items-center justify-between gap-3 hover:bg-[#F2F6FD] transition-colors"
>
<div className="flex items-center gap-3 text-left min-w-0">
<span className="w-1.5 h-8 rounded-full bg-[#0071E3]/80" />
<div className="min-w-0">
<p className="text-[15px] font-semibold text-[#1D1D1F] truncate">
{scene.title || ('\u573a\u666f ' + (sceneIndex + 1))}
</p>
<p className="text-[11px] text-[#8E8E93] mt-0.5">{sceneWordCount + ' \u5b57'}</p>
</div>
</div>
<ChevronDown
size={16}
className={cn('text-[#8E8E93] transition-transform', sceneExpanded ? 'rotate-180' : '')}
/>
</button>
{sceneExpanded && (
<div className="px-4 pb-4">
<div className="rounded-xl bg-white border border-[#ECECEF] px-4 py-3">
<pre className="whitespace-pre-wrap break-words text-[14px] leading-[1.85] font-serif text-[#1D1D1F]">
{scene.content || '\uff08\u672c\u573a\u666f\u6682\u65e0\u5185\u5bb9\uff09'}
</pre>
</div>
</div>
)}
</article>
);
})}
</div>
)}
</section>
);
})}
</div> </div>
)} )}
</div> </div>
@ -2395,52 +2411,99 @@ ${script}`;
<div className="flex-1 overflow-y-auto p-4 space-y-6"> <div className="flex-1 overflow-y-auto p-4 space-y-6">
{(isSyncing || creationPages[currentPageIndex]?.scripts?.some(s => s)) ? ( {(isSyncing || creationPages[currentPageIndex]?.scripts?.some(s => s)) ? (
<div className="grid grid-cols-3 gap-4 h-full"> <div className="grid grid-cols-2 gap-4 h-full">
{creationPages[currentPageIndex]?.scripts?.map((script, idx) => ( {creationPages[currentPageIndex]?.scripts?.map((script, idx) => {
<div key={idx} className={cn( const creationScriptKey = (creationPages[currentPageIndex]?.id || '') + '-' + idx;
"flex flex-col rounded-2xl border transition-all overflow-hidden bg-white/50", const isEditingCreationScript = editingCreationScriptKey === creationScriptKey;
creationPages[currentPageIndex]?.selectedScriptIndex === idx
? "border-[#0071E3] ring-1 ring-[#0071E3] shadow-lg" return (
: "border-[#D2D2D7]/30 hover:border-[#D2D2D7]" <div key={idx} className={cn(
)}> "flex flex-col rounded-2xl border transition-all overflow-hidden bg-white/50",
<div className="p-3 border-b border-[#D2D2D7]/30 flex items-center justify-between bg-white/40"> creationPages[currentPageIndex]?.selectedScriptIndex === idx
<div className="flex items-center gap-2"> ? "border-[#0071E3] ring-1 ring-[#0071E3] shadow-lg"
<span className="text-[10px] font-bold text-[#86868B]">{['A', 'B', 'C'][idx]}</span> : "border-[#D2D2D7]/30 hover:border-[#D2D2D7]"
{isSyncing && !script && <Loader2 size={10} className="animate-spin text-blue-500" />} )}>
</div> <div className="p-3 border-b border-[#D2D2D7]/30 flex items-center justify-between bg-white/40">
<div className="flex items-center gap-1"> <div className="flex items-center gap-2">
<button <span className="text-[10px] font-bold text-[#86868B]">{['A', 'B', 'C'][idx] + '\u7248\u672c'}</span>
onClick={() => copyToClipboard(script, `${creationPages[currentPageIndex]?.id}-${idx}`)} {isSyncing && !script && <Loader2 size={10} className="animate-spin text-blue-500" />}
className="p-1.5 rounded-full hover:bg-black/5 text-[#86868B] transition-all"
>
{copiedId === `${creationPages[currentPageIndex]?.id}-${idx}` ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
</button>
<button
onClick={() => selectCurrentPageScript(idx)}
className={cn(
"px-3 py-1 rounded-full text-[10px] font-bold transition-all",
creationPages[currentPageIndex]?.selectedScriptIndex === idx
? "bg-[#0071E3] text-white"
: "bg-white border border-[#D2D2D7] text-[#1D1D1F] hover:bg-[#F5F5F7]"
)}
>
{creationPages[currentPageIndex]?.selectedScriptIndex === idx ? '\u5df2\u5b9a\u7a3f' : '\u5b9a\u7a3f'}
</button>
</div>
</div>
<div className="flex-1 p-4 overflow-y-auto font-mono text-[11px] leading-relaxed prose prose-sm max-w-none">
{script ? (
<ReactMarkdown>{script}</ReactMarkdown>
) : isSyncing ? (
<div className="space-y-2">
<div className="h-3 bg-black/5 rounded w-3/4 animate-pulse" />
<div className="h-3 bg-black/5 rounded w-1/2 animate-pulse" />
<div className="h-3 bg-black/5 rounded w-5/6 animate-pulse" />
</div> </div>
) : null} <div className="flex items-center gap-1">
<button
onClick={() => copyToClipboard(script, creationScriptKey)}
className="p-1.5 rounded-full hover:bg-black/5 text-[#86868B] transition-all"
>
{copiedId === creationScriptKey ? <Check size={12} className="text-green-500" /> : <Copy size={12} />}
</button>
<button
type="button"
onClick={() => setEditingCreationScriptKey((prev) => prev === creationScriptKey ? null : creationScriptKey)}
disabled={isSyncing}
className="px-2.5 py-1 rounded-full text-[10px] font-bold transition-all border border-[#D2D2D7] bg-white text-[#1D1D1F] hover:bg-[#F5F5F7] disabled:opacity-40"
>
{isEditingCreationScript ? '\u5b8c\u6210\u7f16\u8f91' : '\u7f16\u8f91'}
</button>
<button
onClick={() => selectCurrentPageScript(idx)}
className={cn(
"px-3 py-1 rounded-full text-[10px] font-bold transition-all",
creationPages[currentPageIndex]?.selectedScriptIndex === idx
? "bg-[#0071E3] text-white"
: "bg-white border border-[#D2D2D7] text-[#1D1D1F] hover:bg-[#F5F5F7]"
)}
>
{creationPages[currentPageIndex]?.selectedScriptIndex === idx ? '\u5df2\u5b9a\u7a3f' : '\u5b9a\u7a3f'}
</button>
</div>
</div>
<div className={cn(
"flex-1 p-4",
isEditingCreationScript && !isSyncing ? "flex overflow-hidden" : "overflow-y-auto"
)}>
{isEditingCreationScript && !isSyncing ? (
<div className="flex h-full w-full flex-1 flex-col gap-2">
<span className="inline-flex w-fit items-center rounded-full border border-[#0071E3]/25 bg-[#0071E3]/10 px-2.5 py-1 text-[10px] font-bold text-[#0071E3]">
{'\u573a\u666f\u6807\u8bc6\uff1a##\uff08\u4e0d\u53ef\u5220\u9664\uff09'}
</span>
<textarea
value={script}
onChange={(event) => updateCreationScriptContent(currentPageIndex, idx, event.target.value)}
className="w-full h-full min-h-0 flex-1 resize-none rounded-2xl border border-[#D2D2D7]/50 bg-white px-4 py-3 font-mono text-[16px] leading-8 text-[#1D1D1F] outline-none focus:border-[#0071E3]/40 focus:ring-2 focus:ring-[#0071E3]/10"
/>
</div>
) : script ? (
<div className="font-mono text-[16px] leading-8 text-[#1D1D1F]">
{splitScriptPreviewBlocks(script).map((block, blockIndex) => {
const content = block.type === 'scene'
? block.content.replace(/^#{1,3}\s*/, '')
: block.content;
return (
<p
key={(creationPages[currentPageIndex]?.id || '') + '-' + idx + '-' + blockIndex}
className={cn(
block.type === 'scene'
? 'mb-4 mt-2 text-[21px] font-black leading-9 tracking-[0.01em] text-[#111111]'
: 'mb-3 text-[16px] leading-8 text-[#1D1D1F]',
)}
>
{content}
</p>
);
})}
</div>
) : isSyncing ? (
<div className="space-y-2">
<div className="h-3 bg-black/5 rounded w-3/4 animate-pulse" />
<div className="h-3 bg-black/5 rounded w-1/2 animate-pulse" />
<div className="h-3 bg-black/5 rounded w-5/6 animate-pulse" />
</div>
) : null}
</div>
</div> </div>
</div> );
))} })}
</div> </div>
) : ( ) : (
<div className="h-full flex flex-col items-center justify-center text-[#86868B] space-y-4 opacity-50"> <div className="h-full flex flex-col items-center justify-center text-[#86868B] space-y-4 opacity-50">

View File

@ -0,0 +1,63 @@
export type CreationGenerationMode = 'single' | 'batch';
export type CreationBackgroundField = 'outline' | 'worldview';
interface CreationBackgroundValues {
outline: string;
worldview: string;
}
interface CreationValidationPrompt {
title: string;
description: string;
actionLabel: string;
}
const FIELD_LABELS: Record<CreationBackgroundField, string> = {
outline: '\u6545\u4e8b\u5927\u7eb2',
worldview: '\u4e16\u754c\u89c2\u8bbe\u5b9a',
};
const TITLE_TEXT = '\u7f3a\u5c11\u5267\u60c5\u80cc\u666f\u8bbe\u5b9a';
const ACTION_LABEL_TEXT = '\u53bb\u8865\u5145\u8bbe\u5b9a';
export function getMissingCreationBackgroundFields(
values: CreationBackgroundValues,
): CreationBackgroundField[] {
const missingFields: CreationBackgroundField[] = [];
if (!values.outline.trim()) {
missingFields.push('outline');
}
if (!values.worldview.trim()) {
missingFields.push('worldview');
}
return missingFields;
}
function joinFieldLabels(fields: CreationBackgroundField[]) {
if (fields.length <= 0) {
return '';
}
if (fields.length === 1) {
return `${FIELD_LABELS[fields[0]]}`;
}
return fields.map((field) => `${FIELD_LABELS[field]}`).join('\u548c');
}
export function buildCreationValidationPrompt(
mode: CreationGenerationMode,
missingFields: CreationBackgroundField[] = ['outline', 'worldview'],
): CreationValidationPrompt {
const actionText = mode === 'batch' ? '\u6267\u884c\u6279\u91cf\u751f\u6210' : '\u6267\u884c\u751f\u6210';
const fieldsText = joinFieldLabels(missingFields);
return {
title: TITLE_TEXT,
description: `\u8bf7\u5148\u5728“\u5267\u60c5\u80cc\u666f”\u4e2d\u586b\u5199${fieldsText}\u540e\u518d${actionText}\u3002`,
actionLabel: ACTION_LABEL_TEXT,
};
}

162
src/lib/finalizedOutline.ts Normal file
View File

@ -0,0 +1,162 @@
export interface FinalizedScene {
id: string;
title: string;
content: string;
}
export interface FinalizedEpisode {
id: string;
title: string;
scenes: FinalizedScene[];
content: string;
wordCount: number;
}
export interface FinalizedSidebarItem {
type: 'episode' | 'scene';
text: string;
id: string;
}
export interface FinalizedOutline {
episodes: FinalizedEpisode[];
sidebarItems: FinalizedSidebarItem[];
}
const EPISODE_REGEX = /^##\s*\u7b2c\s*\d+\s*\u96c6/;
const SCENE_REGEX_CN_HEADING = /^##\s*\u573a\u666f\s*\d+/;
const SCENE_REGEX_CN = /^\u573a\u666f\s*\d+/;
const SCENE_REGEX_EN_HEADING = /^##\s*SCENE\s*\d+/i;
const SCENE_REGEX_EN = /^SCENE\s*\d+/i;
const cleanHeading = (line: string): string => line.replace(/^#+\s*/, '').trim();
const countWords = (text: string): number => text.replace(/\s+/g, '').length;
const isEpisodeHeading = (line: string): boolean => EPISODE_REGEX.test(line.trim());
const isSceneHeading = (line: string): boolean => {
const trimmed = line.trim();
return SCENE_REGEX_CN_HEADING.test(trimmed)
|| SCENE_REGEX_CN.test(trimmed)
|| SCENE_REGEX_EN_HEADING.test(trimmed)
|| SCENE_REGEX_EN.test(trimmed);
};
export const parseFinalizedOutline = (script: string): FinalizedOutline => {
const lines = script.split('\n');
const episodes: Array<Omit<FinalizedEpisode, 'wordCount'>> = [];
let episodeCounter = 0;
let sceneCounter = 0;
let currentEpisode: Omit<FinalizedEpisode, 'wordCount'> | null = null;
let currentScene: FinalizedScene | null = null;
let episodeBuffer: string[] = [];
const flushScene = () => {
if (!currentScene || !currentEpisode) return;
currentScene.content = currentScene.content.trim();
currentEpisode.scenes.push(currentScene);
currentScene = null;
};
const flushEpisode = () => {
if (!currentEpisode) return;
flushScene();
currentEpisode.content = episodeBuffer.join('\n').trim();
episodes.push(currentEpisode);
currentEpisode = null;
episodeBuffer = [];
};
const ensureEpisode = () => {
if (currentEpisode) return;
episodeCounter += 1;
currentEpisode = {
id: `episode-${episodeCounter - 1}`,
title: `\u7b2c${episodeCounter}\u96c6 \u672a\u547d\u540d\u7ae0\u8282`,
scenes: [],
content: '',
};
};
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) {
if (currentScene) {
currentScene.content += `${currentScene.content ? '\n' : ''}`;
}
if (currentEpisode) {
episodeBuffer.push('');
}
continue;
}
if (trimmed === '---') {
continue;
}
if (isEpisodeHeading(line)) {
flushEpisode();
episodeCounter += 1;
const title = cleanHeading(line);
currentEpisode = {
id: `episode-${episodeCounter - 1}`,
title,
scenes: [],
content: '',
};
episodeBuffer.push(title);
continue;
}
if (isSceneHeading(line)) {
ensureEpisode();
flushScene();
const title = cleanHeading(line);
currentScene = {
id: `scene-${sceneCounter}`,
title,
content: '',
};
sceneCounter += 1;
episodeBuffer.push(title);
continue;
}
ensureEpisode();
if (currentScene) {
currentScene.content += `${currentScene.content ? '\n' : ''}${line}`;
}
episodeBuffer.push(line);
}
flushEpisode();
const normalizedEpisodes: FinalizedEpisode[] = episodes.map((episode, idx) => {
const scenes = episode.scenes.length > 0
? episode.scenes
: [{ id: `scene-fallback-${idx}`, title: '\u573a\u666f 1', content: episode.content }];
const cleanedScenes = scenes.map((scene) => ({ ...scene, content: scene.content.trim() }));
return {
...episode,
scenes: cleanedScenes,
content: episode.content.trim(),
wordCount: countWords(episode.content),
};
});
const sidebarItems: FinalizedSidebarItem[] = [];
for (const episode of normalizedEpisodes) {
sidebarItems.push({ type: 'episode', text: episode.title, id: episode.id });
for (const scene of episode.scenes) {
sidebarItems.push({ type: 'scene', text: scene.title, id: scene.id });
}
}
return { episodes: normalizedEpisodes, sidebarItems };
};

11
src/lib/modeSource.ts Normal file
View File

@ -0,0 +1,11 @@
export type RootTab = 'conversion' | 'creation';
export type AppTab = RootTab | 'finalized';
export const getDisplayedMode = (activeTab: AppTab, previousTab: RootTab): RootTab => {
return activeTab === 'finalized' ? previousTab : activeTab;
};
export const getDisplayedModeLabel = (activeTab: AppTab, previousTab: RootTab): string => {
const mode = getDisplayedMode(activeTab, previousTab);
return mode === 'conversion' ? '\u4e00\u952e\u8f6c\u6362\u6a21\u5f0f' : '\u5373\u65f6\u521b\u4f5c\u6a21\u5f0f';
};

145
src/lib/promptBuilder.ts Normal file
View File

@ -0,0 +1,145 @@
import {
AUDIENCE_PROMPT_MAP,
NARRATIVE_PROMPT_MAP,
THEME_PROMPT_MAP,
WORD_RANGE_PROMPT_MAP,
} from './promptProfiles.ts';
import type { PromptProfileGlobalSettings } from './promptProfiles.ts';
const FALLBACK_TEXT = '未设定';
export interface StructuredUserPromptInput {
sourceText: string;
scriptType: string;
scriptFormat: string;
combinedStyle?: string;
selectedThemes?: string[];
selectedNarratives?: string[];
globalSettings?: PromptProfileGlobalSettings;
previousEpisodeContent?: string;
episodeOutline?: string;
}
function uniqueLabels(values: string[] | undefined): string[] {
if (!values || values.length === 0) return [];
const cleaned = values
.map((value) => value.trim())
.filter(Boolean);
return Array.from(new Set(cleaned));
}
function parseCombinedStyle(combinedStyle: string | undefined): string[] {
if (!combinedStyle) return [];
return uniqueLabels(combinedStyle.split('+').map((value) => value.trim()));
}
function resolveGuidance(labels: string[], map: Record<string, string[]>, unknownPrefix: string): string[] {
if (labels.length === 0) return [];
const result: string[] = [];
for (const label of labels) {
const guidance = map[label];
if (guidance && guidance.length > 0) {
result.push(...guidance);
continue;
}
result.push(`${unknownPrefix}${label},并让该参数直接影响剧情推进。`);
}
return Array.from(new Set(result));
}
function formatBulletLines(lines: string[], fallbackLine: string): string {
const finalLines = lines.length > 0 ? lines : [fallbackLine];
return finalLines.map((line) => `- ${line}`).join('\n');
}
function pickValue(value: string | undefined): string {
const trimmed = value?.trim();
return trimmed ? trimmed : FALLBACK_TEXT;
}
export function buildStructuredUserPrompt(input: StructuredUserPromptInput): string {
const parsedCombined = parseCombinedStyle(input.combinedStyle);
const themeLabels = uniqueLabels(
input.selectedThemes && input.selectedThemes.length > 0
? input.selectedThemes
: parsedCombined.filter((label) => Boolean(THEME_PROMPT_MAP[label])),
);
const narrativeLabels = uniqueLabels([
...(input.selectedNarratives ?? []),
...parsedCombined.filter((label) => Boolean(NARRATIVE_PROMPT_MAP[label])),
]);
const styleGuidanceLines = [
`已选核心风格:${themeLabels.length > 0 ? themeLabels.join('、') : FALLBACK_TEXT}`,
...resolveGuidance(themeLabels, THEME_PROMPT_MAP, '已选择参数:'),
];
const narrativeGuidanceLines = [
`基础叙事格式:${pickValue(input.scriptFormat)}`,
`已选叙事手法:${narrativeLabels.length > 0 ? narrativeLabels.join('、') : FALLBACK_TEXT}`,
...resolveGuidance(narrativeLabels, NARRATIVE_PROMPT_MAP, '已选择叙事参数:'),
];
const audiencePreference = pickValue(input.globalSettings?.audiencePreference);
const audienceGuidanceLines = [
`受众倾向:${audiencePreference}`,
...resolveGuidance(
audiencePreference === FALLBACK_TEXT ? [] : [audiencePreference],
AUDIENCE_PROMPT_MAP,
'受众倾向为:',
),
];
const wordRange = pickValue(input.globalSettings?.wordRange);
const wordRangeGuidanceLines = [
`字数范围:${wordRange}`,
...resolveGuidance(
wordRange === FALLBACK_TEXT ? [] : [wordRange],
WORD_RANGE_PROMPT_MAP,
'字数约束为:',
),
];
const previousEpisodeContent = pickValue(input.previousEpisodeContent);
return `请根据以下信息进行创作:
${previousEpisodeContent}
1.
${pickValue(input.episodeOutline || input.sourceText)}
2.
${pickValue(input.scriptType)}
3.
${formatBulletLines(styleGuidanceLines, '保持当前风格稳定推进。')}
4.
${formatBulletLines(narrativeGuidanceLines, '按当前叙事节奏完成该集。')}
5.
${formatBulletLines(audienceGuidanceLines, '根据目标受众调整情绪目标与爽点设计。')}
6.
${formatBulletLines(wordRangeGuidanceLines, '根据剧情复杂度决定篇幅,保证节奏与完整度。')}
7.
${pickValue(input.globalSettings?.worldview)}
8.
${pickValue(input.globalSettings?.outline)}
9.
${pickValue(input.globalSettings?.characters)}
`;
}

128
src/lib/promptProfiles.ts Normal file
View File

@ -0,0 +1,128 @@
export interface PromptProfileGlobalSettings {
worldview: string;
outline: string;
characters: string;
audiencePreference: string;
wordRange: string;
}
export type PromptGuidanceMap = Record<string, string[]>;
export const THEME_PROMPT_MAP: PromptGuidanceMap = {
'悬疑': [
'信息披露采用延迟策略,每个关键场景至少留下一个待解疑点。',
'优先制造“已知与未知”的落差,让读者持续追问真相。',
],
'侦探': [
'围绕“线索收集-分析-验证”推进,不可只给结论不给过程。',
'每次调查推进都要带来新证据或推翻旧判断。',
],
'犯罪': [
'明确犯罪动机、手段与风险,冲突必须具备现实压力。',
'强化后果链条,避免“无代价犯罪”削弱可信度。',
],
'反转': [
'反转必须基于前文可回溯信息,不能凭空硬拐。',
'反转后要立即重排人物关系或目标,形成新一轮推进。',
],
'惊悚': [
'通过环境、时间限制与感官细节持续制造压迫感。',
'危险要逐级升级,避免同强度重复刺激。',
],
'推理': [
'结论必须由证据链推导,关键判断给出清晰因果。',
'控制误导信息比例,确保读者能在回看时自洽。',
],
};
export const NARRATIVE_PROMPT_MAP: PromptGuidanceMap = {
'顺叙': [
'按时间线稳定推进,重点保证因果清晰。',
'每个场景承接上一个场景的问题,并给出新的推进。',
],
'倒叙': [
'先抛结果或高压节点,再回溯关键成因。',
'倒叙段落必须服务当前主线,不可成为无关回忆。',
],
'插叙': [
'插叙只补关键动机、关系或秘密,不做冗长旁枝。',
'插叙结束后要迅速回到主线并改变当前局势。',
],
'第一人称': [
'严格限制在主角可感知范围内,保持主观一致性。',
'通过内心判断与即时反应增强代入感。',
],
'第三人称全知 / 限知': [
'全知视角用于宏观铺排,限知视角用于悬念控制。',
'视角切换必须有明确目的,避免信息过载。',
],
'多线叙事': [
'每条线都要有独立目标与阶段性冲突,不可空转。',
'在关键节点做线与线的呼应或碰撞,形成汇流。',
],
'环形叙事': [
'开篇意象或问题在结尾形成回应,但语义要升级。',
'首尾呼应不是重复,要体现人物或局势变化。',
],
'蒙太奇': [
'用并置镜头表达时间压缩、关系对照或情绪推进。',
'片段拼接后必须输出明确信息增量。',
],
'留白叙事': [
'关键事实可不直说,但要给足可推断信号。',
'留白后尽快在后续场景兑现,不长期悬空。',
],
'悬念叙事': [
'每个段落结尾尽量设置未解问题或风险升级点。',
'悬念要按“提出-加深-兑现/反转”节奏闭环。',
],
};
export const AUDIENCE_PROMPT_MAP: PromptGuidanceMap = {
'男频': [
'目标读者:男性为主。',
'核心情绪:成就感 / 权力感 / 逆袭爽感。',
'主角需求:变强、翻盘、掌控世界。',
'爽点来源:打脸、升级、权力提升。',
'写男频,要构建一个宏大且规则残酷的世界,让主角去征服它;',
],
'女频': [
'目标读者:女性为主。',
'核心情绪:情感满足 / 关系张力 / 被偏爱。',
'主角需求:被爱、选择、情感成长。',
'爽点来源:情感拉扯、宠爱、修罗场。',
'写女频,要编织一张错综复杂的情感与社会关系网,让主角去撕破或重塑它。',
],
};
export const WORD_RANGE_PROMPT_MAP: PromptGuidanceMap = {
'200 - 500': [
'目标字数200 - 500 字。',
'极度压缩,只保留核心冲突、一次推进和一个结尾钩子。',
'场景尽量少,对白必须有信息密度。',
],
'500 - 1000': [
'目标字数500 - 1000 字。',
'保持紧凑,允许一个清晰推进段和一个小高潮。',
'可以有少量氛围和动作细节,但不能稀释节奏。',
],
'1000 - 2000': [
'目标字数1000 - 2000 字。',
'完整展开一条主冲突线,并加入适度人物反应。',
'中段必须有明确升级,不能只有起承没有转折。',
],
'2000 - 3000': [
'目标字数2000 - 3000 字。',
'允许较完整的铺垫、升级、高潮和悬念结构。',
'可以增强动作、环境和关系细节,但必须围绕主线收束。',
],
'3000以上': [
'目标字数3000 字以上。',
'允许多轮冲突推进和更丰富场景层次。',
'每 2 到 3 个场景最好都带来新的信息推进或冲突升级。',
],
'不限': [
'字数不设上限,按剧情复杂度决定篇幅。',
'优先保证节奏和完整度,不为了拉长而拉长,不为了压缩而压缩。',
],
};

View File

@ -1,8 +1,23 @@
export type ScriptPreviewBlock = export type ScriptPreviewBlock =
| { type: 'scene'; content: string } | { type: 'scene'; content: string }
| { type: 'text'; content: string }; | { type: 'text'; content: string };
const SCENE_LINE_PATTERN = /^\s*(?:#{0,2}\s*)?(?:\u573A\u666F(?:\s*\d+)?|SCENE\b)/i; const SCENE_LINE_PATTERN = /^\s*(?:#{0,6}\s*)?(?:\u573a\u666f(?:\s*\d+)?|SCENE\b)/i;
export function normalizeSceneHeadingMarkers(content: string): string {
return content
.replace(/\r\n/g, '\n')
.split('\n')
.map((line) => {
const trimmed = line.trim();
if (!trimmed) return line;
if (!SCENE_LINE_PATTERN.test(trimmed)) return line;
const withoutMarker = trimmed.replace(/^#{1,6}\s*/, '');
return `## ${withoutMarker}`;
})
.join('\n');
}
export function splitScriptPreviewBlocks(content: string): ScriptPreviewBlock[] { export function splitScriptPreviewBlocks(content: string): ScriptPreviewBlock[] {
return content return content

View File

@ -1,6 +1,7 @@
import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai"; import { GoogleGenAI, Type, ThinkingLevel } from "@google/genai";
import OpenAI from "openai"; import OpenAI from "openai";
import { isConversionAbortedError, throwIfAborted } from "../lib/conversionWorkflow"; import { isConversionAbortedError, throwIfAborted } from "../lib/conversionWorkflow";
import { buildStructuredUserPrompt } from "../lib/promptBuilder";
export interface AIConfig { export interface AIConfig {
model: 'gemini' | 'doubao'; model: 'gemini' | 'doubao';
@ -227,6 +228,8 @@ export async function convertTextToScript(
previousEpisodeContent?: string; previousEpisodeContent?: string;
episodeTitle?: string; episodeTitle?: string;
episodeOutline?: string; episodeOutline?: string;
selectedThemes?: string[];
selectedNarratives?: string[];
} }
): Promise<void> { ): Promise<void> {
try { try {
@ -611,32 +614,17 @@ ${seedContent}
try { try {
throwIfAborted(options?.signal); throwIfAborted(options?.signal);
const prompt = `请根据以下信息进行创作: const prompt = buildStructuredUserPrompt({
sourceText,
${options?.previousEpisodeContent || ''} scriptType,
scriptFormat,
1. combinedStyle,
${options?.episodeOutline || sourceText} selectedThemes: options?.selectedThemes,
selectedNarratives: options?.selectedNarratives,
2. globalSettings,
${scriptType} previousEpisodeContent: options?.previousEpisodeContent,
episodeOutline: options?.episodeOutline || sourceText,
3. });
${combinedStyle}
4.
${scriptFormat}
5.
${globalSettings?.worldview || '未设定'}
6.
${globalSettings?.outline || '未设定'}
7.
${globalSettings?.characters || '未设定'}
`;
const systemInstruction = baseSystemInstruction + v.instruction; const systemInstruction = baseSystemInstruction + v.instruction;
let fullText = seedContent; let fullText = seedContent;
@ -771,545 +759,28 @@ export async function generatePageScript(
config: AIConfig config: AIConfig
): Promise<void> { ): Promise<void> {
try { try {
const previousContext = previousScript ? ` const combinedStyle = [...styles, ...narratives].join(' + ');
稿
${previousScript} await convertTextToScript(
story,
scriptType,
` : ''; scriptFormat,
combinedStyle,
const baseSystemInstruction = `你是一位专业影视编剧和剧本改编专家。 onUpdate,
config,
globalSettings,
-
-
-
-
-
-
****
---
#
****
****
-
-
- 线
-
-
- ****
-
****
---
#
1.
2.
-
-
-
-
3.
4. ****
---
#
1. ****
---
1. ****
---
1. ****
---
1. ****
线
---
1. ****
-
-
-
- 线
****
---
#
使 ****
---
#
****
- 13
- 20100
** + **
- 36
-
-
### 1. 3
****
-
-
-
-
****
---
### 2.
- 30
- 1
-
---
### 3.
-
-
-
**3**
---
### 4.
-
-
-
-
-
-
---
### 5.
-
-
-
---
#
- 45
- 12100
** + 线**
- 612
---
### 1.
-
-
-
---
### 2. 线
- 线
- 线
线
-
-
-
---
### 3. +
-
-
-
- 线
---
### 4.
-
-
-
---
### 5.
-
-
-
-
---
#
- 90120
****
****
---
###
-
-
-
-
---
###
-
-
-
---
###
-
-
-
-
---
###
- 线
- 线
-
-
---
#
## 1 /
---
#
## 1 /
---`;
const versionPrompts = [
{ {
id: 0, targetVersionIds: [0, 1],
instruction: ` previousEpisodeContent: previousScript || '',
# A版本 episodeOutline: story,
selectedThemes: styles,
selectedNarratives: narratives,
线
#
使 Markdown JSON [[VERSION]]
使`
}, },
{ );
id: 1,
instruction: `
# B版本
线
#
使 Markdown JSON [[VERSION]]
使`
},
{
id: 2,
instruction: `
#
线
#
使 Markdown JSON [[VERSION]]
使`
}
];
const generateVersion = async (v: { id: number, instruction: string }) => {
try {
const prompt = `请根据以下信息进行创作:
${previousContext}
1.
${story}
2.
${scriptType}
3.
${styles.join(', ')}
4.
${narratives.join(', ')}
5.
${globalSettings?.worldview || '未设定'}
6.
${globalSettings?.outline || '未设定'}
7.
${globalSettings?.characters || '未设定'}
`;
const systemInstruction = baseSystemInstruction + v.instruction;
if (config.model === 'gemini') {
const ai = getGeminiClient(config.apiKey);
const result = await ai.models.generateContentStream({
model: "gemini-3-flash-preview",
contents: [
{
role: "user",
parts: [{ text: prompt }]
}
],
config: {
systemInstruction: systemInstruction,
}
});
let fullText = "";
for await (const chunk of result) {
const chunkText = chunk.text;
if (chunkText) {
fullText += chunkText;
onUpdate(v.id, fullText);
}
}
} else {
const client = getDoubaoClient(config.apiKey);
const stream: any = await client.chat.completions.create({
model: "doubao-seed-2-0-lite-260215",
messages: [
{ role: "system", content: systemInstruction },
{ role: "user", content: prompt }
],
stream: true,
thinking: { "type": "disabled" }
// extra_body: {
// "thinking": { "type": "enabled" },
// "reasoning_effort": "minimal"
// }
} as any);
let fullText = "";
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || "";
if (content) {
fullText += content;
onUpdate(v.id, fullText);
}
}
}
} catch (error) {
console.error(`Error generating version ${v.id}:`, error);
onUpdate(v.id, "生成失败");
}
};
await Promise.all(versionPrompts.map(v => generateVersion(v)));
} catch (error) { } catch (error) {
console.error("Error generating page script:", error); console.error("Error generating page script:", error);
onUpdate(0, "\u751f\u6210\u5931\u8d25");
onUpdate(1, "\u751f\u6210\u5931\u8d25");
} }
} }
@ -1323,82 +794,40 @@ export async function generateAllScripts(
globalSettings?: { worldview: string; outline: string; characters: string; audiencePreference: string; wordRange: string } globalSettings?: { worldview: string; outline: string; characters: string; audiencePreference: string; wordRange: string }
): Promise<string[][]> { ): Promise<string[][]> {
try { try {
const generateForPage = async (pageStory: string, pageIndex: number): Promise<string[]> => { const results: string[][] = [];
const globalContext = globalSettings ? ` let previousScript = '';
剧情背景设定: ${globalSettings.worldview || '未设定'}
故事大纲: ${globalSettings.outline || '未设定'}
核心角色简介: ${globalSettings.characters || '未设定'}
` : '';
const basePrompt = `请将以下故事情节片段改编成三个不同侧重点的专业 ${scriptType} 剧本版本。 for (const page of pages) {
const versions = ['', ''];
${globalContext}
当前集剧情指令: ${pageStory}
剧本格式要求: ${scriptFormat}
风格标签: ${styles.join(', ')}
叙事手法: ${narratives.join(', ')}
Markdown
[[VERSION A]] await generatePageScript(
... page.story,
[[VERSION B]] scriptType,
... scriptFormat,
[[VERSION C]] styles,
...`; narratives,
globalSettings,
const systemInstruction = `你是一位专业影视编剧和剧本改编专家。你的任务是将故事文本改编为影视剧本。请严格遵守剧本创作规则,并按指定的 [[VERSION X]] 标签格式输出。请务必使用中文。`; previousScript,
(versionIndex, content) => {
let text = ""; if (versionIndex >= 0 && versionIndex < versions.length) {
if (config.model === 'gemini') { versions[versionIndex] = content;
const ai = getGeminiClient(config.apiKey);
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: basePrompt }] }],
config: { systemInstruction }
});
text = response.text || "";
} else {
const client = getDoubaoClient(config.apiKey);
const response = await client.chat.completions.create({
model: "doubao-seed-2-0-lite-260215",
messages: [
{ role: "system", content: systemInstruction },
{ role: "user", content: basePrompt }
],
thinking: { "type": "disabled" }
// extra_body: {
// "thinking": { "type": "enabled" },
// "reasoning_effort": "minimal"
// }
} as any);
text = response.choices[0]?.message?.content || "";
}
const versions = ["", "", ""];
if (text) {
const versionParts = text.split(/\[\[VERSION [A-C]\]\]/);
const versionHeaders = text.match(/\[\[VERSION [A-C]\]\]/g) || [];
versionHeaders.forEach((vHeader, vIdx) => {
const versionId = vHeader.replace('[[VERSION ', '').replace(']]', '');
const content = versionParts[vIdx + 1] || "";
const vMap: Record<string, number> = { A: 0, B: 1, C: 2 };
const vIndex = vMap[versionId];
if (vIndex !== undefined) {
versions[vIndex] = content.trim();
} }
}); },
} config,
return versions; );
};
return await Promise.all(pages.map((p, i) => generateForPage(p.story, i))); if (!versions.some((content) => content.trim())) {
versions[0] = "\u751f\u6210\u5931\u8d25";
versions[1] = "\u751f\u6210\u5931\u8d25";
}
results.push(versions);
previousScript = versions[0].trim() ? versions[0] : previousScript;
}
return results;
} catch (error) { } catch (error) {
console.error("Error generating all scripts:", error); console.error("Error generating all scripts:", error);
return pages.map(() => ["生成失败", "生成失败", "生成失败"]); return pages.map(() => ["\u751f\u6210\u5931\u8d25", "\u751f\u6210\u5931\u8d25"]);
} }
} }