|
|
@@ -0,0 +1,159 @@
|
|
|
+'use client';
|
|
|
+
|
|
|
+import { useState, useEffect } from 'react';
|
|
|
+import { FileText, Sparkles } from 'lucide-react';
|
|
|
+import type { WorldStateFile, WorldLoreFile } from '@/app/lib/novel-types';
|
|
|
+import { useWorldState } from '@/app/hooks/useWorldState';
|
|
|
+
|
|
|
+interface WorldEditorProps {
|
|
|
+ projectPath: string;
|
|
|
+ apiSettings: { apiUrl: string; apiKey: string; model: string } | null;
|
|
|
+}
|
|
|
+
|
|
|
+export default function WorldEditor({ projectPath, apiSettings }: WorldEditorProps) {
|
|
|
+ const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
|
|
|
+ const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
|
|
|
+ const [editContent, setEditContent] = useState('');
|
|
|
+ const [isAiExpanding, setIsAiExpanding] = useState(false);
|
|
|
+ const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
|
|
+
|
|
|
+ const allFiles = [
|
|
|
+ ...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
|
|
|
+ ...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
|
|
|
+ ];
|
|
|
+
|
|
|
+ // Auto-save on content change (debounced 1.5s)
|
|
|
+ useEffect(() => {
|
|
|
+ if (!selectedFile || editContent === selectedFile.content) return;
|
|
|
+ const timer = setTimeout(async () => {
|
|
|
+ setSaveStatus('saving');
|
|
|
+ try {
|
|
|
+ await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
|
|
|
+ setSaveStatus('saved');
|
|
|
+ setTimeout(() => setSaveStatus('idle'), 2000);
|
|
|
+ } catch { setSaveStatus('idle'); }
|
|
|
+ }, 1500);
|
|
|
+ return () => clearTimeout(timer);
|
|
|
+ }, [editContent, selectedFile, updateFile]);
|
|
|
+
|
|
|
+ const handleSelectFile = (file: WorldStateFile) => {
|
|
|
+ setSelectedFile(file);
|
|
|
+ setEditContent(file.content);
|
|
|
+ setSaveStatus('idle');
|
|
|
+ };
|
|
|
+
|
|
|
+ const handleAIExpand = async () => {
|
|
|
+ if (!apiSettings || !selectedFile || !editContent) return;
|
|
|
+ setIsAiExpanding(true);
|
|
|
+ try {
|
|
|
+ const res = await fetch('/api/stream-proxy', {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({
|
|
|
+ apiUrl: apiSettings.apiUrl,
|
|
|
+ apiKey: apiSettings.apiKey,
|
|
|
+ model: apiSettings.model,
|
|
|
+ messages: [{
|
|
|
+ role: 'user',
|
|
|
+ content: `请基于以下现有内容,扩展和丰富这个小说设定文件。保持原有结构和风格,增加更多具体细节。\n\n## 当前内容\n${editContent}\n\n## 要求\n- 保持 Markdown 格式\n- 扩展内容要符合世界观逻辑\n- 增加具象化细节(避免抽象描述)\n- 保持原有章节结构`,
|
|
|
+ }],
|
|
|
+ }),
|
|
|
+ });
|
|
|
+
|
|
|
+ const reader = res.body?.getReader();
|
|
|
+ if (!reader) { setIsAiExpanding(false); return; }
|
|
|
+ let result = '';
|
|
|
+ while (true) {
|
|
|
+ const { done, value } = await reader.read();
|
|
|
+ if (done) break;
|
|
|
+ const text = new TextDecoder().decode(value);
|
|
|
+ const lines = text.split('\n').filter(l => l.startsWith('data: '));
|
|
|
+ for (const line of lines) {
|
|
|
+ try {
|
|
|
+ const json = JSON.parse(line.slice(6));
|
|
|
+ if (json.choices?.[0]?.delta?.content) {
|
|
|
+ result += json.choices[0].delta.content;
|
|
|
+ }
|
|
|
+ } catch { /* partial chunk */ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (result) setEditContent(prev => prev + '\n\n' + result);
|
|
|
+ } catch { /* ignore */ }
|
|
|
+ finally { setIsAiExpanding(false); }
|
|
|
+ };
|
|
|
+
|
|
|
+ return (
|
|
|
+ <div className="flex h-full bg-white">
|
|
|
+ {/* File tree sidebar */}
|
|
|
+ <div className="w-56 border-r border-gray-200 overflow-y-auto p-2 shrink-0">
|
|
|
+ <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide">世界状态</h3>
|
|
|
+ {allFiles.filter(f => f.category === 'world_state').map(file => (
|
|
|
+ <button
|
|
|
+ key={file.fileName}
|
|
|
+ onClick={() => handleSelectFile(file)}
|
|
|
+ className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
|
|
|
+ selectedFile?.fileName === file.fileName && !selectedFile?.isLore
|
|
|
+ ? 'bg-indigo-100 text-indigo-700'
|
|
|
+ : 'hover:bg-gray-50 text-gray-700'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <FileText className="inline h-3 w-3 mr-1" />
|
|
|
+ {file.displayName || file.fileName}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mt-3 mb-1 uppercase tracking-wide">世界观设定</h3>
|
|
|
+ {allFiles.filter(f => f.category === 'lore').map(file => (
|
|
|
+ <button
|
|
|
+ key={file.fileName}
|
|
|
+ onClick={() => handleSelectFile(file)}
|
|
|
+ className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
|
|
|
+ selectedFile?.fileName === file.fileName && selectedFile?.isLore
|
|
|
+ ? 'bg-indigo-100 text-indigo-700'
|
|
|
+ : 'hover:bg-gray-50 text-gray-700'
|
|
|
+ }`}
|
|
|
+ >
|
|
|
+ <FileText className="inline h-3 w-3 mr-1" />
|
|
|
+ {file.displayName || file.fileName}
|
|
|
+ </button>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {/* Editor area */}
|
|
|
+ <div className="flex-1 flex flex-col">
|
|
|
+ {selectedFile ? (
|
|
|
+ <>
|
|
|
+ <div className="flex items-center justify-between px-4 py-2 border-b border-gray-100 shrink-0">
|
|
|
+ <div className="flex items-center gap-2">
|
|
|
+ <h2 className="text-sm font-semibold text-gray-800">
|
|
|
+ {selectedFile.displayName || selectedFile.fileName}
|
|
|
+ </h2>
|
|
|
+ <span className="text-xs text-gray-400">
|
|
|
+ {saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ <button
|
|
|
+ onClick={handleAIExpand}
|
|
|
+ disabled={isAiExpanding || !apiSettings}
|
|
|
+ className="flex items-center gap-1 px-3 py-1.5 text-xs text-indigo-600 bg-indigo-50 border border-indigo-200 rounded-lg hover:bg-indigo-100 disabled:opacity-50 transition-colors"
|
|
|
+ >
|
|
|
+ <Sparkles className="h-3.5 w-3.5" />
|
|
|
+ {isAiExpanding ? '扩展中...' : 'AI 扩展'}
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <textarea
|
|
|
+ value={editContent}
|
|
|
+ onChange={e => setEditContent(e.target.value)}
|
|
|
+ className="flex-1 w-full p-4 text-sm font-mono border-0 resize-none focus:outline-none"
|
|
|
+ placeholder="文件内容..."
|
|
|
+ spellCheck={false}
|
|
|
+ />
|
|
|
+ </>
|
|
|
+ ) : (
|
|
|
+ <div className="flex-1 flex items-center justify-center text-gray-400 text-sm">
|
|
|
+ 从左侧选择文件开始编辑
|
|
|
+ </div>
|
|
|
+ )}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ );
|
|
|
+}
|