|
@@ -1,636 +1,575 @@
|
|
|
-'use client';
|
|
|
|
|
-
|
|
|
|
|
-import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
-import { NovelType, NovelProject, WritingConstraint, AntiHomogenizationStrategy, WorldStateFile, NovelPhase, WorkflowState } from '@/app/lib/novel-types';
|
|
|
|
|
-import PhaseNavigation from '@/app/features/novel-writer/components/PhaseNavigation';
|
|
|
|
|
-import EventGraphEditor from '@/app/features/novel-writer/components/EventGraphEditor';
|
|
|
|
|
-import WorldEditor from '@/app/features/novel-writer/components/WorldEditor';
|
|
|
|
|
-import { ApiProvider } from '@/app/components/ApiSettings';
|
|
|
|
|
-import ApiSettings from '@/app/components/ApiSettings';
|
|
|
|
|
-import WorldStateSidebar from '@/app/features/novel-writer/components/WorldStateSidebar';
|
|
|
|
|
-import ChapterDraftPipeline from '@/app/features/novel-writer/components/ChapterDraftPipeline';
|
|
|
|
|
-import ChapterHistoryList from '@/app/features/novel-writer/components/ChapterHistoryList';
|
|
|
|
|
-import WritingConstraintManager from '@/app/features/novel-writer/components/WritingConstraintManager';
|
|
|
|
|
-import AntiHomogenizationPanel from '@/app/features/novel-writer/components/AntiHomogenizationPanel';
|
|
|
|
|
-import MaintenancePanel from '@/app/features/novel-writer/components/MaintenancePanel';
|
|
|
|
|
-import { useWorldState } from '@/app/hooks/useWorldState';
|
|
|
|
|
-import { useChapterHistory } from '@/app/hooks/useChapterHistory';
|
|
|
|
|
-
|
|
|
|
|
-// ── Prompt templates ──────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-interface PromptTemplate {
|
|
|
|
|
- id: string;
|
|
|
|
|
- label: string;
|
|
|
|
|
- description: string;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-const PROMPT_TEMPLATES: PromptTemplate[] = [
|
|
|
|
|
- {
|
|
|
|
|
- id: 'start_first_chapter',
|
|
|
|
|
- label: '开始第一章',
|
|
|
|
|
- description: '基于世界设定和背景信息,创作完整的第一章内容,开启故事',
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- id: 'continue_paragraph',
|
|
|
|
|
- label: '续写下一段',
|
|
|
|
|
- description: '在当前内容基础上,续写下一个自然段,保持文风一致',
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- id: 'continue_chapter',
|
|
|
|
|
- label: '续写下一章',
|
|
|
|
|
- description: '根据当前剧情走向,续写完整的下一章内容',
|
|
|
|
|
- },
|
|
|
|
|
- {
|
|
|
|
|
- id: 'expand_outline',
|
|
|
|
|
- label: '根据大纲扩写',
|
|
|
|
|
- description: '将提供的大纲要点扩写为完整的章节内容',
|
|
|
|
|
- },
|
|
|
|
|
-];
|
|
|
|
|
-
|
|
|
|
|
-const TEMPLATE_STORAGE_KEY = 'novel_writer_last_template';
|
|
|
|
|
-
|
|
|
|
|
-// ── Props ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-interface NovelContextManagerProps {
|
|
|
|
|
- projectPath: string;
|
|
|
|
|
- novelType: NovelType;
|
|
|
|
|
- apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
|
|
|
|
|
- onProjectClose: () => void;
|
|
|
|
|
- // API settings panel passthrough
|
|
|
|
|
- showApiSettings: boolean;
|
|
|
|
|
- onToggleApiSettings: () => void;
|
|
|
|
|
- apiProvider: ApiProvider;
|
|
|
|
|
- setApiProvider: (provider: ApiProvider) => void;
|
|
|
|
|
- apiUrl: string;
|
|
|
|
|
- setApiUrl: (url: string) => void;
|
|
|
|
|
- apiKey: string;
|
|
|
|
|
- setApiKey: (key: string) => void;
|
|
|
|
|
- model: string;
|
|
|
|
|
- setModel: (model: string) => void;
|
|
|
|
|
- availableModels: string[];
|
|
|
|
|
- fetchModels: () => Promise<string[] | void>;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
-// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
|
-
|
|
|
|
|
-export default function NovelContextManager({
|
|
|
|
|
- projectPath,
|
|
|
|
|
- novelType,
|
|
|
|
|
- apiSettings,
|
|
|
|
|
- onProjectClose,
|
|
|
|
|
- showApiSettings,
|
|
|
|
|
- onToggleApiSettings,
|
|
|
|
|
- apiProvider,
|
|
|
|
|
- setApiProvider,
|
|
|
|
|
- apiUrl,
|
|
|
|
|
- setApiUrl,
|
|
|
|
|
- apiKey,
|
|
|
|
|
- setApiKey,
|
|
|
|
|
- model,
|
|
|
|
|
- setModel,
|
|
|
|
|
- availableModels,
|
|
|
|
|
- fetchModels,
|
|
|
|
|
-}: NovelContextManagerProps) {
|
|
|
|
|
- // ── Project config ──
|
|
|
|
|
- const [projectConfig, setProjectConfig] = useState<NovelProject | null>(null);
|
|
|
|
|
- const [writingConstraints, setWritingConstraints] = useState<WritingConstraint[]>([]);
|
|
|
|
|
- const [antiHomogenizationStrategies, setAntiHomogenizationStrategies] = useState<AntiHomogenizationStrategy[]>([]);
|
|
|
|
|
- const [chapterWordCountMin, setChapterWordCountMin] = useState(2000);
|
|
|
|
|
- const [chapterWordCountMax, setChapterWordCountMax] = useState(4000);
|
|
|
|
|
- const [recentChaptersCount, setRecentChaptersCount] = useState(3);
|
|
|
|
|
-
|
|
|
|
|
- // ── UI state ──
|
|
|
|
|
- const [activeTab, setActiveTab] = useState<'chapters' | 'constraints' | 'anti'>('chapters');
|
|
|
|
|
- const [showPipeline, setShowPipeline] = useState(false);
|
|
|
|
|
- const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
|
|
|
|
- const [marathonMode, setMarathonMode] = useState(false);
|
|
|
|
|
- const [savedNotification, setSavedNotification] = useState(false);
|
|
|
|
|
- const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
|
|
|
|
|
- if (typeof window !== 'undefined') {
|
|
|
|
|
- return localStorage.getItem(TEMPLATE_STORAGE_KEY) || PROMPT_TEMPLATES[0].id;
|
|
|
|
|
- }
|
|
|
|
|
- return PROMPT_TEMPLATES[0].id;
|
|
|
|
|
- });
|
|
|
|
|
-
|
|
|
|
|
- // ── Phase 1 state ──
|
|
|
|
|
- const [currentPhase, setCurrentPhase] = useState<NovelPhase>('phase2');
|
|
|
|
|
- const [phase1Tab, setPhase1Tab] = useState<'world' | 'events'>('world');
|
|
|
|
|
-
|
|
|
|
|
- // ── Hooks ──
|
|
|
|
|
- const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
|
|
|
|
|
- const { chapters, saveChapter } = useChapterHistory(projectPath);
|
|
|
|
|
-
|
|
|
|
|
- // ── File editor state ──
|
|
|
|
|
- const [editingFile, setEditingFile] = useState<{ file: WorldStateFile; isLore: boolean } | null>(null);
|
|
|
|
|
- const [editContent, setEditContent] = useState('');
|
|
|
|
|
- const [isSaving, setIsSaving] = useState(false);
|
|
|
|
|
- const [saveError, setSaveError] = useState<string | null>(null);
|
|
|
|
|
-
|
|
|
|
|
- // ── Load project config ──
|
|
|
|
|
- const loadProjectConfig = useCallback(async () => {
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await fetch('/api/novel/project', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({ action: 'open', path: projectPath }),
|
|
|
|
|
- });
|
|
|
|
|
- const data = await response.json();
|
|
|
|
|
- if (data.success && data.project) {
|
|
|
|
|
- const project = data.project as NovelProject;
|
|
|
|
|
- setProjectConfig(project);
|
|
|
|
|
- if (Array.isArray(project.writingConstraints)) {
|
|
|
|
|
- setWritingConstraints(project.writingConstraints);
|
|
|
|
|
- }
|
|
|
|
|
- if (Array.isArray(project.antiHomogenizationStrategies)) {
|
|
|
|
|
- setAntiHomogenizationStrategies(project.antiHomogenizationStrategies);
|
|
|
|
|
- }
|
|
|
|
|
- if (project.chapterWordCountMin) setChapterWordCountMin(project.chapterWordCountMin);
|
|
|
|
|
- if (project.chapterWordCountMax) setChapterWordCountMax(project.chapterWordCountMax);
|
|
|
|
|
- if (project.recentChaptersCount) setRecentChaptersCount(project.recentChaptersCount);
|
|
|
|
|
- }
|
|
|
|
|
- } catch {
|
|
|
|
|
- // silently ignore — use defaults
|
|
|
|
|
- }
|
|
|
|
|
- }, [projectPath]);
|
|
|
|
|
-
|
|
|
|
|
- useEffect(() => {
|
|
|
|
|
- loadProjectConfig();
|
|
|
|
|
- }, [loadProjectConfig]);
|
|
|
|
|
-
|
|
|
|
|
- // ── Save config changes back to project ──
|
|
|
|
|
- const saveProjectConfig = useCallback(
|
|
|
|
|
- async (updates: Partial<NovelProject>) => {
|
|
|
|
|
- try {
|
|
|
|
|
- await fetch('/api/novel/project', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({
|
|
|
|
|
- action: 'save_config',
|
|
|
|
|
- path: projectPath,
|
|
|
|
|
- config: updates,
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
- } catch {
|
|
|
|
|
- // silently ignore
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- [projectPath]
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // ── Template selection ──
|
|
|
|
|
- const handleTemplateSelect = (templateId: string) => {
|
|
|
|
|
- setSelectedTemplateId(templateId);
|
|
|
|
|
- if (typeof window !== 'undefined') {
|
|
|
|
|
- localStorage.setItem(TEMPLATE_STORAGE_KEY, templateId);
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // ── Writing constraints change ──
|
|
|
|
|
- const handleConstraintsChange = useCallback(
|
|
|
|
|
- (constraints: WritingConstraint[]) => {
|
|
|
|
|
- setWritingConstraints(constraints);
|
|
|
|
|
- saveProjectConfig({ writingConstraints: constraints });
|
|
|
|
|
- },
|
|
|
|
|
- [saveProjectConfig]
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // ── Anti-homogenization strategies change ──
|
|
|
|
|
- const handleStrategiesChange = useCallback(
|
|
|
|
|
- (strategies: AntiHomogenizationStrategy[]) => {
|
|
|
|
|
- setAntiHomogenizationStrategies(strategies);
|
|
|
|
|
- saveProjectConfig({ antiHomogenizationStrategies: strategies });
|
|
|
|
|
- },
|
|
|
|
|
- [saveProjectConfig]
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- // ── Chapter pipeline ──
|
|
|
|
|
- const handleStartNewChapter = () => {
|
|
|
|
|
- setShowPipeline(true);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const handlePipelineComplete = useCallback(
|
|
|
|
|
- async (chapterTitle: string, content: string) => {
|
|
|
|
|
- setShowPipeline(false);
|
|
|
|
|
- try {
|
|
|
|
|
- await saveChapter(content, chapterTitle);
|
|
|
|
|
- setSavedNotification(true);
|
|
|
|
|
- setTimeout(() => setSavedNotification(false), 3000);
|
|
|
|
|
-
|
|
|
|
|
- // Background agents: RollingSummarizer every 5 chapters, MilestoneAudit every 20
|
|
|
|
|
- const newChapterIndex = chapters.length + 1;
|
|
|
|
|
- if (apiSettings) {
|
|
|
|
|
- if (newChapterIndex % 5 === 0) {
|
|
|
|
|
- const startChapter = newChapterIndex - 4;
|
|
|
|
|
- fetch('/api/novel/summarize', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({ projectPath, startChapter, endChapter: newChapterIndex, apiSettings }),
|
|
|
|
|
- }).catch(() => {});
|
|
|
|
|
- }
|
|
|
|
|
- if (newChapterIndex > 0 && newChapterIndex % 20 === 0) {
|
|
|
|
|
- fetch('/api/novel/audit', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({ projectPath, chapterIndex: newChapterIndex, apiSettings }),
|
|
|
|
|
- }).catch(() => {});
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // Marathon mode: auto-start next chapter after short delay
|
|
|
|
|
- if (marathonMode) {
|
|
|
|
|
- setTimeout(() => setShowPipeline(true), 1000);
|
|
|
|
|
- }
|
|
|
|
|
- } catch {
|
|
|
|
|
- // silently ignore — chapter was still written
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- [saveChapter, marathonMode, chapters.length, apiSettings, projectPath]
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- const handlePipelineCancel = () => {
|
|
|
|
|
- setShowPipeline(false);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // ── File editor handlers ──
|
|
|
|
|
-// 文件编辑处理函数
|
|
|
|
|
- const handleFileEdit = (file: WorldStateFile, isLore: boolean) => {
|
|
|
|
|
- setEditingFile({ file, isLore });
|
|
|
|
|
- setEditContent(file.content);
|
|
|
|
|
- setSaveError(null);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // 文风设定编辑处理函数
|
|
|
|
|
- const handleWritingStyleClick = async () => {
|
|
|
|
|
- try {
|
|
|
|
|
- const response = await fetch('/api/novel/files', {
|
|
|
|
|
- method: 'POST',
|
|
|
|
|
- headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
- body: JSON.stringify({
|
|
|
|
|
- action: 'read',
|
|
|
|
|
- projectPath,
|
|
|
|
|
- filePath: 'world_state/writing_style.md',
|
|
|
|
|
- }),
|
|
|
|
|
- });
|
|
|
|
|
- const data = await response.json();
|
|
|
|
|
-
|
|
|
|
|
- if (data.success) {
|
|
|
|
|
- const writingStyleFile: WorldStateFile = {
|
|
|
|
|
- fileName: 'writing_style.md',
|
|
|
|
|
- displayName: '文风设定',
|
|
|
|
|
- content: data.content as string,
|
|
|
|
|
- lastUpdatedAt: data.lastUpdatedAt as string || new Date().toISOString(),
|
|
|
|
|
- enabled: true,
|
|
|
|
|
- isLore: false,
|
|
|
|
|
- };
|
|
|
|
|
- setEditingFile({ file: writingStyleFile, isLore: false });
|
|
|
|
|
- setEditContent(data.content as string);
|
|
|
|
|
- setSaveError(null);
|
|
|
|
|
- }
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- console.error('加载文风设定失败:', err);
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const handleSaveFile = async () => {
|
|
|
|
|
- if (!editingFile) return;
|
|
|
|
|
-
|
|
|
|
|
- setIsSaving(true);
|
|
|
|
|
- setSaveError(null);
|
|
|
|
|
- try {
|
|
|
|
|
- await updateFile(editingFile.file.fileName, editContent, editingFile.isLore);
|
|
|
|
|
- setEditingFile(null);
|
|
|
|
|
- } catch (err) {
|
|
|
|
|
- setSaveError(err instanceof Error ? err.message : '保存失败');
|
|
|
|
|
- } finally {
|
|
|
|
|
- setIsSaving(false);
|
|
|
|
|
- }
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- const handleCancelEdit = () => {
|
|
|
|
|
- setEditingFile(null);
|
|
|
|
|
- setSaveError(null);
|
|
|
|
|
- };
|
|
|
|
|
-
|
|
|
|
|
- // ── Project name ──
|
|
|
|
|
- const projectName = projectConfig?.name || projectPath.replace(/\\/g, '/').split('/').pop() || '未命名项目';
|
|
|
|
|
-
|
|
|
|
|
- // ── Render pipeline (full-screen overlay) ──
|
|
|
|
|
- if (showPipeline) {
|
|
|
|
|
- return (
|
|
|
|
|
- <ChapterDraftPipeline
|
|
|
|
|
- projectPath={projectPath}
|
|
|
|
|
- chapterIndex={chapters.length + 1}
|
|
|
|
|
- apiSettings={apiSettings}
|
|
|
|
|
- worldStateFiles={worldStateFiles}
|
|
|
|
|
- loreFiles={loreFiles}
|
|
|
|
|
- writingConstraints={writingConstraints}
|
|
|
|
|
- antiHomogenizationStrategies={antiHomogenizationStrategies}
|
|
|
|
|
- wordCountMin={chapterWordCountMin}
|
|
|
|
|
- wordCountMax={chapterWordCountMax}
|
|
|
|
|
- recentChaptersCount={recentChaptersCount}
|
|
|
|
|
- onComplete={handlePipelineComplete}
|
|
|
|
|
- onCancel={handlePipelineCancel}
|
|
|
|
|
- />
|
|
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- // ── Main layout ──
|
|
|
|
|
- return (
|
|
|
|
|
- <div className="flex h-screen bg-gray-50 overflow-hidden">
|
|
|
|
|
- {/* Left sidebar: World State */}
|
|
|
|
|
- <WorldStateSidebar
|
|
|
|
|
- projectPath={projectPath}
|
|
|
|
|
- latestChapterIndex={chapters.length}
|
|
|
|
|
- onFileEdit={handleFileEdit}
|
|
|
|
|
- onWritingStyleClick={handleWritingStyleClick}
|
|
|
|
|
- />
|
|
|
|
|
-
|
|
|
|
|
- {/* Right main area */}
|
|
|
|
|
- <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
|
|
|
- {/* Top toolbar */}
|
|
|
|
|
- <header className="flex items-center justify-between px-5 py-3 bg-white border-b border-gray-200 shrink-0">
|
|
|
|
|
- <div className="flex items-center gap-3 min-w-0">
|
|
|
|
|
- <div className="min-w-0">
|
|
|
|
|
- <h1 className="text-base font-semibold text-gray-800 truncate">{projectName}</h1>
|
|
|
|
|
- <p className="text-xs text-gray-400 truncate">{projectPath}</p>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- <div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
- {/* API settings toggle */}
|
|
|
|
|
- <div className="relative">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={onToggleApiSettings}
|
|
|
|
|
- className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
|
|
|
- showApiSettings
|
|
|
|
|
- ? 'bg-blue-50 border-blue-300 text-blue-700'
|
|
|
|
|
- : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
|
|
|
|
- }`}
|
|
|
|
|
- aria-label="API 设置"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path
|
|
|
|
|
- strokeLinecap="round"
|
|
|
|
|
- strokeLinejoin="round"
|
|
|
|
|
- strokeWidth={2}
|
|
|
|
|
- d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
|
|
|
- />
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- API 设置
|
|
|
|
|
- </button>
|
|
|
|
|
-
|
|
|
|
|
- {showApiSettings && (
|
|
|
|
|
- <div className="absolute top-10 right-0 w-80 bg-white border border-gray-200 rounded-xl shadow-xl p-4 z-50">
|
|
|
|
|
- <div className="flex items-center justify-between mb-3">
|
|
|
|
|
- <h3 className="text-sm font-semibold text-gray-700">API 设置</h3>
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={onToggleApiSettings}
|
|
|
|
|
- className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
|
|
|
- aria-label="关闭"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- <ApiSettings
|
|
|
|
|
- showSettings={true}
|
|
|
|
|
- toggleSettings={onToggleApiSettings}
|
|
|
|
|
- apiProvider={apiProvider}
|
|
|
|
|
- setApiProvider={setApiProvider}
|
|
|
|
|
- apiUrl={apiUrl}
|
|
|
|
|
- setApiUrl={setApiUrl}
|
|
|
|
|
- apiKey={apiKey}
|
|
|
|
|
- setApiKey={setApiKey}
|
|
|
|
|
- model={model}
|
|
|
|
|
- setModel={setModel}
|
|
|
|
|
- availableModels={availableModels}
|
|
|
|
|
- fetchModels={fetchModels}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Close project */}
|
|
|
|
|
- {!showCloseConfirm ? (
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={() => setShowCloseConfirm(true)}
|
|
|
|
|
- className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-red-50 hover:border-red-300 hover:text-red-600 transition-colors"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 关闭项目
|
|
|
|
|
- </button>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="flex items-center gap-2">
|
|
|
|
|
- <span className="text-xs text-red-600">确定关闭?</span>
|
|
|
|
|
- <button onClick={onProjectClose} className="px-2 py-1 text-xs font-medium text-white bg-red-500 rounded hover:bg-red-600">确认</button>
|
|
|
|
|
- <button onClick={() => setShowCloseConfirm(false)} className="px-2 py-1 text-xs text-gray-500 border border-gray-300 rounded hover:bg-gray-50">取消</button>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </header>
|
|
|
|
|
-
|
|
|
|
|
- <PhaseNavigation currentPhase={currentPhase} onPhaseChange={setCurrentPhase} />
|
|
|
|
|
-
|
|
|
|
|
- {currentPhase === 'phase1' && (
|
|
|
|
|
- <div className="flex-1 flex flex-col overflow-hidden">
|
|
|
|
|
- <div className="flex items-center gap-0 border-b border-gray-200 bg-white px-4 shrink-0">
|
|
|
|
|
- {[
|
|
|
|
|
- { id: 'world' as const, label: '世界观编辑' },
|
|
|
|
|
- { id: 'events' as const, label: '事件图' },
|
|
|
|
|
- ].map(tab => (
|
|
|
|
|
- <button key={tab.id} onClick={() => setPhase1Tab(tab.id)}
|
|
|
|
|
- className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
|
|
|
|
- phase1Tab === tab.id ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
|
|
|
- }`}>
|
|
|
|
|
- {tab.label}
|
|
|
|
|
- </button>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- {phase1Tab === 'world' ? (
|
|
|
|
|
- <WorldEditor projectPath={projectPath} apiSettings={apiSettings} />
|
|
|
|
|
- ) : (
|
|
|
|
|
- <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {currentPhase === 'phase3' && (
|
|
|
|
|
- <MaintenancePanel projectPath={projectPath} apiSettings={apiSettings} />
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {currentPhase !== 'phase1' && currentPhase !== 'phase3' && (
|
|
|
|
|
- <>
|
|
|
|
|
- {editingFile ? (
|
|
|
|
|
- <div className="flex-1 overflow-auto p-5">
|
|
|
|
|
- <div className="max-w-4xl mx-auto space-y-4">
|
|
|
|
|
- <div className="flex items-center justify-between">
|
|
|
|
|
- <h2 className="text-lg font-semibold text-gray-800">
|
|
|
|
|
- 编辑文件: {editingFile.file.displayName || editingFile.file.fileName}
|
|
|
|
|
- </h2>
|
|
|
|
|
- <div className="flex gap-2">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={handleCancelEdit}
|
|
|
|
|
- className="px-4 py-2 text-sm bg-gray-100 text-gray-600 rounded hover:bg-gray-200 transition-colors"
|
|
|
|
|
- >
|
|
|
|
|
- 取消
|
|
|
|
|
- </button>
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={handleSaveFile}
|
|
|
|
|
- disabled={isSaving}
|
|
|
|
|
- className="px-4 py-2 text-sm bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors disabled:opacity-50"
|
|
|
|
|
- >
|
|
|
|
|
- {isSaving ? '保存中...' : '保存'}
|
|
|
|
|
- </button>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- {saveError && (
|
|
|
|
|
- <div className="p-3 bg-red-50 border border-red-200 rounded text-sm text-red-600">
|
|
|
|
|
- {saveError}
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- <div className="border border-gray-200 rounded-lg overflow-hidden">
|
|
|
|
|
- <textarea
|
|
|
|
|
- value={editContent}
|
|
|
|
|
- onChange={(e) => setEditContent(e.target.value)}
|
|
|
|
|
- className="w-full h-[60vh] p-4 text-sm font-mono border-0 focus:outline-none resize-none"
|
|
|
|
|
- placeholder="文件内容..."
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- <div className="text-xs text-gray-400">
|
|
|
|
|
- 最后更新: {new Date(editingFile.file.lastUpdatedAt).toLocaleString()}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- ) : (
|
|
|
|
|
- <div className="flex-1 flex flex-col">
|
|
|
|
|
- {/* Saved notification */}
|
|
|
|
|
- {savedNotification && (
|
|
|
|
|
- <div className="mx-5 mt-3 px-4 py-2 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2 shrink-0">
|
|
|
|
|
- <svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 已自动保存
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {/* Prompt template selector */}
|
|
|
|
|
- <div className="px-5 py-3 bg-white border-b border-gray-100 shrink-0">
|
|
|
|
|
- <div className="flex items-center gap-2">
|
|
|
|
|
- <span className="text-xs text-gray-500 shrink-0">AI 提示词模板:</span>
|
|
|
|
|
- <div className="flex gap-2 flex-wrap">
|
|
|
|
|
- {PROMPT_TEMPLATES.map((template) => (
|
|
|
|
|
- <button
|
|
|
|
|
- key={template.id}
|
|
|
|
|
- onClick={() => handleTemplateSelect(template.id)}
|
|
|
|
|
- title={template.description}
|
|
|
|
|
- className={`px-3 py-1 text-xs rounded-full border transition-all ${
|
|
|
|
|
- selectedTemplateId === template.id
|
|
|
|
|
- ? 'bg-indigo-600 border-indigo-600 text-white'
|
|
|
|
|
- : 'bg-white border-gray-200 text-gray-600 hover:border-indigo-300 hover:text-indigo-600'
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- {template.label}
|
|
|
|
|
- </button>
|
|
|
|
|
- ))}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Start new chapter + Marathon toggle */}
|
|
|
|
|
- <div className="px-5 py-4 shrink-0 space-y-3">
|
|
|
|
|
- <button
|
|
|
|
|
- onClick={handleStartNewChapter}
|
|
|
|
|
- className="flex items-center gap-2 w-full px-5 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-xl hover:bg-indigo-700 transition-colors shadow-sm"
|
|
|
|
|
- >
|
|
|
|
|
- <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
- <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
- </svg>
|
|
|
|
|
- 开始新章节(第 {chapters.length + 1} 章)
|
|
|
|
|
- </button>
|
|
|
|
|
- <label className="flex items-center gap-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
|
|
|
|
- <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">
|
|
|
|
|
- 马拉松模式
|
|
|
|
|
- {marathonMode && <span className="text-indigo-600 font-medium ml-1">— 自动续写</span>}
|
|
|
|
|
- </span>
|
|
|
|
|
- </label>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Tab navigation */}
|
|
|
|
|
- <div className="px-5 border-b border-gray-200 bg-white shrink-0">
|
|
|
|
|
- <nav className="flex gap-0" role="tablist">
|
|
|
|
|
- {[
|
|
|
|
|
- { id: 'chapters' as const, label: '章节历史' },
|
|
|
|
|
- { id: 'constraints' as const, label: '写作约束' },
|
|
|
|
|
- { id: 'anti' as const, label: '反同质化' },
|
|
|
|
|
- ].map((tab) => (
|
|
|
|
|
- <button
|
|
|
|
|
- key={tab.id}
|
|
|
|
|
- role="tab"
|
|
|
|
|
- aria-selected={activeTab === tab.id}
|
|
|
|
|
- onClick={() => setActiveTab(tab.id)}
|
|
|
|
|
- className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
|
|
|
|
- activeTab === tab.id
|
|
|
|
|
- ? 'border-indigo-600 text-indigo-600'
|
|
|
|
|
- : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
|
|
|
- }`}
|
|
|
|
|
- >
|
|
|
|
|
- {tab.label}
|
|
|
|
|
- </button>
|
|
|
|
|
- ))}
|
|
|
|
|
- </nav>
|
|
|
|
|
- </div>
|
|
|
|
|
-
|
|
|
|
|
- {/* Tab content */}
|
|
|
|
|
- <div className="flex-1 overflow-hidden">
|
|
|
|
|
- {activeTab === 'chapters' && (
|
|
|
|
|
- <ChapterHistoryList projectPath={projectPath} />
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {activeTab === 'constraints' && (
|
|
|
|
|
- <div className="h-full overflow-y-auto p-5">
|
|
|
|
|
- <div className="max-w-2xl">
|
|
|
|
|
- <h2 className="text-sm font-semibold text-gray-700 mb-1">写作约束</h2>
|
|
|
|
|
- <p className="text-xs text-gray-400 mb-4">
|
|
|
|
|
- 设置每章必须满足的写作要求,这些约束将注入 AI 提示词中。
|
|
|
|
|
- </p>
|
|
|
|
|
- <WritingConstraintManager
|
|
|
|
|
- constraints={writingConstraints}
|
|
|
|
|
- onChange={handleConstraintsChange}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
-
|
|
|
|
|
- {activeTab === 'anti' && (
|
|
|
|
|
- <div className="h-full overflow-y-auto p-5">
|
|
|
|
|
- <div className="max-w-2xl">
|
|
|
|
|
- <h2 className="text-sm font-semibold text-gray-700 mb-1">反同质化策略</h2>
|
|
|
|
|
- <p className="text-xs text-gray-400 mb-4">
|
|
|
|
|
- 选择用于避免内容同质化的写作策略,这些策略将注入 AI 提示词中。
|
|
|
|
|
- </p>
|
|
|
|
|
- <AntiHomogenizationPanel
|
|
|
|
|
- strategies={antiHomogenizationStrategies}
|
|
|
|
|
- onChange={handleStrategiesChange}
|
|
|
|
|
- />
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- )}
|
|
|
|
|
- </>
|
|
|
|
|
- )}
|
|
|
|
|
- </div>
|
|
|
|
|
- </div>
|
|
|
|
|
- );
|
|
|
|
|
-}
|
|
|
|
|
|
|
+'use client';
|
|
|
|
|
+
|
|
|
|
|
+import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
+import { NovelType, NovelProject, WritingConstraint, AntiHomogenizationStrategy, WorldStateFile, NovelPhase, WorkflowState } from '@/app/lib/novel-types';
|
|
|
|
|
+import PhaseNavigation from '@/app/features/novel-writer/components/PhaseNavigation';
|
|
|
|
|
+import EventGraphEditor from '@/app/features/novel-writer/components/EventGraphEditor';
|
|
|
|
|
+import WorldEditor from '@/app/features/novel-writer/components/WorldEditor';
|
|
|
|
|
+import { ApiProvider } from '@/app/components/ApiSettings';
|
|
|
|
|
+import ApiSettings from '@/app/components/ApiSettings';
|
|
|
|
|
+import WorldStateSidebar from '@/app/features/novel-writer/components/WorldStateSidebar';
|
|
|
|
|
+import ChapterDraftPipeline from '@/app/features/novel-writer/components/ChapterDraftPipeline';
|
|
|
|
|
+import ChapterHistoryList from '@/app/features/novel-writer/components/ChapterHistoryList';
|
|
|
|
|
+import WritingConstraintManager from '@/app/features/novel-writer/components/WritingConstraintManager';
|
|
|
|
|
+import AntiHomogenizationPanel from '@/app/features/novel-writer/components/AntiHomogenizationPanel';
|
|
|
|
|
+import MaintenancePanel from '@/app/features/novel-writer/components/MaintenancePanel';
|
|
|
|
|
+import { useWorldState } from '@/app/hooks/useWorldState';
|
|
|
|
|
+import { useChapterHistory } from '@/app/hooks/useChapterHistory';
|
|
|
|
|
+
|
|
|
|
|
+// ── Prompt templates ──────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface PromptTemplate {
|
|
|
|
|
+ id: string;
|
|
|
|
|
+ label: string;
|
|
|
|
|
+ description: string;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+const PROMPT_TEMPLATES: PromptTemplate[] = [
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'start_first_chapter',
|
|
|
|
|
+ label: '开始第一章',
|
|
|
|
|
+ description: '基于世界设定和背景信息,创作完整的第一章内容,开启故事',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'continue_paragraph',
|
|
|
|
|
+ label: '续写下一段',
|
|
|
|
|
+ description: '在当前内容基础上,续写下一个自然段,保持文风一致',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'continue_chapter',
|
|
|
|
|
+ label: '续写下一章',
|
|
|
|
|
+ description: '根据当前剧情走向,续写完整的下一章内容',
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ id: 'expand_outline',
|
|
|
|
|
+ label: '根据大纲扩写',
|
|
|
|
|
+ description: '将提供的大纲要点扩写为完整的章节内容',
|
|
|
|
|
+ },
|
|
|
|
|
+];
|
|
|
|
|
+
|
|
|
|
|
+const TEMPLATE_STORAGE_KEY = 'novel_writer_last_template';
|
|
|
|
|
+
|
|
|
|
|
+// ── Props ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+interface NovelContextManagerProps {
|
|
|
|
|
+ projectPath: string;
|
|
|
|
|
+ novelType: NovelType;
|
|
|
|
|
+ apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
|
|
|
|
|
+ onProjectClose: () => void;
|
|
|
|
|
+ // API settings panel passthrough
|
|
|
|
|
+ showApiSettings: boolean;
|
|
|
|
|
+ onToggleApiSettings: () => void;
|
|
|
|
|
+ apiProvider: ApiProvider;
|
|
|
|
|
+ setApiProvider: (provider: ApiProvider) => void;
|
|
|
|
|
+ apiUrl: string;
|
|
|
|
|
+ setApiUrl: (url: string) => void;
|
|
|
|
|
+ apiKey: string;
|
|
|
|
|
+ setApiKey: (key: string) => void;
|
|
|
|
|
+ model: string;
|
|
|
|
|
+ setModel: (model: string) => void;
|
|
|
|
|
+ availableModels: string[];
|
|
|
|
|
+ fetchModels: () => Promise<string[] | void>;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// ── Main component ────────────────────────────────────────────────────────────
|
|
|
|
|
+
|
|
|
|
|
+export default function NovelContextManager({
|
|
|
|
|
+ projectPath,
|
|
|
|
|
+ novelType,
|
|
|
|
|
+ apiSettings,
|
|
|
|
|
+ onProjectClose,
|
|
|
|
|
+ showApiSettings,
|
|
|
|
|
+ onToggleApiSettings,
|
|
|
|
|
+ apiProvider,
|
|
|
|
|
+ setApiProvider,
|
|
|
|
|
+ apiUrl,
|
|
|
|
|
+ setApiUrl,
|
|
|
|
|
+ apiKey,
|
|
|
|
|
+ setApiKey,
|
|
|
|
|
+ model,
|
|
|
|
|
+ setModel,
|
|
|
|
|
+ availableModels,
|
|
|
|
|
+ fetchModels,
|
|
|
|
|
+}: NovelContextManagerProps) {
|
|
|
|
|
+ // ── Project config ──
|
|
|
|
|
+ const [projectConfig, setProjectConfig] = useState<NovelProject | null>(null);
|
|
|
|
|
+ const [writingConstraints, setWritingConstraints] = useState<WritingConstraint[]>([]);
|
|
|
|
|
+ const [antiHomogenizationStrategies, setAntiHomogenizationStrategies] = useState<AntiHomogenizationStrategy[]>([]);
|
|
|
|
|
+ const [chapterWordCountMin, setChapterWordCountMin] = useState(2000);
|
|
|
|
|
+ const [chapterWordCountMax, setChapterWordCountMax] = useState(4000);
|
|
|
|
|
+ const [recentChaptersCount, setRecentChaptersCount] = useState(3);
|
|
|
|
|
+
|
|
|
|
|
+ // ── UI state ──
|
|
|
|
|
+ const [activeTab, setActiveTab] = useState<'chapters' | 'constraints' | 'anti'>('chapters');
|
|
|
|
|
+ const [showPipeline, setShowPipeline] = useState(false);
|
|
|
|
|
+ const [showCloseConfirm, setShowCloseConfirm] = useState(false);
|
|
|
|
|
+ const [marathonMode, setMarathonMode] = useState(false);
|
|
|
|
|
+ const [savedNotification, setSavedNotification] = useState(false);
|
|
|
|
|
+ const [selectedTemplateId, setSelectedTemplateId] = useState<string>(() => {
|
|
|
|
|
+ if (typeof window !== 'undefined') {
|
|
|
|
|
+ return localStorage.getItem(TEMPLATE_STORAGE_KEY) || PROMPT_TEMPLATES[0].id;
|
|
|
|
|
+ }
|
|
|
|
|
+ return PROMPT_TEMPLATES[0].id;
|
|
|
|
|
+ });
|
|
|
|
|
+
|
|
|
|
|
+ // ── Phase 1 state ──
|
|
|
|
|
+ const [currentPhase, setCurrentPhase] = useState<NovelPhase>('phase2');
|
|
|
|
|
+ const [phase1Tab, setPhase1Tab] = useState<'world' | 'events'>('world');
|
|
|
|
|
+
|
|
|
|
|
+ // ── Hooks ──
|
|
|
|
|
+ const { worldStateFiles, loreFiles } = useWorldState(projectPath);
|
|
|
|
|
+ const { chapters, saveChapter } = useChapterHistory(projectPath);
|
|
|
|
|
+
|
|
|
|
|
+ // ── File editor state ──
|
|
|
|
|
+ const [editingFile, setEditingFile] = useState<{ file: WorldStateFile; isLore: boolean } | null>(null);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Load project config ──
|
|
|
|
|
+ const loadProjectConfig = useCallback(async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch('/api/novel/project', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ action: 'open', path: projectPath }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+ if (data.success && data.project) {
|
|
|
|
|
+ const project = data.project as NovelProject;
|
|
|
|
|
+ setProjectConfig(project);
|
|
|
|
|
+ if (Array.isArray(project.writingConstraints)) {
|
|
|
|
|
+ setWritingConstraints(project.writingConstraints);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (Array.isArray(project.antiHomogenizationStrategies)) {
|
|
|
|
|
+ setAntiHomogenizationStrategies(project.antiHomogenizationStrategies);
|
|
|
|
|
+ }
|
|
|
|
|
+ if (project.chapterWordCountMin) setChapterWordCountMin(project.chapterWordCountMin);
|
|
|
|
|
+ if (project.chapterWordCountMax) setChapterWordCountMax(project.chapterWordCountMax);
|
|
|
|
|
+ if (project.recentChaptersCount) setRecentChaptersCount(project.recentChaptersCount);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // silently ignore — use defaults
|
|
|
|
|
+ }
|
|
|
|
|
+ }, [projectPath]);
|
|
|
|
|
+
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ loadProjectConfig();
|
|
|
|
|
+ }, [loadProjectConfig]);
|
|
|
|
|
+
|
|
|
|
|
+ // ── Save config changes back to project ──
|
|
|
|
|
+ const saveProjectConfig = useCallback(
|
|
|
|
|
+ async (updates: Partial<NovelProject>) => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ await fetch('/api/novel/project', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ action: 'save_config',
|
|
|
|
|
+ path: projectPath,
|
|
|
|
|
+ config: updates,
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // silently ignore
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ [projectPath]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // ── Template selection ──
|
|
|
|
|
+ const handleTemplateSelect = (templateId: string) => {
|
|
|
|
|
+ setSelectedTemplateId(templateId);
|
|
|
|
|
+ if (typeof window !== 'undefined') {
|
|
|
|
|
+ localStorage.setItem(TEMPLATE_STORAGE_KEY, templateId);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ── Writing constraints change ──
|
|
|
|
|
+ const handleConstraintsChange = useCallback(
|
|
|
|
|
+ (constraints: WritingConstraint[]) => {
|
|
|
|
|
+ setWritingConstraints(constraints);
|
|
|
|
|
+ saveProjectConfig({ writingConstraints: constraints });
|
|
|
|
|
+ },
|
|
|
|
|
+ [saveProjectConfig]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // ── Anti-homogenization strategies change ──
|
|
|
|
|
+ const handleStrategiesChange = useCallback(
|
|
|
|
|
+ (strategies: AntiHomogenizationStrategy[]) => {
|
|
|
|
|
+ setAntiHomogenizationStrategies(strategies);
|
|
|
|
|
+ saveProjectConfig({ antiHomogenizationStrategies: strategies });
|
|
|
|
|
+ },
|
|
|
|
|
+ [saveProjectConfig]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ // ── Chapter pipeline ──
|
|
|
|
|
+ const handleStartNewChapter = () => {
|
|
|
|
|
+ setShowPipeline(true);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ const handlePipelineComplete = useCallback(
|
|
|
|
|
+ async (chapterTitle: string, content: string) => {
|
|
|
|
|
+ setShowPipeline(false);
|
|
|
|
|
+ try {
|
|
|
|
|
+ await saveChapter(content, chapterTitle);
|
|
|
|
|
+ setSavedNotification(true);
|
|
|
|
|
+ setTimeout(() => setSavedNotification(false), 3000);
|
|
|
|
|
+
|
|
|
|
|
+ // Background agents: RollingSummarizer every 5 chapters, MilestoneAudit every 20
|
|
|
|
|
+ const newChapterIndex = chapters.length + 1;
|
|
|
|
|
+ if (apiSettings) {
|
|
|
|
|
+ if (newChapterIndex % 5 === 0) {
|
|
|
|
|
+ const startChapter = newChapterIndex - 4;
|
|
|
|
|
+ fetch('/api/novel/summarize', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, startChapter, endChapter: newChapterIndex, apiSettings }),
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+ if (newChapterIndex > 0 && newChapterIndex % 20 === 0) {
|
|
|
|
|
+ fetch('/api/novel/audit', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ projectPath, chapterIndex: newChapterIndex, apiSettings }),
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // Marathon mode: auto-start next chapter after short delay
|
|
|
|
|
+ if (marathonMode) {
|
|
|
|
|
+ setTimeout(() => setShowPipeline(true), 1000);
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch {
|
|
|
|
|
+ // silently ignore — chapter was still written
|
|
|
|
|
+ }
|
|
|
|
|
+ },
|
|
|
|
|
+ [saveChapter, marathonMode, chapters.length, apiSettings, projectPath]
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ const handlePipelineCancel = () => {
|
|
|
|
|
+ setShowPipeline(false);
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // ── File editor handlers ──
|
|
|
|
|
+// 文件编辑处理函数
|
|
|
|
|
+ const handleFileEdit = (file: WorldStateFile, isLore: boolean) => {
|
|
|
|
|
+ setEditingFile({ file, isLore });
|
|
|
|
|
+ setCurrentPhase('phase1');
|
|
|
|
|
+ setPhase1Tab('world');
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+ // 文风设定编辑处理函数
|
|
|
|
|
+ const handleWritingStyleClick = async () => {
|
|
|
|
|
+ try {
|
|
|
|
|
+ const response = await fetch('/api/novel/files', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({
|
|
|
|
|
+ action: 'read',
|
|
|
|
|
+ projectPath,
|
|
|
|
|
+ filePath: 'world_state/writing_style.md',
|
|
|
|
|
+ }),
|
|
|
|
|
+ });
|
|
|
|
|
+ const data = await response.json();
|
|
|
|
|
+
|
|
|
|
|
+ if (data.success) {
|
|
|
|
|
+ const writingStyleFile: WorldStateFile = {
|
|
|
|
|
+ fileName: 'writing_style.md',
|
|
|
|
|
+ displayName: '文风设定',
|
|
|
|
|
+ content: data.content as string,
|
|
|
|
|
+ lastUpdatedAt: data.lastUpdatedAt as string || new Date().toISOString(),
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ isLore: false,
|
|
|
|
|
+ };
|
|
|
|
|
+ setEditingFile({ file: writingStyleFile, isLore: false });
|
|
|
|
|
+ setCurrentPhase('phase1');
|
|
|
|
|
+ setPhase1Tab('world');
|
|
|
|
|
+ }
|
|
|
|
|
+ } catch (err) {
|
|
|
|
|
+ console.error('加载文风设定失败:', err);
|
|
|
|
|
+ }
|
|
|
|
|
+ };
|
|
|
|
|
+
|
|
|
|
|
+
|
|
|
|
|
+ // ── Project name ──
|
|
|
|
|
+ const projectName = projectConfig?.name || projectPath.replace(/\\/g, '/').split('/').pop() || '未命名项目';
|
|
|
|
|
+
|
|
|
|
|
+ // ── Render pipeline (full-screen overlay) ──
|
|
|
|
|
+ if (showPipeline) {
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ChapterDraftPipeline
|
|
|
|
|
+ projectPath={projectPath}
|
|
|
|
|
+ chapterIndex={chapters.length + 1}
|
|
|
|
|
+ apiSettings={apiSettings}
|
|
|
|
|
+ worldStateFiles={worldStateFiles}
|
|
|
|
|
+ loreFiles={loreFiles}
|
|
|
|
|
+ writingConstraints={writingConstraints}
|
|
|
|
|
+ antiHomogenizationStrategies={antiHomogenizationStrategies}
|
|
|
|
|
+ wordCountMin={chapterWordCountMin}
|
|
|
|
|
+ wordCountMax={chapterWordCountMax}
|
|
|
|
|
+ recentChaptersCount={recentChaptersCount}
|
|
|
|
|
+ onComplete={handlePipelineComplete}
|
|
|
|
|
+ onCancel={handlePipelineCancel}
|
|
|
|
|
+ />
|
|
|
|
|
+ );
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ── Main layout ──
|
|
|
|
|
+ return (
|
|
|
|
|
+ <div className="flex h-screen bg-gray-50 overflow-hidden">
|
|
|
|
|
+ {/* Left sidebar: World State */}
|
|
|
|
|
+ <WorldStateSidebar
|
|
|
|
|
+ projectPath={projectPath}
|
|
|
|
|
+ latestChapterIndex={chapters.length}
|
|
|
|
|
+ onFileEdit={handleFileEdit}
|
|
|
|
|
+ onWritingStyleClick={handleWritingStyleClick}
|
|
|
|
|
+ />
|
|
|
|
|
+
|
|
|
|
|
+ {/* Right main area */}
|
|
|
|
|
+ <div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
|
|
|
|
+ {/* Top toolbar */}
|
|
|
|
|
+ <header className="flex items-center justify-between px-5 py-3 bg-white border-b border-gray-200 shrink-0">
|
|
|
|
|
+ <div className="flex items-center gap-3 min-w-0">
|
|
|
|
|
+ <div className="min-w-0">
|
|
|
|
|
+ <h1 className="text-base font-semibold text-gray-800 truncate">{projectName}</h1>
|
|
|
|
|
+ <p className="text-xs text-gray-400 truncate">{projectPath}</p>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ <div className="flex items-center gap-2 shrink-0">
|
|
|
|
|
+ {/* API settings toggle */}
|
|
|
|
|
+ <div className="relative">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onToggleApiSettings}
|
|
|
|
|
+ className={`flex items-center gap-1.5 px-3 py-1.5 text-xs rounded-lg border transition-colors ${
|
|
|
|
|
+ showApiSettings
|
|
|
|
|
+ ? 'bg-blue-50 border-blue-300 text-blue-700'
|
|
|
|
|
+ : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ aria-label="API 设置"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path
|
|
|
|
|
+ strokeLinecap="round"
|
|
|
|
|
+ strokeLinejoin="round"
|
|
|
|
|
+ strokeWidth={2}
|
|
|
|
|
+ d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"
|
|
|
|
|
+ />
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ API 设置
|
|
|
|
|
+ </button>
|
|
|
|
|
+
|
|
|
|
|
+ {showApiSettings && (
|
|
|
|
|
+ <div className="absolute top-10 right-0 w-80 bg-white border border-gray-200 rounded-xl shadow-xl p-4 z-50">
|
|
|
|
|
+ <div className="flex items-center justify-between mb-3">
|
|
|
|
|
+ <h3 className="text-sm font-semibold text-gray-700">API 设置</h3>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={onToggleApiSettings}
|
|
|
|
|
+ className="text-gray-400 hover:text-gray-600 transition-colors"
|
|
|
|
|
+ aria-label="关闭"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ <ApiSettings
|
|
|
|
|
+ showSettings={true}
|
|
|
|
|
+ toggleSettings={onToggleApiSettings}
|
|
|
|
|
+ apiProvider={apiProvider}
|
|
|
|
|
+ setApiProvider={setApiProvider}
|
|
|
|
|
+ apiUrl={apiUrl}
|
|
|
|
|
+ setApiUrl={setApiUrl}
|
|
|
|
|
+ apiKey={apiKey}
|
|
|
|
|
+ setApiKey={setApiKey}
|
|
|
|
|
+ model={model}
|
|
|
|
|
+ setModel={setModel}
|
|
|
|
|
+ availableModels={availableModels}
|
|
|
|
|
+ fetchModels={fetchModels}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Close project */}
|
|
|
|
|
+ {!showCloseConfirm ? (
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => setShowCloseConfirm(true)}
|
|
|
|
|
+ className="flex items-center gap-1.5 px-3 py-1.5 text-xs text-gray-600 bg-white border border-gray-200 rounded-lg hover:bg-red-50 hover:border-red-300 hover:text-red-600 transition-colors"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 关闭项目
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-xs text-red-600">确定关闭?</span>
|
|
|
|
|
+ <button onClick={onProjectClose} className="px-2 py-1 text-xs font-medium text-white bg-red-500 rounded hover:bg-red-600">确认</button>
|
|
|
|
|
+ <button onClick={() => setShowCloseConfirm(false)} className="px-2 py-1 text-xs text-gray-500 border border-gray-300 rounded hover:bg-gray-50">取消</button>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </header>
|
|
|
|
|
+
|
|
|
|
|
+ <PhaseNavigation currentPhase={currentPhase} onPhaseChange={setCurrentPhase} />
|
|
|
|
|
+
|
|
|
|
|
+ {currentPhase === 'phase1' && (
|
|
|
|
|
+ <div className="flex-1 flex flex-col overflow-hidden">
|
|
|
|
|
+ <div className="flex items-center gap-0 border-b border-gray-200 bg-white px-4 shrink-0">
|
|
|
|
|
+ {[
|
|
|
|
|
+ { id: 'world' as const, label: '世界观编辑' },
|
|
|
|
|
+ { id: 'events' as const, label: '事件图' },
|
|
|
|
|
+ ].map(tab => (
|
|
|
|
|
+ <button key={tab.id} onClick={() => setPhase1Tab(tab.id)}
|
|
|
|
|
+ className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
|
|
|
|
+ phase1Tab === tab.id ? 'border-indigo-600 text-indigo-600' : 'border-transparent text-gray-500 hover:text-gray-700'
|
|
|
|
|
+ }`}>
|
|
|
|
|
+ {tab.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ {phase1Tab === 'world' ? (
|
|
|
|
|
+ <WorldEditor
|
|
|
|
|
+ projectPath={projectPath}
|
|
|
|
|
+ apiSettings={apiSettings}
|
|
|
|
|
+ initialFileName={editingFile?.file.fileName}
|
|
|
|
|
+ />
|
|
|
|
|
+ ) : (
|
|
|
|
|
+ <EventGraphEditor projectPath={projectPath} apiSettings={apiSettings} />
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {currentPhase === 'phase3' && (
|
|
|
|
|
+ <MaintenancePanel projectPath={projectPath} apiSettings={apiSettings} />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {currentPhase !== 'phase1' && currentPhase !== 'phase3' && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <div className="flex-1 flex flex-col">
|
|
|
|
|
+ {/* Saved notification */}
|
|
|
|
|
+ {savedNotification && (
|
|
|
|
|
+ <div className="mx-5 mt-3 px-4 py-2 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700 flex items-center gap-2 shrink-0">
|
|
|
|
|
+ <svg className="w-4 h-4 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 已自动保存
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {/* Prompt template selector */}
|
|
|
|
|
+ <div className="px-5 py-3 bg-white border-b border-gray-100 shrink-0">
|
|
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
|
|
+ <span className="text-xs text-gray-500 shrink-0">AI 提示词模板:</span>
|
|
|
|
|
+ <div className="flex gap-2 flex-wrap">
|
|
|
|
|
+ {PROMPT_TEMPLATES.map((template) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={template.id}
|
|
|
|
|
+ onClick={() => handleTemplateSelect(template.id)}
|
|
|
|
|
+ title={template.description}
|
|
|
|
|
+ className={`px-3 py-1 text-xs rounded-full border transition-all ${
|
|
|
|
|
+ selectedTemplateId === template.id
|
|
|
|
|
+ ? 'bg-indigo-600 border-indigo-600 text-white'
|
|
|
|
|
+ : 'bg-white border-gray-200 text-gray-600 hover:border-indigo-300 hover:text-indigo-600'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {template.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Start new chapter + Marathon toggle */}
|
|
|
|
|
+ <div className="px-5 py-4 shrink-0 space-y-3">
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={handleStartNewChapter}
|
|
|
|
|
+ className="flex items-center gap-2 w-full px-5 py-2.5 bg-indigo-600 text-white text-sm font-medium rounded-xl hover:bg-indigo-700 transition-colors shadow-sm"
|
|
|
|
|
+ >
|
|
|
|
|
+ <svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
|
|
|
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 开始新章节(第 {chapters.length + 1} 章)
|
|
|
|
|
+ </button>
|
|
|
|
|
+ <label className="flex items-center gap-2 px-3 py-2 bg-gray-50 border border-gray-200 rounded-lg cursor-pointer hover:bg-gray-100 transition-colors">
|
|
|
|
|
+ <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">
|
|
|
|
|
+ 马拉松模式
|
|
|
|
|
+ {marathonMode && <span className="text-indigo-600 font-medium ml-1">— 自动续写</span>}
|
|
|
|
|
+ </span>
|
|
|
|
|
+ </label>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Tab navigation */}
|
|
|
|
|
+ <div className="px-5 border-b border-gray-200 bg-white shrink-0">
|
|
|
|
|
+ <nav className="flex gap-0" role="tablist">
|
|
|
|
|
+ {[
|
|
|
|
|
+ { id: 'chapters' as const, label: '章节历史' },
|
|
|
|
|
+ { id: 'constraints' as const, label: '写作约束' },
|
|
|
|
|
+ { id: 'anti' as const, label: '反同质化' },
|
|
|
|
|
+ ].map((tab) => (
|
|
|
|
|
+ <button
|
|
|
|
|
+ key={tab.id}
|
|
|
|
|
+ role="tab"
|
|
|
|
|
+ aria-selected={activeTab === tab.id}
|
|
|
|
|
+ onClick={() => setActiveTab(tab.id)}
|
|
|
|
|
+ className={`px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
|
|
|
|
+ activeTab === tab.id
|
|
|
|
|
+ ? 'border-indigo-600 text-indigo-600'
|
|
|
|
|
+ : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ {tab.label}
|
|
|
|
|
+ </button>
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </nav>
|
|
|
|
|
+ </div>
|
|
|
|
|
+
|
|
|
|
|
+ {/* Tab content */}
|
|
|
|
|
+ <div className="flex-1 overflow-hidden">
|
|
|
|
|
+ {activeTab === 'chapters' && (
|
|
|
|
|
+ <ChapterHistoryList projectPath={projectPath} />
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeTab === 'constraints' && (
|
|
|
|
|
+ <div className="h-full overflow-y-auto p-5">
|
|
|
|
|
+ <div className="max-w-2xl">
|
|
|
|
|
+ <h2 className="text-sm font-semibold text-gray-700 mb-1">写作约束</h2>
|
|
|
|
|
+ <p className="text-xs text-gray-400 mb-4">
|
|
|
|
|
+ 设置每章必须满足的写作要求,这些约束将注入 AI 提示词中。
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <WritingConstraintManager
|
|
|
|
|
+ constraints={writingConstraints}
|
|
|
|
|
+ onChange={handleConstraintsChange}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+
|
|
|
|
|
+ {activeTab === 'anti' && (
|
|
|
|
|
+ <div className="h-full overflow-y-auto p-5">
|
|
|
|
|
+ <div className="max-w-2xl">
|
|
|
|
|
+ <h2 className="text-sm font-semibold text-gray-700 mb-1">反同质化策略</h2>
|
|
|
|
|
+ <p className="text-xs text-gray-400 mb-4">
|
|
|
|
|
+ 选择用于避免内容同质化的写作策略,这些策略将注入 AI 提示词中。
|
|
|
|
|
+ </p>
|
|
|
|
|
+ <AntiHomogenizationPanel
|
|
|
|
|
+ strategies={antiHomogenizationStrategies}
|
|
|
|
|
+ onChange={handleStrategiesChange}
|
|
|
|
|
+ />
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ </div>
|
|
|
|
|
+ </div>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|