|
|
@@ -106,6 +106,10 @@ export default function NovelContextManager({
|
|
|
const [marathonMode, setMarathonMode] = useState(false);
|
|
|
const [marathonTarget, setMarathonTarget] = useState(0); // 0 = no limit
|
|
|
const [marathonPaused, setMarathonPaused] = useState(false);
|
|
|
+ const [showMarathonSetup, setShowMarathonSetup] = useState(false);
|
|
|
+ const [marathonSetupTarget, setMarathonSetupTarget] = useState(20);
|
|
|
+ const [marathonOutline, setMarathonOutline] = useState('');
|
|
|
+ const [isGeneratingOutline, setIsGeneratingOutline] = useState(false);
|
|
|
const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
|
|
|
if (typeof window !== 'undefined') {
|
|
|
return localStorage.getItem(TEMPLATE_STORAGE_KEY) || PROMPT_TEMPLATES[0].id;
|
|
|
@@ -201,6 +205,75 @@ export default function NovelContextManager({
|
|
|
[saveProjectConfig]
|
|
|
);
|
|
|
|
|
|
+ // ── Marathon outline generation ──
|
|
|
+ const handleGenerateMarathonOutline = useCallback(async () => {
|
|
|
+ if (!apiSettings) return;
|
|
|
+ setIsGeneratingOutline(true);
|
|
|
+ try {
|
|
|
+ const graphRes = await fetch(`/api/novel/event-graph?projectPath=${encodeURIComponent(projectPath)}`);
|
|
|
+ const graphData = await graphRes.json();
|
|
|
+
|
|
|
+ const prompt = `你是一位小说大纲规划师。请为第${chapters.length + 1}章到第${marathonSetupTarget}章生成一个逐章大纲。
|
|
|
+
|
|
|
+## 事件图谱
|
|
|
+${JSON.stringify(graphData.events || [], null, 2)}
|
|
|
+
|
|
|
+## 章节分配
|
|
|
+${JSON.stringify(graphData.chapterAllocations || [], null, 2)}
|
|
|
+
|
|
|
+## 要求
|
|
|
+- 每章写1-2句话,概括本章的核心情节
|
|
|
+- 确保事件图谱中分配的事件都在对应章节中体现
|
|
|
+- 如有伏笔,标注埋入章节和回收章节
|
|
|
+- 每个"卷"(约10章)有一个主题概括
|
|
|
+
|
|
|
+## 输出格式
|
|
|
+用 Markdown,每章一行:
|
|
|
+- 第N章:一句话概括`;
|
|
|
+
|
|
|
+ const res = await fetch('/api/proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ targetUrl: apiSettings.apiUrl,
|
|
|
+ headers: { Authorization: `Bearer ${apiSettings.apiKey}` },
|
|
|
+ body: { model: apiSettings.model, messages: [{ role: 'user', content: prompt }], max_tokens: 3000 },
|
|
|
+ isOllama: apiSettings.apiUrl.includes('11434'),
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ const data = await res.json();
|
|
|
+ setMarathonOutline(data.choices?.[0]?.message?.content || '');
|
|
|
+ } catch {
|
|
|
+ // ignore
|
|
|
+ } finally {
|
|
|
+ setIsGeneratingOutline(false);
|
|
|
+ }
|
|
|
+ }, [apiSettings, projectPath, chapters.length, marathonSetupTarget]);
|
|
|
+
|
|
|
+ // ── Start marathon ──
|
|
|
+ const handleStartMarathon = useCallback(async () => {
|
|
|
+ // Save the marathon outline
|
|
|
+ if (marathonOutline.trim()) {
|
|
|
+ try {
|
|
|
+ await fetch('/api/novel/files', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ action: 'write',
|
|
|
+ projectPath,
|
|
|
+ filePath: 'world_state/marathon-outline.md',
|
|
|
+ content: marathonOutline,
|
|
|
+ }),
|
|
|
+ });
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ }
|
|
|
+ setMarathonTarget(marathonSetupTarget);
|
|
|
+ setMarathonMode(true);
|
|
|
+ setMarathonPaused(false);
|
|
|
+ setShowMarathonSetup(false);
|
|
|
+ setShowPipeline(true);
|
|
|
+ }, [projectPath, marathonOutline, marathonSetupTarget]);
|
|
|
+
|
|
|
// ── Chapter pipeline ──
|
|
|
const handleStartNewChapter = () => {
|
|
|
setShowPipeline(true);
|
|
|
@@ -316,12 +389,17 @@ export default function NovelContextManager({
|
|
|
recentChaptersCount={recentChaptersCount}
|
|
|
onComplete={handlePipelineComplete}
|
|
|
onCancel={handlePipelineCancel}
|
|
|
+ autoMode={marathonMode}
|
|
|
+ marathonChapterIndex={chapters.length + 1}
|
|
|
+ marathonTarget={marathonTarget}
|
|
|
+ onStopAuto={() => setMarathonMode(false)}
|
|
|
/>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
// ── Main layout ──
|
|
|
return (
|
|
|
+ <>
|
|
|
<div className="flex h-screen bg-gray-50 overflow-hidden">
|
|
|
{/* Main area */}
|
|
|
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
|
@@ -501,33 +579,16 @@ export default function NovelContextManager({
|
|
|
</span>
|
|
|
</div>
|
|
|
) : (
|
|
|
- /* Inactive marathon mode — checkbox + target input */
|
|
|
- <div className="flex items-center gap-3 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg">
|
|
|
- <label className="flex items-center gap-2 cursor-pointer">
|
|
|
- <input
|
|
|
- type="checkbox"
|
|
|
- checked={marathonMode}
|
|
|
- onChange={(e) => setMarathonMode(e.target.checked)}
|
|
|
- className="w-4 h-4 text-indigo-600 rounded focus:ring-indigo-500"
|
|
|
- />
|
|
|
- <span className="text-xs text-gray-600">马拉松模式</span>
|
|
|
- </label>
|
|
|
- <div className="flex items-center gap-1.5">
|
|
|
- <span className="text-xs text-gray-500">目标:</span>
|
|
|
- <input
|
|
|
- type="number"
|
|
|
- min={0}
|
|
|
- value={marathonTarget || ''}
|
|
|
- onChange={(e) => {
|
|
|
- const val = parseInt(e.target.value, 10);
|
|
|
- setMarathonTarget(isNaN(val) || val < 0 ? 0 : val);
|
|
|
- }}
|
|
|
- placeholder="∞"
|
|
|
- className="w-16 px-2 py-1 text-xs text-gray-700 border border-gray-300 rounded focus:outline-none focus:ring-1 focus:ring-indigo-500"
|
|
|
- />
|
|
|
- <span className="text-xs text-gray-500">章</span>
|
|
|
- </div>
|
|
|
- </div>
|
|
|
+ /* Inactive marathon mode — setup button */
|
|
|
+ <button
|
|
|
+ onClick={() => setShowMarathonSetup(true)}
|
|
|
+ className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 transition-colors"
|
|
|
+ >
|
|
|
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" />
|
|
|
+ </svg>
|
|
|
+ 马拉松模式
|
|
|
+ </button>
|
|
|
)}
|
|
|
|
|
|
{/* Pulsing indicator when marathon is active and pipeline is not showing */}
|
|
|
@@ -608,5 +669,73 @@ export default function NovelContextManager({
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
+
|
|
|
+ {/* Marathon Setup Dialog */}
|
|
|
+ {showMarathonSetup && (
|
|
|
+ <div className="fixed inset-0 z-50 bg-black/40 flex items-center justify-center" onClick={() => setShowMarathonSetup(false)}>
|
|
|
+ <div className="bg-white rounded-2xl shadow-2xl w-full max-w-lg mx-4 max-h-[85vh] overflow-y-auto" onClick={(e) => e.stopPropagation()}>
|
|
|
+ <div className="px-6 py-5 border-b border-gray-200">
|
|
|
+ <h2 className="text-lg font-semibold text-gray-900">马拉松模式设置</h2>
|
|
|
+ <p className="text-sm text-gray-500 mt-1">设置目标章节数和大纲,AI 将自动续写</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="px-6 py-5 space-y-5">
|
|
|
+ {/* Chapter target */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-1.5">目标章节数</label>
|
|
|
+ <input
|
|
|
+ type="number"
|
|
|
+ min={1}
|
|
|
+ value={marathonSetupTarget}
|
|
|
+ onChange={(e) => setMarathonSetupTarget(Math.max(1, parseInt(e.target.value) || 1))}
|
|
|
+ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
|
+ />
|
|
|
+ <p className="text-xs text-gray-400 mt-1">从第 {chapters.length + 1} 章开始,共续写至 {marathonSetupTarget} 章</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Plot outline */}
|
|
|
+ <div>
|
|
|
+ <label className="block text-sm font-medium text-gray-700 mb-1.5">批量大纲(可选)</label>
|
|
|
+ <textarea
|
|
|
+ value={marathonOutline}
|
|
|
+ onChange={(e) => setMarathonOutline(e.target.value)}
|
|
|
+ placeholder="在此填写逐章大纲,或点击下方按钮让 AI 生成..."
|
|
|
+ className="w-full px-3 py-2 text-sm border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none h-40"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* AI Generate Outline */}
|
|
|
+ <button
|
|
|
+ onClick={handleGenerateMarathonOutline}
|
|
|
+ disabled={isGeneratingOutline || !apiSettings}
|
|
|
+ className="flex items-center justify-center gap-2 w-full px-4 py-2 text-sm font-medium text-indigo-700 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors"
|
|
|
+ >
|
|
|
+ {isGeneratingOutline ? (
|
|
|
+ <><span className="w-4 h-4 border-2 border-indigo-600 border-t-transparent rounded-full animate-spin" /> 生成中...</>
|
|
|
+ ) : (
|
|
|
+ <><svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg> AI 生成逐章大纲</>
|
|
|
+ )}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Actions */}
|
|
|
+ <div className="px-6 py-4 border-t border-gray-200 flex items-center justify-end gap-3">
|
|
|
+ <button
|
|
|
+ onClick={() => setShowMarathonSetup(false)}
|
|
|
+ className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
|
|
+ >
|
|
|
+ 取消
|
|
|
+ </button>
|
|
|
+ <button
|
|
|
+ onClick={handleStartMarathon}
|
|
|
+ className="px-5 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
|
|
+ >
|
|
|
+ 开始马拉松创作
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </>
|
|
|
);
|
|
|
}
|