|
@@ -14,10 +14,17 @@ interface WorldEditorProps {
|
|
|
export default function WorldEditor({ projectPath, apiSettings, initialFileName }: WorldEditorProps) {
|
|
export default function WorldEditor({ projectPath, apiSettings, initialFileName }: WorldEditorProps) {
|
|
|
const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
|
|
const { worldStateFiles, loreFiles, updateFile } = useWorldState(projectPath);
|
|
|
const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
|
|
const [selectedFile, setSelectedFile] = useState<WorldStateFile | null>(null);
|
|
|
|
|
+ const [ironRulesFile, setIronRulesFile] = useState<WorldStateFile | null>(null);
|
|
|
|
|
|
|
|
// Auto-select the file navigated from sidebar
|
|
// Auto-select the file navigated from sidebar
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
if (!initialFileName) return;
|
|
if (!initialFileName) return;
|
|
|
|
|
+ // Check if it's iron-rules.md first
|
|
|
|
|
+ if (initialFileName === 'iron-rules.md' && ironRulesFile) {
|
|
|
|
|
+ setSelectedFile(ironRulesFile);
|
|
|
|
|
+ setEditContent(ironRulesFile.content);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
const allFiles = [
|
|
const allFiles = [
|
|
|
...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
|
|
...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
|
|
|
...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
|
|
...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
|
|
@@ -27,12 +34,34 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
|
|
|
setSelectedFile(target);
|
|
setSelectedFile(target);
|
|
|
setEditContent(target.content);
|
|
setEditContent(target.content);
|
|
|
}
|
|
}
|
|
|
- }, [initialFileName, worldStateFiles, loreFiles]);
|
|
|
|
|
|
|
+ }, [initialFileName, worldStateFiles, loreFiles, ironRulesFile]);
|
|
|
|
|
+ // Load iron-rules.md from project root
|
|
|
|
|
+ useEffect(() => {
|
|
|
|
|
+ if (!projectPath) return;
|
|
|
|
|
+ fetch('/api/novel/files', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ action: 'read', projectPath, filePath: 'iron-rules.md' }),
|
|
|
|
|
+ }).then(r => r.json()).then(data => {
|
|
|
|
|
+ if (data.success) {
|
|
|
|
|
+ setIronRulesFile({
|
|
|
|
|
+ fileName: 'iron-rules.md',
|
|
|
|
|
+ displayName: '项目铁律',
|
|
|
|
|
+ content: data.content as string,
|
|
|
|
|
+ lastUpdatedAt: data.lastUpdatedAt || new Date().toISOString(),
|
|
|
|
|
+ enabled: true,
|
|
|
|
|
+ isLore: false,
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+ }).catch(() => {});
|
|
|
|
|
+ }, [projectPath]);
|
|
|
|
|
+
|
|
|
const [editContent, setEditContent] = useState('');
|
|
const [editContent, setEditContent] = useState('');
|
|
|
const [isAiExpanding, setIsAiExpanding] = useState(false);
|
|
const [isAiExpanding, setIsAiExpanding] = useState(false);
|
|
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
|
const [saveStatus, setSaveStatus] = useState<'idle' | 'saving' | 'saved'>('idle');
|
|
|
|
|
|
|
|
const allFiles = [
|
|
const allFiles = [
|
|
|
|
|
+ ...(ironRulesFile ? [{ ...ironRulesFile, category: 'project' as const }] : []),
|
|
|
...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
|
|
...worldStateFiles.map(f => ({ ...f, category: 'world_state' as const })),
|
|
|
...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
|
|
...loreFiles.map(f => ({ ...f, category: 'lore' as const })),
|
|
|
];
|
|
];
|
|
@@ -43,19 +72,35 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
|
|
|
const timer = setTimeout(async () => {
|
|
const timer = setTimeout(async () => {
|
|
|
setSaveStatus('saving');
|
|
setSaveStatus('saving');
|
|
|
try {
|
|
try {
|
|
|
- await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
|
|
|
|
|
|
|
+ if (selectedFile.fileName === 'iron-rules.md') {
|
|
|
|
|
+ await fetch('/api/novel/files', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ action: 'write', projectPath, filePath: 'iron-rules.md', content: editContent }),
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
|
|
|
|
|
+ }
|
|
|
setSaveStatus('saved');
|
|
setSaveStatus('saved');
|
|
|
setTimeout(() => setSaveStatus('idle'), 2000);
|
|
setTimeout(() => setSaveStatus('idle'), 2000);
|
|
|
} catch { setSaveStatus('idle'); }
|
|
} catch { setSaveStatus('idle'); }
|
|
|
}, 1500);
|
|
}, 1500);
|
|
|
return () => clearTimeout(timer);
|
|
return () => clearTimeout(timer);
|
|
|
- }, [editContent, selectedFile, updateFile]);
|
|
|
|
|
|
|
+ }, [editContent, selectedFile, updateFile, projectPath]);
|
|
|
|
|
|
|
|
const handleSelectFile = async (file: WorldStateFile) => {
|
|
const handleSelectFile = async (file: WorldStateFile) => {
|
|
|
// Force-save current file before switching
|
|
// Force-save current file before switching
|
|
|
if (selectedFile && editContent !== selectedFile.content && editContent.trim()) {
|
|
if (selectedFile && editContent !== selectedFile.content && editContent.trim()) {
|
|
|
try {
|
|
try {
|
|
|
- await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
|
|
|
|
|
|
|
+ if (selectedFile.fileName === 'iron-rules.md') {
|
|
|
|
|
+ await fetch('/api/novel/files', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ action: 'write', projectPath, filePath: 'iron-rules.md', content: editContent }),
|
|
|
|
|
+ });
|
|
|
|
|
+ } else {
|
|
|
|
|
+ await updateFile(selectedFile.fileName, editContent, selectedFile.isLore);
|
|
|
|
|
+ }
|
|
|
} catch { /* ignore, proceed with switch */ }
|
|
} catch { /* ignore, proceed with switch */ }
|
|
|
}
|
|
}
|
|
|
setSelectedFile(file);
|
|
setSelectedFile(file);
|
|
@@ -111,13 +156,30 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
|
|
|
<div className="flex h-full bg-white">
|
|
<div className="flex h-full bg-white">
|
|
|
{/* File tree sidebar */}
|
|
{/* File tree sidebar */}
|
|
|
<div className="w-56 border-r border-gray-200 overflow-y-auto p-2 shrink-0">
|
|
<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>
|
|
|
|
|
|
|
+ {/* 项目文件 section (top) */}
|
|
|
|
|
+ {ironRulesFile && (
|
|
|
|
|
+ <>
|
|
|
|
|
+ <h3 className="text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide">项目文件</h3>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={() => handleSelectFile(ironRulesFile)}
|
|
|
|
|
+ className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
|
|
|
|
|
+ selectedFile?.fileName === 'iron-rules.md'
|
|
|
|
|
+ ? 'bg-indigo-100 text-indigo-700'
|
|
|
|
|
+ : 'hover:bg-gray-50 text-gray-700'
|
|
|
|
|
+ }`}
|
|
|
|
|
+ >
|
|
|
|
|
+ <FileText className="inline h-3 w-3 mr-1 text-indigo-400" />
|
|
|
|
|
+ <span className="text-indigo-600 font-medium">项目铁律</span>
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </>
|
|
|
|
|
+ )}
|
|
|
|
|
+ <h3 className={`text-xs font-semibold text-gray-500 px-2 py-1 mb-1 uppercase tracking-wide ${ironRulesFile ? 'mt-3' : 'mt-0'}`}>世界状态</h3>
|
|
|
{allFiles.filter(f => f.category === 'world_state').map(file => (
|
|
{allFiles.filter(f => f.category === 'world_state').map(file => (
|
|
|
<button
|
|
<button
|
|
|
key={file.fileName}
|
|
key={file.fileName}
|
|
|
onClick={() => handleSelectFile(file)}
|
|
onClick={() => handleSelectFile(file)}
|
|
|
className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
|
|
className={`w-full text-left px-2 py-1.5 text-xs rounded-lg transition-colors ${
|
|
|
- selectedFile?.fileName === file.fileName && !selectedFile?.isLore
|
|
|
|
|
|
|
+ selectedFile?.fileName === file.fileName && !selectedFile?.isLore && selectedFile?.fileName !== 'iron-rules.md'
|
|
|
? 'bg-indigo-100 text-indigo-700'
|
|
? 'bg-indigo-100 text-indigo-700'
|
|
|
: 'hover:bg-gray-50 text-gray-700'
|
|
: 'hover:bg-gray-50 text-gray-700'
|
|
|
}`}
|
|
}`}
|
|
@@ -156,14 +218,38 @@ export default function WorldEditor({ projectPath, apiSettings, initialFileName
|
|
|
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
|
|
{saveStatus === 'saving' ? '保存中...' : saveStatus === 'saved' ? '已保存' : ''}
|
|
|
</span>
|
|
</span>
|
|
|
</div>
|
|
</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 className="flex items-center gap-2">
|
|
|
|
|
+ <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>
|
|
|
|
|
+ <button
|
|
|
|
|
+ onClick={async () => {
|
|
|
|
|
+ if (!selectedFile) return;
|
|
|
|
|
+ const filePath = selectedFile.fileName === 'iron-rules.md'
|
|
|
|
|
+ ? 'iron-rules.md'
|
|
|
|
|
+ : selectedFile.isLore
|
|
|
|
|
+ ? `world_state/lore/${selectedFile.fileName}`
|
|
|
|
|
+ : `world_state/${selectedFile.fileName}`;
|
|
|
|
|
+ await fetch('/api/novel/files', {
|
|
|
|
|
+ method: 'POST',
|
|
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
+ body: JSON.stringify({ action: 'open', projectPath, filePath }),
|
|
|
|
|
+ });
|
|
|
|
|
+ }}
|
|
|
|
|
+ className="flex items-center gap-1 px-3 py-1.5 text-xs text-gray-500 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
|
|
|
|
|
+ title="在系统编辑器中打开"
|
|
|
|
|
+ >
|
|
|
|
|
+ <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 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
|
|
|
|
|
+ </svg>
|
|
|
|
|
+ 外部打开
|
|
|
|
|
+ </button>
|
|
|
|
|
+ </div>
|
|
|
</div>
|
|
</div>
|
|
|
<textarea
|
|
<textarea
|
|
|
value={editContent}
|
|
value={editContent}
|