2026-08-08 07:09:54 +08:00
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
|
|
|
|
import { api, DocTreeNode, DocContent } from './api';
|
|
|
|
|
|
import { DocTree } from './components/DocTree';
|
|
|
|
|
|
import { Editor } from './components/Editor';
|
|
|
|
|
|
import { SearchBar } from './components/SearchBar';
|
|
|
|
|
|
import { VersionHistory } from './components/VersionHistory';
|
2026-08-08 07:38:09 +08:00
|
|
|
|
import AgentChat from './components/AgentChat';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
|
|
|
|
|
type View = 'editor' | 'history';
|
|
|
|
|
|
|
|
|
|
|
|
export default function App() {
|
2026-08-08 10:39:18 +08:00
|
|
|
|
const agentApiBase = window.location.protocol === 'file:' ? 'http://127.0.0.1:3890' : '';
|
2026-08-08 07:09:54 +08:00
|
|
|
|
const [tree, setTree] = useState<DocTreeNode[]>([]);
|
|
|
|
|
|
const [currentDoc, setCurrentDoc] = useState<DocContent | null>(null);
|
|
|
|
|
|
const [currentPath, setCurrentPath] = useState<string>('');
|
|
|
|
|
|
const [view, setView] = useState<View>('editor');
|
|
|
|
|
|
const [sidebarOpen, setSidebarOpen] = useState(true);
|
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
const [error, setError] = useState<string | null>(null);
|
2026-08-08 07:38:09 +08:00
|
|
|
|
const [agentPanelOpen, setAgentPanelOpen] = useState(true);
|
2026-08-08 07:09:54 +08:00
|
|
|
|
|
|
|
|
|
|
const refreshTree = useCallback(async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const t = await api.getTree();
|
|
|
|
|
|
setTree(t);
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`加载文档树失败: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const openDoc = useCallback(async (docPath: string) => {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
setError(null);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.getDoc(docPath);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
setCurrentPath(docPath);
|
|
|
|
|
|
setView('editor');
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`加载文档失败: ${err.message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const saveDoc = useCallback(async (title: string, body: string) => {
|
|
|
|
|
|
if (!currentPath) return;
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.updateDoc(currentPath, title, body);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`保存失败: ${err.message}`);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentPath, refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
const createDoc = useCallback(async (parentPath: string) => {
|
|
|
|
|
|
const name = prompt('文档文件名(不含 .md):');
|
|
|
|
|
|
if (!name) return;
|
|
|
|
|
|
const title = prompt('文档标题:') || name;
|
|
|
|
|
|
const docPath = parentPath ? `${parentPath}/${name}.md` : `${name}.md`;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const doc = await api.createDoc(docPath, title, `# ${title}\n\n在这里开始写作...\n`);
|
|
|
|
|
|
setCurrentDoc(doc);
|
|
|
|
|
|
setCurrentPath(docPath);
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`创建失败: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
const deleteDoc = useCallback(async () => {
|
|
|
|
|
|
if (!currentPath) return;
|
|
|
|
|
|
if (!confirm(`确认删除 ${currentPath}?`)) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
await api.deleteDoc(currentPath);
|
|
|
|
|
|
setCurrentDoc(null);
|
|
|
|
|
|
setCurrentPath('');
|
|
|
|
|
|
await refreshTree();
|
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
|
setError(`删除失败: ${err.message}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [currentPath, refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
refreshTree();
|
|
|
|
|
|
}, [refreshTree]);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="kb-app">
|
|
|
|
|
|
{/* 顶栏 */}
|
|
|
|
|
|
<header className="kb-header">
|
|
|
|
|
|
<div className="kb-header-left">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="kb-btn-icon"
|
|
|
|
|
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
|
|
|
|
title={sidebarOpen ? '收起侧栏' : '展开侧栏'}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sidebarOpen ? '◀' : '▶'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<h1 className="kb-title">光湖知识库</h1>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-header-center">
|
|
|
|
|
|
<SearchBar onSelect={openDoc} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-header-right">
|
|
|
|
|
|
{currentDoc && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`kb-btn-tab ${view === 'editor' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setView('editor')}
|
|
|
|
|
|
>
|
|
|
|
|
|
编辑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className={`kb-btn-tab ${view === 'history' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setView('history')}
|
|
|
|
|
|
>
|
|
|
|
|
|
历史
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button className="kb-btn-icon kb-btn-danger" onClick={deleteDoc} title="删除">
|
|
|
|
|
|
🗑
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
2026-08-08 07:38:09 +08:00
|
|
|
|
<button
|
|
|
|
|
|
className={`kb-btn-tab kb-btn-agent ${agentPanelOpen ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => setAgentPanelOpen(!agentPanelOpen)}
|
|
|
|
|
|
title={agentPanelOpen ? '收起 Agent' : '展开 Agent'}
|
|
|
|
|
|
>
|
|
|
|
|
|
🌊 Agent
|
|
|
|
|
|
</button>
|
2026-08-08 07:09:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 主体 */}
|
|
|
|
|
|
<div className="kb-body">
|
|
|
|
|
|
{/* 侧栏 */}
|
|
|
|
|
|
{sidebarOpen && (
|
|
|
|
|
|
<aside className="kb-sidebar">
|
|
|
|
|
|
<div className="kb-sidebar-actions">
|
|
|
|
|
|
<button className="kb-btn-small" onClick={() => createDoc('')}>
|
|
|
|
|
|
+ 根目录文档
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<DocTree
|
|
|
|
|
|
nodes={tree}
|
|
|
|
|
|
currentPath={currentPath}
|
|
|
|
|
|
onSelect={openDoc}
|
|
|
|
|
|
onCreate={createDoc}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</aside>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{/* 内容区 */}
|
|
|
|
|
|
<main className="kb-content">
|
|
|
|
|
|
{error && (
|
|
|
|
|
|
<div className="kb-error">
|
|
|
|
|
|
{error}
|
|
|
|
|
|
<button onClick={() => setError(null)}>✕</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{loading && <div className="kb-loading">加载中...</div>}
|
|
|
|
|
|
{!currentDoc && !loading && (
|
|
|
|
|
|
<div className="kb-empty">
|
|
|
|
|
|
<div className="kb-empty-icon">📖</div>
|
|
|
|
|
|
<p>选择左侧文档开始阅读</p>
|
|
|
|
|
|
<p className="kb-empty-hint">或点击「+ 根目录文档」创建新文档</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
{currentDoc && view === 'editor' && (
|
|
|
|
|
|
<Editor doc={currentDoc} onSave={saveDoc} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
{currentDoc && view === 'history' && (
|
|
|
|
|
|
<VersionHistory docPath={currentPath} />
|
|
|
|
|
|
)}
|
|
|
|
|
|
</main>
|
2026-08-08 07:38:09 +08:00
|
|
|
|
|
|
|
|
|
|
{/* Agent 面板 */}
|
|
|
|
|
|
{agentPanelOpen && (
|
|
|
|
|
|
<aside className="kb-agent-panel">
|
2026-08-08 10:39:18 +08:00
|
|
|
|
<AgentChat apiBase={agentApiBase} onDocSelect={openDoc} />
|
2026-08-08 07:38:09 +08:00
|
|
|
|
</aside>
|
|
|
|
|
|
)}
|
2026-08-08 07:09:54 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|