feat: 解析多表头excel
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
// @ts-check
|
||||
const { app, BrowserWindow, ipcMain, dialog, shell } = require('electron');
|
||||
const path = require('path');
|
||||
const { spawn } = require('child_process');
|
||||
|
||||
/** @type {import('child_process').ChildProcess | null} */
|
||||
let pythonProcess = null;
|
||||
const BACKEND_PORT = 9231;
|
||||
const BACKEND_URL = `http://127.0.0.1:${BACKEND_PORT}`;
|
||||
const isDev = !app.isPackaged;
|
||||
|
||||
function startPythonBackend() {
|
||||
const serverDir = isDev
|
||||
? path.join(__dirname, '..', '..', 'server')
|
||||
: path.join(process.resourcesPath, 'server');
|
||||
|
||||
// In dev, use Python directly; in production, use PyInstaller binary
|
||||
if (isDev) {
|
||||
const venvPython = path.join(serverDir, 'venv', 'bin', 'python3');
|
||||
const mainPy = path.join(serverDir, 'main.py');
|
||||
pythonProcess = spawn(venvPython, [mainPy], {
|
||||
cwd: serverDir,
|
||||
env: { ...process.env, ENGIMIND_PORT: String(BACKEND_PORT) },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
} else {
|
||||
const binaryName = process.platform === 'win32' ? 'engimind-server.exe' : 'engimind-server';
|
||||
const binary = path.join(serverDir, binaryName);
|
||||
pythonProcess = spawn(binary, [], {
|
||||
env: { ...process.env, ENGIMIND_PORT: String(BACKEND_PORT) },
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
});
|
||||
}
|
||||
|
||||
pythonProcess.stdout?.on('data', (data) => {
|
||||
console.log(`[server] ${data.toString().trim()}`);
|
||||
});
|
||||
pythonProcess.stderr?.on('data', (data) => {
|
||||
console.error(`[server] ${data.toString().trim()}`);
|
||||
});
|
||||
pythonProcess.on('exit', (code) => {
|
||||
console.log(`[server] exited with code ${code}`);
|
||||
pythonProcess = null;
|
||||
});
|
||||
}
|
||||
|
||||
function stopPythonBackend() {
|
||||
if (pythonProcess) {
|
||||
pythonProcess.kill('SIGTERM');
|
||||
setTimeout(() => {
|
||||
if (pythonProcess && !pythonProcess.killed) {
|
||||
pythonProcess.kill('SIGKILL');
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForBackend(maxWait = 15000) {
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxWait) {
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/health`);
|
||||
if (resp.ok) return true;
|
||||
} catch {}
|
||||
await new Promise(r => setTimeout(r, 300));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
const win = new BrowserWindow({
|
||||
width: 1600,
|
||||
height: 960,
|
||||
title: 'EngiMind — 工程 AI 协作空间',
|
||||
titleBarStyle: 'hiddenInset',
|
||||
backgroundColor: '#ffffff',
|
||||
webPreferences: {
|
||||
preload: path.join(__dirname, 'preload.cjs'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
},
|
||||
});
|
||||
|
||||
if (isDev) {
|
||||
win.loadURL('http://localhost:5173');
|
||||
win.webContents.openDevTools({ mode: 'detach' });
|
||||
} else {
|
||||
win.loadFile(path.join(__dirname, '..', 'dist', 'index.html'));
|
||||
}
|
||||
|
||||
return win;
|
||||
}
|
||||
|
||||
// ── IPC Handlers ──
|
||||
|
||||
// File dialog
|
||||
ipcMain.handle('dialog:openFiles', async (_, filters) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile', 'multiSelections'],
|
||||
filters: filters || [
|
||||
{ name: '所有支持的文件', extensions: ['pdf', 'xlsx', 'xls', 'docx', 'dwg', 'dxf', 'shp', 'geojson', 'kml', 'gpkg'] },
|
||||
{ name: 'PDF', extensions: ['pdf'] },
|
||||
{ name: 'Excel', extensions: ['xlsx', 'xls'] },
|
||||
{ name: 'Word', extensions: ['docx'] },
|
||||
{ name: 'CAD', extensions: ['dwg', 'dxf'] },
|
||||
{ name: 'GIS', extensions: ['shp', 'geojson', 'kml', 'gpkg'] },
|
||||
],
|
||||
});
|
||||
return result.canceled ? [] : result.filePaths;
|
||||
});
|
||||
|
||||
ipcMain.handle('dialog:openFile', async (_, filters) => {
|
||||
const result = await dialog.showOpenDialog({
|
||||
properties: ['openFile'],
|
||||
filters: filters || [
|
||||
{ name: 'Documents', extensions: ['pdf', 'xlsx', 'xls', 'docx'] },
|
||||
],
|
||||
});
|
||||
return result.canceled ? null : result.filePaths[0];
|
||||
});
|
||||
|
||||
// Backend URL for renderer
|
||||
ipcMain.handle('getBackendURL', () => BACKEND_URL);
|
||||
|
||||
// ── App lifecycle ──
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// In dev mode, skip auto-starting Python if it's already running externally
|
||||
if (isDev) {
|
||||
// Check if backend is already running
|
||||
let alreadyRunning = false;
|
||||
try {
|
||||
const resp = await fetch(`${BACKEND_URL}/health`);
|
||||
if (resp.ok) alreadyRunning = true;
|
||||
} catch {}
|
||||
|
||||
if (!alreadyRunning) {
|
||||
startPythonBackend();
|
||||
const ready = await waitForBackend();
|
||||
if (!ready) {
|
||||
console.error('Python backend failed to start within timeout');
|
||||
}
|
||||
} else {
|
||||
console.log('[electron] Python backend already running at', BACKEND_URL);
|
||||
}
|
||||
} else {
|
||||
startPythonBackend();
|
||||
const ready = await waitForBackend();
|
||||
if (!ready) {
|
||||
console.error('Python backend failed to start within timeout');
|
||||
}
|
||||
}
|
||||
createWindow();
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
stopPythonBackend();
|
||||
if (process.platform !== 'darwin') app.quit();
|
||||
});
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) createWindow();
|
||||
});
|
||||
|
||||
app.on('before-quit', () => {
|
||||
stopPythonBackend();
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
// @ts-check
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// File dialogs
|
||||
openFiles: (filters) => ipcRenderer.invoke('dialog:openFiles', filters),
|
||||
openFile: (filters) => ipcRenderer.invoke('dialog:openFile', filters),
|
||||
|
||||
// Backend URL
|
||||
getBackendURL: () => ipcRenderer.invoke('getBackendURL'),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>EngiMind</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+8277
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "engimind",
|
||||
"version": "1.0.0",
|
||||
"description": "EngiMind — 工程 AI 协作空间",
|
||||
"main": "electron/main.cjs",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "concurrently \"vite --port 5173 --strictPort --host 127.0.0.1\" \"wait-on http://localhost:5173 && electron .\"",
|
||||
"build": "tsc && vite build",
|
||||
"build:electron": "tsc -p tsconfig.electron.json",
|
||||
"preview": "vite preview",
|
||||
"package": "npm run build && npm run build:electron && electron-builder",
|
||||
"package:mac": "npm run package -- --mac",
|
||||
"package:win": "npm run package -- --win",
|
||||
"package:linux": "npm run package -- --linux"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^1.7.0",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-markdown": "^9.0.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.2.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react-swc": "^4.3.0",
|
||||
"concurrently": "^9.0.0",
|
||||
"electron": "^33.0.0",
|
||||
"electron-builder": "^25.0.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"typescript": "^6.0.2",
|
||||
"vite": "^8.0.5",
|
||||
"wait-on": "^8.0.0"
|
||||
},
|
||||
"build": {
|
||||
"appId": "com.engimind.app",
|
||||
"productName": "EngiMind",
|
||||
"directories": {
|
||||
"output": "release"
|
||||
},
|
||||
"files": [
|
||||
"dist/**/*",
|
||||
"electron/**/*.cjs"
|
||||
],
|
||||
"extraResources": [],
|
||||
"mac": {
|
||||
"target": ["dmg", "zip"],
|
||||
"category": "public.app-category.productivity",
|
||||
"icon": "public/icon.icns"
|
||||
},
|
||||
"win": {
|
||||
"target": ["nsis"],
|
||||
"icon": "public/icon.ico"
|
||||
},
|
||||
"linux": {
|
||||
"target": ["AppImage", "deb"],
|
||||
"icon": "public/icon.png"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
:root {
|
||||
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { LeftSidebar } from './components/LeftSidebar';
|
||||
import { Console } from './components/Console';
|
||||
import { RightSidebar } from './components/RightSidebar';
|
||||
import { SlideOverViewer } from './components/Modals/SlideOverViewer';
|
||||
import { NewProjectModal } from './components/Modals/NewProjectModal';
|
||||
import { InsightPopup } from './components/Modals/InsightPopup';
|
||||
import { ExcelPreviewModal } from './components/Modals/ExcelPreviewModal';
|
||||
import { SettingsModal } from './components/Settings/SettingsModal';
|
||||
import { useAppStore } from './stores/useAppStore';
|
||||
import { useChatStore } from './stores/useChatStore';
|
||||
import { subscribeEvents } from './api';
|
||||
|
||||
export default function App() {
|
||||
const initConfigs = useAppStore(s => s.initConfigs);
|
||||
const initProjects = useAppStore(s => s.initProjects);
|
||||
const updateFileStatus = useAppStore(s => s.updateFileStatus);
|
||||
const pendingExcelPreview = useAppStore(s => s.pendingExcelPreview);
|
||||
const setPendingExcelPreview = useAppStore(s => s.setPendingExcelPreview);
|
||||
const materialMsgIds = useRef<Map<string, number>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
initConfigs();
|
||||
initProjects();
|
||||
}, [initConfigs, initProjects]);
|
||||
|
||||
// Subscribe to SSE events from Python backend
|
||||
useEffect(() => {
|
||||
const unsub = subscribeEvents((data: any) => {
|
||||
if (data.type !== 'material_status_update') return;
|
||||
|
||||
const { fileId, fileName, status, step } = data;
|
||||
if (!fileId || !status) return;
|
||||
|
||||
updateFileStatus(fileId, status);
|
||||
|
||||
const cs = useChatStore.getState();
|
||||
let msgId = materialMsgIds.current.get(fileId);
|
||||
|
||||
if (!msgId) {
|
||||
msgId = Date.now() + Math.floor(Math.random() * 1000);
|
||||
materialMsgIds.current.set(fileId, msgId);
|
||||
cs.addMessage({
|
||||
id: msgId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
type: 'material-log',
|
||||
status: 'processing',
|
||||
steps: [step || `正在处理 ${fileName || fileId}...`],
|
||||
});
|
||||
} else {
|
||||
const msg = cs.messages.find(m => m.id === msgId);
|
||||
const existingSteps = msg?.steps || [];
|
||||
|
||||
if (status === 'done') {
|
||||
cs.updateMessage(msgId, {
|
||||
status: 'success',
|
||||
steps: [...existingSteps, '✓ 处理完成,素材已就绪'],
|
||||
content: '素材已解析并分块存储,可以在对话中引用。',
|
||||
});
|
||||
materialMsgIds.current.delete(fileId);
|
||||
} else if (status === 'error') {
|
||||
cs.updateMessage(msgId, {
|
||||
status: 'error',
|
||||
steps: [...existingSteps, step || '✗ 处理失败'],
|
||||
});
|
||||
materialMsgIds.current.delete(fileId);
|
||||
} else if (step) {
|
||||
cs.updateMessage(msgId, { steps: [...existingSteps, step] });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return unsub;
|
||||
}, [updateFileStatus]);
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen bg-[var(--color-surface-main)] text-[var(--color-text-primary)] font-sans overflow-hidden transition-colors duration-300">
|
||||
<div className="grid h-full w-full grid-cols-[auto_1fr_auto] overflow-hidden">
|
||||
<LeftSidebar />
|
||||
<Console />
|
||||
<RightSidebar />
|
||||
</div>
|
||||
<SlideOverViewer />
|
||||
<NewProjectModal />
|
||||
<InsightPopup />
|
||||
<SettingsModal />
|
||||
{pendingExcelPreview && (
|
||||
<ExcelPreviewModal
|
||||
fileId={pendingExcelPreview.id}
|
||||
fileName={pendingExcelPreview.name}
|
||||
onClose={() => setPendingExcelPreview(null)}
|
||||
onIngested={() => setPendingExcelPreview(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
/**
|
||||
* API client — replaces Wails bindings with HTTP calls to the Python backend.
|
||||
* All functions match the original Wails binding signatures.
|
||||
*/
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI?: {
|
||||
openFiles: (filters?: any) => Promise<string[]>;
|
||||
openFile: (filters?: any) => Promise<string | null>;
|
||||
getBackendURL: () => Promise<string>;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
let _baseURL = 'http://127.0.0.1:9231';
|
||||
|
||||
export async function initAPI() {
|
||||
if (window.electronAPI) {
|
||||
_baseURL = await window.electronAPI.getBackendURL();
|
||||
}
|
||||
}
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${_baseURL}${path}`);
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function post<T>(path: string, body?: any): Promise<T> {
|
||||
const resp = await fetch(`${_baseURL}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function del<T>(path: string): Promise<T> {
|
||||
const resp = await fetch(`${_baseURL}${path}`, { method: 'DELETE' });
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
// ── Config ──
|
||||
|
||||
export async function GetAllProviders() {
|
||||
return get<any[]>('/api/providers');
|
||||
}
|
||||
|
||||
export async function SaveProvider(p: any) {
|
||||
return post('/api/providers', p);
|
||||
}
|
||||
|
||||
export async function DeleteProvider(id: string) {
|
||||
return del(`/api/providers/${id}`);
|
||||
}
|
||||
|
||||
export async function GetVectorDBConfig() {
|
||||
return get<any>('/api/vector-db');
|
||||
}
|
||||
|
||||
export async function SaveVectorDBConfig(c: any) {
|
||||
return post('/api/vector-db', c);
|
||||
}
|
||||
|
||||
export async function GetEmbeddingConfig() {
|
||||
return get<any>('/api/embedding');
|
||||
}
|
||||
|
||||
export async function SaveEmbeddingConfig(c: any) {
|
||||
return post('/api/embedding', c);
|
||||
}
|
||||
|
||||
export async function TestVectorDBConnection(endpoint: string) {
|
||||
return post<{ ok: boolean }>('/api/vector-db/test', { endpoint });
|
||||
}
|
||||
|
||||
export async function TestEmbeddingConnection(provider: string, url: string, model: string, key: string) {
|
||||
return post<{ ok: boolean }>('/api/embedding/test', { provider, url, model, key });
|
||||
}
|
||||
|
||||
export async function TestLLMConnection(provider: string, url: string, key: string) {
|
||||
return post<{ ok: boolean }>('/api/llm/test', { provider, url, key });
|
||||
}
|
||||
|
||||
// ── Projects ──
|
||||
|
||||
export async function ListProjects() {
|
||||
return get<any[]>('/api/projects');
|
||||
}
|
||||
|
||||
export async function CreateProject(name: string) {
|
||||
return post<any>('/api/projects', { name });
|
||||
}
|
||||
|
||||
export async function SwitchProject(id: string) {
|
||||
return post(`/api/projects/${id}/switch`);
|
||||
}
|
||||
|
||||
export async function DeleteProject(id: string) {
|
||||
return del(`/api/projects/${id}`);
|
||||
}
|
||||
|
||||
// ── Materials ──
|
||||
|
||||
export async function GetProjectFiles() {
|
||||
return get<any[]>('/api/materials');
|
||||
}
|
||||
|
||||
export async function UploadMaterials(): Promise<any[]> {
|
||||
// Use Electron dialog to pick files, then send paths to backend
|
||||
if (!window.electronAPI) {
|
||||
console.warn('No Electron API available');
|
||||
return [];
|
||||
}
|
||||
const paths = await window.electronAPI.openFiles();
|
||||
if (!paths || paths.length === 0) return [];
|
||||
return post<any[]>('/api/materials/upload', { filePaths: paths });
|
||||
}
|
||||
|
||||
export async function DeleteMaterial(fileId: string) {
|
||||
return del(`/api/materials/${fileId}`);
|
||||
}
|
||||
|
||||
// ── Material Content Preview ──
|
||||
|
||||
export interface MaterialContentResult {
|
||||
type: string;
|
||||
content?: string;
|
||||
sheets?: Array<{ name: string; rows: string[][] }>;
|
||||
}
|
||||
|
||||
export async function GetMaterialContent(fileId: string): Promise<MaterialContentResult> {
|
||||
return get<MaterialContentResult>(`/api/materials/${fileId}/content`);
|
||||
}
|
||||
|
||||
// ── Excel Pre-parse & Ingest ──
|
||||
|
||||
export interface PreParseResult {
|
||||
total_rows: number;
|
||||
suggested_start_row: number;
|
||||
sheets: Array<{
|
||||
name: string;
|
||||
total_rows: number;
|
||||
suggested_start_row: number;
|
||||
headers: string[];
|
||||
preview_sentences: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function PreParseMaterial(fileId: string, startRow?: number): Promise<PreParseResult> {
|
||||
return post<PreParseResult>('/api/materials/pre-parse', { fileId, startRow: startRow ?? null });
|
||||
}
|
||||
|
||||
export async function IngestMaterial(fileId: string, startRow: number): Promise<{ ok: boolean }> {
|
||||
return post<{ ok: boolean }>('/api/materials/ingest', { fileId, startRow });
|
||||
}
|
||||
|
||||
// ── Chat ──
|
||||
|
||||
export async function GetChatMessages() {
|
||||
return get<any[]>('/api/chat/messages');
|
||||
}
|
||||
|
||||
export async function SaveChatMessage(role: string, content: string, sources: string, citations: string) {
|
||||
return post('/api/chat/messages', { role, content, sources, citations });
|
||||
}
|
||||
|
||||
export async function ClearChatMessages() {
|
||||
return del('/api/chat/messages');
|
||||
}
|
||||
|
||||
export async function SendMessage(content: string, selectedFileIds: string[], modelId: string) {
|
||||
return post<{ content: string }>('/api/chat/send', { content, selectedFileIds, modelId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a chat message via SSE. Calls onChunk for content tokens
|
||||
* and onThinking for reasoning tokens.
|
||||
* Pass an AbortController signal to allow stopping the stream.
|
||||
*/
|
||||
export async function StreamMessage(
|
||||
content: string, selectedFileIds: string[], modelId: string,
|
||||
onChunk: (text: string) => void,
|
||||
onThinking?: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const resp = await fetch(`${_baseURL}/api/chat/stream`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, selectedFileIds, modelId }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') break;
|
||||
try {
|
||||
const chunk = JSON.parse(data);
|
||||
if (chunk.type === 'content') {
|
||||
fullText += chunk.text;
|
||||
onChunk(fullText);
|
||||
} else if (chunk.type === 'thinking' && onThinking) {
|
||||
onThinking(chunk.text);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') {
|
||||
// User stopped the stream — return what we have so far
|
||||
return fullText;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
// ── Template ──
|
||||
|
||||
export async function GetTemplateChapters() {
|
||||
return get<any[]>('/api/template/chapters');
|
||||
}
|
||||
|
||||
export async function SaveTemplateChapters(chapters: any[]) {
|
||||
return post('/api/template/chapters', chapters);
|
||||
}
|
||||
|
||||
export async function DeleteTemplateChapter(id: string) {
|
||||
return del(`/api/template/chapters/${id}`);
|
||||
}
|
||||
|
||||
export async function StreamTemplateDirectory(
|
||||
content: string, modelId: string,
|
||||
onChunk: (text: string) => void,
|
||||
onThinking?: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const resp = await fetch(`${_baseURL}/api/template/extract-directory`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, modelId }),
|
||||
signal,
|
||||
});
|
||||
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line.slice(6));
|
||||
if (chunk.type === 'content') {
|
||||
fullText += chunk.text;
|
||||
onChunk(fullText);
|
||||
} else if (chunk.type === 'thinking' && onThinking) {
|
||||
onThinking(chunk.text);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') return fullText;
|
||||
throw e;
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
export async function StreamGenerateChapter(
|
||||
chapterTitle: string, selectedFileIds: string[], modelId: string,
|
||||
onChunk: (text: string) => void,
|
||||
signal?: AbortSignal,
|
||||
): Promise<string> {
|
||||
const resp = await fetch(`${_baseURL}/api/chat/generate-chapter`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ chapterTitle, selectedFileIds, modelId }),
|
||||
signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(await resp.text());
|
||||
const reader = resp.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let fullText = '';
|
||||
let buffer = '';
|
||||
|
||||
try {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith('data: ')) continue;
|
||||
try {
|
||||
const chunk = JSON.parse(line.slice(6));
|
||||
if (chunk.type === 'content') {
|
||||
fullText += chunk.text;
|
||||
onChunk(fullText);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.name === 'AbortError') return fullText;
|
||||
throw e;
|
||||
}
|
||||
return fullText;
|
||||
}
|
||||
|
||||
// ── Delivery Standard ──
|
||||
|
||||
export async function GetDeliveryStandard() {
|
||||
return get<any>('/api/delivery-standard');
|
||||
}
|
||||
|
||||
export async function SaveDeliveryStandard(fileName: string, content: string) {
|
||||
return post('/api/delivery-standard', { fileName, content });
|
||||
}
|
||||
|
||||
export async function ParseDeliveryStandard(): Promise<string> {
|
||||
if (!window.electronAPI) return '';
|
||||
const filePath = await window.electronAPI.openFile([
|
||||
{ name: 'Documents', extensions: ['pdf', 'xlsx', 'xls', 'docx'] },
|
||||
]);
|
||||
if (!filePath) return '';
|
||||
const result = await post<any>('/api/parse-file', { filePath });
|
||||
return result.markdown || '';
|
||||
}
|
||||
|
||||
// ── SSE Events (material status) ──
|
||||
|
||||
export function subscribeEvents(onEvent: (data: any) => void): () => void {
|
||||
const evtSource = new EventSource(`${_baseURL}/api/events`);
|
||||
evtSource.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
onEvent(data);
|
||||
} catch {}
|
||||
};
|
||||
return () => evtSource.close();
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { Terminal, Loader2, CheckCircle2, BookOpen, Brain, ChevronDown, ChevronRight, UploadCloud, MessageSquare, Settings, FileText, AlertCircle } from 'lucide-react';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useCurrentProject } from '../../stores/useAppStore';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
function MaterialLog({ msg }: { msg: ChatMessage }) {
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="bg-[var(--color-surface-hover)] px-5 py-3 flex items-center justify-between border-b border-[var(--color-border-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText size={14} className="text-[var(--color-text-tertiary)]" />
|
||||
<span className="text-[11px] font-medium tracking-wide text-[var(--color-text-secondary)]">素材处理</span>
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
msg.status === 'success' ? 'bg-[var(--color-success-bg)] text-[var(--color-success)] border border-[var(--color-success-border)]'
|
||||
: msg.status === 'error' ? 'bg-red-50 text-red-600 border border-red-200'
|
||||
: 'bg-[var(--color-surface-active)] text-[var(--color-text-secondary)] animate-pulse border border-[var(--color-border-strong)]'
|
||||
}`}>
|
||||
{msg.status === 'success' ? '处理完成' : msg.status === 'error' ? '处理失败' : '处理中'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-2.5 font-mono">
|
||||
{msg.steps?.map((step, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 animate-fade-in">
|
||||
{idx === (msg.steps?.length ?? 0) - 1 && msg.status === 'processing'
|
||||
? <Loader2 size={12} className="text-[var(--color-accent-primary)] animate-spin shrink-0" />
|
||||
: msg.status === 'error' && idx === (msg.steps?.length ?? 0) - 1
|
||||
? <AlertCircle size={12} className="text-red-500 shrink-0" />
|
||||
: <CheckCircle2 size={12} className={msg.status === 'success' ? 'text-[var(--color-success)] shrink-0' : 'text-[var(--color-text-muted)] shrink-0'} />}
|
||||
<span className="text-[12px] text-[var(--color-text-secondary)]">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{msg.content && msg.status === 'success' && (
|
||||
<div className="mt-3 pt-3 border-t border-[var(--color-border-subtle)] text-[12px] text-[var(--color-text-tertiary)] font-sans">
|
||||
{msg.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GenerationLog({ msg }: { msg: ChatMessage }) {
|
||||
const currentProject = useCurrentProject();
|
||||
const setPreviewChapter = useUIStore(s => s.setPreviewChapter);
|
||||
|
||||
return (
|
||||
<div className="bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] rounded-2xl overflow-hidden shadow-sm">
|
||||
<div className="bg-[var(--color-surface-hover)] px-5 py-3 flex items-center justify-between border-b border-[var(--color-border-subtle)]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={14} className="text-[var(--color-text-tertiary)]" />
|
||||
<span className="text-[11px] font-medium tracking-wide text-[var(--color-text-secondary)]">
|
||||
BUILDING: {msg.chapterTitle}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`text-[10px] font-medium px-2 py-0.5 rounded-full ${
|
||||
msg.status === 'success' ? 'bg-[var(--color-success-bg)] text-[var(--color-success)] border border-[var(--color-success-border)]' : 'bg-[var(--color-surface-active)] text-[var(--color-text-secondary)] animate-pulse border border-[var(--color-border-strong)]'
|
||||
}`}>
|
||||
{msg.status === 'success' ? 'COMPLETE' : 'IN PROGRESS'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-3 font-mono">
|
||||
{msg.steps?.map((step, idx) => (
|
||||
<div key={idx} className="flex items-center gap-3 animate-fade-in font-mono">
|
||||
{idx === (msg.steps?.length ?? 0) - 1 && msg.status !== 'success'
|
||||
? <Loader2 size={12} className="text-[var(--color-accent-primary)] animate-spin" />
|
||||
: <CheckCircle2 size={12} className={msg.status === 'success' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]'} />}
|
||||
<span className="text-[12px] text-[var(--color-text-secondary)]">{step}</span>
|
||||
</div>
|
||||
))}
|
||||
{msg.status === 'success' && msg.metrics && (
|
||||
<div className="mt-5 pt-5 border-t border-[var(--color-border-subtle)] flex items-center justify-between font-sans">
|
||||
<div className="flex gap-6">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tight">Tokens</span>
|
||||
<span className="text-[13px] font-mono text-[var(--color-text-primary)]">{msg.metrics.tokensIn + msg.metrics.tokensOut}</span>
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-tight">Latency</span>
|
||||
<span className="text-[13px] font-mono text-[var(--color-text-primary)]">{msg.metrics.latency}s</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
const ch = currentProject.activeTemplate.chapters.find(c => c.title === msg.chapterTitle);
|
||||
if (ch) setPreviewChapter(ch);
|
||||
}}
|
||||
className="px-4 py-2 bg-[var(--color-surface-main)] shadow-sm border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] rounded-lg text-[12px] font-medium hover:border-[var(--color-border-strong)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-side)] transition-all flex items-center gap-2"
|
||||
>
|
||||
<BookOpen size={14} /> Read Document
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ThinkingBlock({ thinking, isStreaming }: { thinking: string; isStreaming?: boolean }) {
|
||||
const [expanded, setExpanded] = useState(isStreaming);
|
||||
|
||||
useEffect(() => {
|
||||
if (isStreaming) setExpanded(true);
|
||||
}, [isStreaming]);
|
||||
|
||||
return (
|
||||
<div className="mb-3 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(e => !e)}
|
||||
className="w-full flex items-center gap-2 px-4 py-2.5 text-left hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Loader2 size={13} className="text-[var(--color-accent-primary)] animate-spin shrink-0" />
|
||||
) : (
|
||||
<Brain size={13} className="text-[var(--color-text-tertiary)] shrink-0" />
|
||||
)}
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)] tracking-wide uppercase">
|
||||
{isStreaming ? '正在思考...' : '思考过程'}
|
||||
</span>
|
||||
{expanded ? <ChevronDown size={12} className="ml-auto text-[var(--color-text-tertiary)]" /> : <ChevronRight size={12} className="ml-auto text-[var(--color-text-tertiary)]" />}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="px-4 pb-3 text-[13px] leading-[1.6] text-[var(--color-text-tertiary)] whitespace-pre-wrap max-h-[300px] overflow-y-auto custom-scrollbar border-t border-[var(--color-border-subtle)]">
|
||||
{thinking}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MessageBubble({ msg }: { msg: ChatMessage }) {
|
||||
const setInsightData = useUIStore(s => s.setInsightData);
|
||||
|
||||
return (
|
||||
<div className={`flex flex-col ${msg.role === 'user' ? 'items-end' : 'items-start'}`}>
|
||||
<div className={`max-w-[85%] px-5 py-4 ${
|
||||
msg.role === 'user'
|
||||
? 'bg-[var(--color-surface-side)] text-[var(--color-text-primary)] rounded-3xl border border-[var(--color-border-subtle)]'
|
||||
: 'bg-transparent text-[var(--color-text-primary)]'
|
||||
}`}>
|
||||
{msg.role === 'assistant' && msg.thinking && (
|
||||
<ThinkingBlock thinking={msg.thinking} isStreaming={msg.status === 'processing'} />
|
||||
)}
|
||||
{msg.sources && msg.sources.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 mb-3">
|
||||
{msg.sources.map((s, i) => (
|
||||
<span key={i} className={`px-2.5 py-1 text-[11px] rounded-lg border ${msg.role === 'user' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)]' : 'bg-[var(--color-surface-side)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] shadow-sm'}`}>{s}</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-[15px] leading-[1.6] text-left opacity-90 transition-opacity whitespace-pre-wrap">
|
||||
{typeof msg.content === 'string'
|
||||
? msg.content.split(/(\[\d+\])/).map((part, i) => {
|
||||
const m = part.match(/\[(\d+)\]/);
|
||||
if (m) {
|
||||
return (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => {
|
||||
const citation = msg.citations?.find(c => c.id === parseInt(m[1]));
|
||||
if (citation) setInsightData(citation);
|
||||
}}
|
||||
className={`inline-flex items-center justify-center min-w-[22px] h-5.5 rounded px-1 text-[10px] font-mono font-medium mx-1 border transition-colors ${msg.role === 'user' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]' : 'bg-[var(--color-surface-side)] text-[var(--color-text-secondary)] border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-active)] hover:text-[var(--color-text-primary)]'}`}
|
||||
>
|
||||
{m[1]}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
})
|
||||
: '解析中...'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const { messages, isThinking } = useChatStore();
|
||||
const chatEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [messages, isThinking]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-4 sm:px-8 custom-scrollbar">
|
||||
<div className="max-w-3xl mx-auto space-y-10 pt-16 pb-8 min-h-full flex flex-col">
|
||||
{messages.length === 0 && (
|
||||
<div className="text-center mt-16 mb-auto flex flex-col items-center animate-fade-in">
|
||||
<h1 className="text-2xl font-serif text-[var(--color-text-primary)] mb-3 tracking-tight">EngiMind 工程协作空间</h1>
|
||||
<p className="text-[13px] text-[var(--color-text-tertiary)] mb-10">AI 驱动的工程文档写作助手</p>
|
||||
<div className="grid grid-cols-3 gap-3 max-w-md w-full">
|
||||
<button
|
||||
onClick={() => useUIStore.getState().setSettingsOpen(true)}
|
||||
className="flex flex-col items-center gap-2.5 p-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] hover:bg-[var(--color-surface-hover)] hover:border-[var(--color-border-strong)] transition-all group"
|
||||
>
|
||||
<Settings size={18} className="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-accent-primary)] transition-colors" />
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">配置模型</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => useUIStore.getState().setNewProjectModalOpen(true)}
|
||||
className="flex flex-col items-center gap-2.5 p-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] hover:bg-[var(--color-surface-hover)] hover:border-[var(--color-border-strong)] transition-all group"
|
||||
>
|
||||
<MessageSquare size={18} className="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-accent-primary)] transition-colors" />
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">新建工程</span>
|
||||
</button>
|
||||
<button
|
||||
className="flex flex-col items-center gap-2.5 p-4 rounded-xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] hover:bg-[var(--color-surface-hover)] hover:border-[var(--color-border-strong)] transition-all group"
|
||||
>
|
||||
<UploadCloud size={18} className="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-accent-primary)] transition-colors" />
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-secondary)]">上传标准</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className="animate-slide-up text-left">
|
||||
{msg.type === 'generation-log' ? <GenerationLog msg={msg} />
|
||||
: msg.type === 'material-log' ? <MaterialLog msg={msg} />
|
||||
: <MessageBubble msg={msg} />}
|
||||
</div>
|
||||
))}
|
||||
{isThinking && (
|
||||
<div className="flex items-center gap-3 animate-fade-in px-4">
|
||||
<Loader2 size={16} className="text-[var(--color-accent-primary)] animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
<div ref={chatEndRef} className="h-4" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { useRef } from 'react';
|
||||
import { Send, Square, X } from 'lucide-react';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { StreamMessage, SaveChatMessage } from '../../api';
|
||||
|
||||
export function ChatInput() {
|
||||
const { selectedFileIds, inputValue, setInputValue, isThinking } = useChatStore();
|
||||
const addMessage = useChatStore(s => s.addMessage);
|
||||
const setIsThinking = useChatStore(s => s.setIsThinking);
|
||||
const currentProject = useCurrentProject();
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const handleStop = () => {
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
if (!inputValue.trim() || isThinking) return;
|
||||
|
||||
const activeModelId = useAppStore.getState().activeModelId;
|
||||
if (!activeModelId) {
|
||||
alert('请先在配置中心连接一个有效的大语言模型提供商 (Please configure an LLM provider first)');
|
||||
return;
|
||||
}
|
||||
|
||||
const contextFiles = Array.from(selectedFileIds);
|
||||
const contextNames = contextFiles
|
||||
.map(id => currentProject.files.find(f => f.id === id)?.name)
|
||||
.filter(Boolean) as string[];
|
||||
|
||||
const question = inputValue;
|
||||
setInputValue('');
|
||||
setIsThinking(true);
|
||||
|
||||
addMessage({ id: Date.now(), role: 'user', content: question, sources: contextNames });
|
||||
|
||||
SaveChatMessage('user', question, JSON.stringify(contextNames), '').catch(console.error);
|
||||
|
||||
const assistantMsgId = Date.now() + 1;
|
||||
addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
|
||||
|
||||
const controller = new AbortController();
|
||||
abortRef.current = controller;
|
||||
|
||||
try {
|
||||
const finalContent = await StreamMessage(
|
||||
question,
|
||||
contextFiles,
|
||||
activeModelId,
|
||||
(fullText: string) => {
|
||||
const state = useChatStore.getState();
|
||||
const msg = state.messages.find(m => m.id === assistantMsgId);
|
||||
if (!msg) return;
|
||||
const displayText = fullText.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
const thinkMatch = fullText.match(/<think>([\s\S]*?)(<\/think>|$)/);
|
||||
const thinkContent = thinkMatch ? thinkMatch[1] : msg.thinking;
|
||||
state.updateMessage(assistantMsgId, {
|
||||
content: displayText || (thinkContent ? '' : ''),
|
||||
thinking: thinkContent,
|
||||
});
|
||||
},
|
||||
(thinkingText: string) => {
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { thinking: thinkingText });
|
||||
},
|
||||
controller.signal,
|
||||
);
|
||||
|
||||
const cleanContent = finalContent.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { content: cleanContent, status: 'success' });
|
||||
SaveChatMessage('assistant', cleanContent, '', '').catch(console.error);
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
// User stopped — save what we got
|
||||
const partial = useChatStore.getState().messages.find(m => m.id === assistantMsgId);
|
||||
const content = partial?.content || '';
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { status: 'success' });
|
||||
if (content) SaveChatMessage('assistant', content + '\n\n*(已停止生成)*', '', '').catch(console.error);
|
||||
} else {
|
||||
console.error(err);
|
||||
useChatStore.getState().appendChunk(assistantMsgId, `\n\n**请求失败:** ${err}`);
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { status: 'success' });
|
||||
}
|
||||
} finally {
|
||||
abortRef.current = null;
|
||||
setIsThinking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<footer className="px-6 pb-6 pt-0 mt-auto">
|
||||
<div className="max-w-3xl mx-auto relative flex flex-col gap-3">
|
||||
{selectedFileIds.size > 0 && (
|
||||
<div className="absolute -top-12 left-0 pointer-events-auto bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] px-4 py-2 rounded-xl text-[12px] font-medium flex items-center gap-3 shadow-sm animate-fade-in z-10 transition-all">
|
||||
<div className="w-2 h-2 rounded-full bg-[var(--color-accent-primary)]" />
|
||||
<span>已选 {selectedFileIds.size} 个文件</span>
|
||||
<div className="flex items-center gap-2 ml-1 border-l border-[var(--color-border-subtle)] pl-3">
|
||||
{Array.from(selectedFileIds).map(id => {
|
||||
const file = currentProject.files.find(f => f.id === id);
|
||||
if (!file) return null;
|
||||
return (
|
||||
<span key={id} className="text-[var(--color-text-primary)] max-w-[120px] truncate">{file.name}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => useChatStore.getState().clearSelection()}
|
||||
className="ml-2 hover:bg-[var(--color-surface-active)] p-1 rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-accent-primary)] transition-colors"
|
||||
title="清除选择"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative group flex items-end gap-3 bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] rounded-[1.5rem] p-2 pr-2.5 transition-all duration-300 focus-within:bg-[var(--color-surface-main)] focus-within:shadow-[0_4px_20px_-4px_rgba(0,0,0,0.06)] focus-within:border-[var(--color-border-strong)]">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
|
||||
placeholder="输入问题,让 AI 协助你..."
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 text-[15px] py-4 px-4 resize-none h-[64px] text-[var(--color-text-primary)] outline-none custom-scrollbar placeholder-[var(--color-text-muted)] font-sans leading-relaxed"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSend}
|
||||
className={`w-10 h-10 mb-1.5 shrink-0 flex items-center justify-center rounded-xl transition-all active:scale-95 ${
|
||||
inputValue.trim() ? 'bg-[var(--color-accent-primary)] text-white shadow-sm hover:bg-[var(--color-accent-primary-hover)]' : 'bg-[var(--color-surface-active)] text-[var(--color-text-muted)] cursor-not-allowed'
|
||||
}`}
|
||||
style={{ display: isThinking ? 'none' : 'flex' }}
|
||||
>
|
||||
<Send size={16} className={`ml-0.5 ${inputValue.trim() ? '' : 'opacity-80'}`} />
|
||||
</button>
|
||||
{isThinking && (
|
||||
<button
|
||||
onClick={handleStop}
|
||||
className="w-10 h-10 mb-1.5 shrink-0 flex items-center justify-center rounded-xl transition-all active:scale-95 bg-red-500 text-white shadow-sm hover:bg-red-600"
|
||||
title="停止生成"
|
||||
>
|
||||
<Square size={14} fill="currentColor" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Database, Settings, Scroll, Sparkles, PanelLeftOpen, PanelRightOpen } from 'lucide-react';
|
||||
import { useAppStore, useCurrentProject, useActiveModel } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function ConsoleHeader() {
|
||||
const currentProject = useCurrentProject();
|
||||
const activeModel = useActiveModel();
|
||||
const { vectorDB } = useAppStore();
|
||||
const { setSettingsOpen, isLeftSidebarCollapsed, isRightSidebarCollapsed, toggleLeftSidebar, toggleRightSidebar } = useUIStore();
|
||||
|
||||
return (
|
||||
<header className="h-14 flex items-center justify-between px-3 border-b border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] relative z-10">
|
||||
<div className="flex items-center gap-2">
|
||||
{isLeftSidebarCollapsed && (
|
||||
<button
|
||||
onClick={toggleLeftSidebar}
|
||||
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
title="展开左侧栏"
|
||||
>
|
||||
<PanelLeftOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-4 bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] px-4 py-2 rounded-xl shadow-sm">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className={`flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--color-accent-primary)] ${activeModel?.enabled ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]'}`}
|
||||
>
|
||||
<Sparkles size={14} strokeWidth={2.5} className={activeModel?.enabled ? 'text-[var(--color-accent-primary)]' : 'text-[var(--color-text-muted)]'} />
|
||||
<span className="hidden sm:inline max-w-[120px] truncate" title={activeModel?.name}>{activeModel?.name || '未配置模型'}</span>
|
||||
</button>
|
||||
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
|
||||
<button
|
||||
onClick={() => { useUIStore.getState().setActiveSettingsTab('vector'); setSettingsOpen(true); }}
|
||||
className={`flex items-center gap-2 text-[12px] font-medium transition-colors hover:text-[var(--color-success)] ${vectorDB.status === 'connected' ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-tertiary)]'}`}
|
||||
>
|
||||
<Database size={13} strokeWidth={2.5} className={vectorDB.status === 'connected' ? 'text-[var(--color-success)]' : 'text-[var(--color-text-muted)]'} />
|
||||
<span className="hidden sm:inline">Qdrant</span>
|
||||
</button>
|
||||
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-text-secondary)] font-medium">
|
||||
<Scroll size={13} strokeWidth={2} className="text-[var(--color-text-tertiary)]" />
|
||||
<span className="hidden sm:inline">{currentProject.activeTemplate?.version || '--'}</span>
|
||||
</div>
|
||||
<div className="w-[1px] h-3 bg-[var(--color-border-subtle)]" />
|
||||
<button
|
||||
onClick={() => setSettingsOpen(true)}
|
||||
className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors group p-1"
|
||||
>
|
||||
<Settings size={15} strokeWidth={2} className="group-hover:rotate-45 transition-transform duration-300" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{isRightSidebarCollapsed && (
|
||||
<button
|
||||
onClick={toggleRightSidebar}
|
||||
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
title="展开右侧栏"
|
||||
>
|
||||
<PanelRightOpen size={18} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { ConsoleHeader } from './ConsoleHeader';
|
||||
import { ChatArea } from './ChatArea';
|
||||
import { ChatInput } from './ChatInput';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function Console() {
|
||||
const { previewSource, previewChapter } = useUIStore();
|
||||
const hasOverlay = !!(previewSource || previewChapter);
|
||||
|
||||
return (
|
||||
<main
|
||||
className={[
|
||||
'flex flex-col relative bg-[var(--color-surface-main)] overflow-hidden min-w-0',
|
||||
hasOverlay ? 'opacity-40 pointer-events-none select-none blur-[1px]' : '',
|
||||
].join(' ')}
|
||||
>
|
||||
<ConsoleHeader />
|
||||
<ChatArea />
|
||||
<ChatInput />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FileText, Layers, Map as MapIcon, Table } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
className?: string;
|
||||
colored?: boolean;
|
||||
}
|
||||
|
||||
export function FileIcon({ type, className = '', colored = false }: Props) {
|
||||
if (!colored) {
|
||||
switch (type) {
|
||||
case 'pdf': return <FileText className={className} />;
|
||||
case 'cad': return <Layers className={className} />;
|
||||
case 'gis': return <MapIcon className={className} />;
|
||||
case 'excel': return <Table className={className} />;
|
||||
default: return <FileText className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pdf': return <FileText className={`text-red-500 ${className}`} />;
|
||||
case 'cad': return <Layers className={`text-cyan-500 ${className}`} />;
|
||||
case 'gis': return <MapIcon className={`text-emerald-500 ${className}`} />;
|
||||
case 'excel': return <Table className={`text-green-500 ${className}`} />;
|
||||
default: return <FileText className={className} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useState } from 'react';
|
||||
import { CheckSquare, Square, UploadCloud, Trash2, Loader2, CheckCircle2, AlertCircle, RotateCw } from 'lucide-react';
|
||||
import { FileIcon } from '../FileIcon';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { ExcelPreviewModal } from '../Modals/ExcelPreviewModal';
|
||||
|
||||
function StatusIndicator({ status }: { status?: string }) {
|
||||
switch (status) {
|
||||
case 'processing':
|
||||
return <Loader2 size={12} className="text-[var(--color-accent-primary)] animate-spin shrink-0" />;
|
||||
case 'done':
|
||||
return <CheckCircle2 size={12} className="text-[var(--color-success)] shrink-0" />;
|
||||
case 'error':
|
||||
return <AlertCircle size={12} className="text-[var(--color-danger)] shrink-0" />;
|
||||
default:
|
||||
return <div className="w-1.5 h-1.5 rounded-full bg-[var(--color-text-muted)] shrink-0" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function FileTree() {
|
||||
const currentProject = useCurrentProject();
|
||||
const { selectedFileIds, toggleFileSelection } = useChatStore();
|
||||
const { setPreviewSource } = useUIStore();
|
||||
const uploadMaterials = useAppStore(s => s.uploadMaterials);
|
||||
const deleteMaterial = useAppStore(s => s.deleteMaterial);
|
||||
const [reparseFile, setReparseFile] = useState<{ id: string; name: string } | null>(null);
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (currentProject.id === 'empty') {
|
||||
alert('请先创建或选择一个项目');
|
||||
return;
|
||||
}
|
||||
await uploadMaterials();
|
||||
};
|
||||
|
||||
const handleDelete = async (e: React.MouseEvent, fileId: string) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
try {
|
||||
await deleteMaterial(fileId);
|
||||
} catch (err) {
|
||||
console.error('Delete material failed:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReparse = (e: React.MouseEvent, file: { id: string; name: string }) => {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
setReparseFile(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2 custom-scrollbar">
|
||||
<div className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-[0.2em] mb-4 mt-2 px-3 flex justify-between items-center">
|
||||
<span>工程素材库</span>
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="p-1 hover:bg-[var(--color-surface-active)] rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
title="上传素材文件"
|
||||
>
|
||||
<UploadCloud size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{currentProject.files.length === 0 ? (
|
||||
<div
|
||||
onClick={handleUpload}
|
||||
className="mx-2 py-8 border-2 border-dashed border-[var(--color-border-subtle)] rounded-xl flex flex-col items-center gap-2 cursor-pointer hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] transition-all"
|
||||
>
|
||||
<UploadCloud size={24} className="text-[var(--color-text-muted)]" />
|
||||
<span className="text-[11px] text-[var(--color-text-tertiary)]">点击上传素材文件</span>
|
||||
<span className="text-[9px] text-[var(--color-text-muted)]">支持 Excel / Word / PDF / CAD</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-[2px]">
|
||||
{currentProject.files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setPreviewSource(file)}
|
||||
className={`group flex items-center gap-3 px-2.5 py-2 rounded-lg transition-all duration-200 cursor-pointer ${
|
||||
selectedFileIds.has(file.id)
|
||||
? 'bg-[var(--color-surface-active)] shadow-sm'
|
||||
: 'bg-transparent hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); toggleFileSelection(file.id); }}
|
||||
className={`shrink-0 transition-colors ml-1 ${selectedFileIds.has(file.id) ? 'text-[var(--color-accent-primary)]' : 'text-[var(--color-text-muted)] group-hover:text-[var(--color-text-tertiary)]'}`}
|
||||
>
|
||||
{selectedFileIds.has(file.id) ? <CheckSquare size={16} /> : <Square size={16} />}
|
||||
</div>
|
||||
<FileIcon type={file.type} className={`w-4 h-4 shrink-0 transition-transform group-hover:scale-110 ${selectedFileIds.has(file.id) ? 'opacity-100' : 'opacity-80'}`} colored={true} />
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<span className={`text-[13px] truncate transition-colors ${selectedFileIds.has(file.id) ? 'text-[var(--color-text-primary)] font-medium' : 'text-[var(--color-text-secondary)]'}`}>{file.name}</span>
|
||||
</div>
|
||||
<StatusIndicator status={file.vectorStatus} />
|
||||
{file.type === 'excel' && (
|
||||
<button
|
||||
onClick={(e) => handleReparse(e, file)}
|
||||
className="opacity-0 group-hover:opacity-100 text-[var(--color-text-muted)] hover:text-[var(--color-accent-primary)] transition-all shrink-0"
|
||||
title="重新解析"
|
||||
>
|
||||
<RotateCw size={13} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={(e) => handleDelete(e, file.id)}
|
||||
className="opacity-0 group-hover:opacity-100 text-[var(--color-text-muted)] hover:text-[var(--color-danger)] transition-all shrink-0"
|
||||
title="删除素材"
|
||||
>
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{reparseFile && (
|
||||
<ExcelPreviewModal
|
||||
fileId={reparseFile.id}
|
||||
fileName={reparseFile.name}
|
||||
onClose={() => setReparseFile(null)}
|
||||
onIngested={() => setReparseFile(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { ChevronDown, CheckCircle2, Plus } from 'lucide-react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function ProjectSelector() {
|
||||
const { projects, currentProjectId } = useAppStore();
|
||||
const switchProject = useAppStore(s => s.switchProject);
|
||||
const currentProject = useCurrentProject();
|
||||
const { isProjectDropdownOpen, setProjectDropdownOpen, setNewProjectModalOpen } = useUIStore();
|
||||
|
||||
const handleSwitch = async (id: string) => {
|
||||
setProjectDropdownOpen(false);
|
||||
await switchProject(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative flex items-center gap-2 mb-6">
|
||||
<button
|
||||
onClick={() => setProjectDropdownOpen(!isProjectDropdownOpen)}
|
||||
className="flex-1 flex flex-col items-start justify-center bg-transparent px-2 py-1.5 rounded-lg hover:bg-[var(--color-surface-active)] transition-colors duration-200 group relative overflow-hidden"
|
||||
>
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-[13px] truncate text-[var(--color-text-primary)]">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[var(--color-text-tertiary)] group-hover:text-[var(--color-text-primary)] transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setNewProjectModalOpen(true)}
|
||||
className="w-7 h-7 shrink-0 flex items-center justify-center bg-transparent hover:bg-[var(--color-surface-active)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] rounded-lg transition-colors duration-200"
|
||||
title="新建工程"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
{isProjectDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-10 bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] rounded-xl shadow-[0_4px_20px_-4px_rgba(0,0,0,0.1)] z-50 overflow-hidden animate-zoom-in">
|
||||
<div className="max-h-64 overflow-y-auto custom-scrollbar text-left py-1">
|
||||
{projects.map(p => (
|
||||
<div
|
||||
key={p.id}
|
||||
onClick={() => handleSwitch(p.id)}
|
||||
className={`px-3 py-2 mx-1 hover:bg-[var(--color-surface-hover)] cursor-pointer flex items-center gap-3 text-[13px] rounded-lg transition-colors ${
|
||||
p.id === currentProjectId ? 'text-[var(--color-accent-primary)] font-medium' : 'text-[var(--color-text-secondary)]'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
{p.id === currentProjectId && <CheckCircle2 size={14} className="ml-auto text-[var(--color-accent-primary)]" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
import { Shield, UploadCloud, FileCheck, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { ParseDeliveryStandard, SaveDeliveryStandard, StreamTemplateDirectory } from '../../api';
|
||||
|
||||
export function TemplateCard() {
|
||||
const currentProject = useCurrentProject();
|
||||
const { isParsingTemplate, hasNewTemplatePending, deliveryStandardContent, setParsingTemplate, setNewTemplatePending, setDeliveryStandardContent, setEditingOutline } = useUIStore();
|
||||
const setProjects = useAppStore(s => s.setProjects);
|
||||
const pendingMarkdown = deliveryStandardContent;
|
||||
|
||||
const startTemplateParse = async () => {
|
||||
if (!pendingMarkdown) return;
|
||||
|
||||
try {
|
||||
setParsingTemplate(true);
|
||||
setNewTemplatePending(false);
|
||||
|
||||
const activeModelId = useAppStore.getState().activeModelId;
|
||||
if (!activeModelId) {
|
||||
alert("提示:请先在配置中心连接一个有效的大语言模型提供商,否则无法进行文件解析!");
|
||||
setParsingTemplate(false);
|
||||
setNewTemplatePending(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const cs = useChatStore.getState();
|
||||
const userMsgId = Date.now();
|
||||
const assistantMsgId = userMsgId + 1;
|
||||
|
||||
cs.addMessage({ id: userMsgId, role: 'user', content: '我上传了一份工程交付文档。请帮我深度解析并归纳出标准大纲结构。' });
|
||||
cs.addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
|
||||
|
||||
// Stream template directory via SSE
|
||||
let jsonStr = await StreamTemplateDirectory(pendingMarkdown, activeModelId, (fullText: string) => {
|
||||
const state = useChatStore.getState();
|
||||
const displayText = fullText.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
const thinkMatch = fullText.match(/<think>([\s\S]*?)(<\/think>|$)/);
|
||||
const thinking = thinkMatch ? thinkMatch[1] : undefined;
|
||||
state.updateMessage(assistantMsgId, {
|
||||
content: displayText || (thinking ? '(正在深度思考中...)' : ''),
|
||||
thinking,
|
||||
});
|
||||
}, (thinkingText: string) => {
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { thinking: thinkingText });
|
||||
});
|
||||
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { status: 'success' });
|
||||
|
||||
let jsonStrClean = jsonStr.replace(/<think>[\s\S]*?<\/think>/g, '').trim();
|
||||
jsonStrClean = jsonStrClean.replace(/```json/g, '').replace(/```/g, '').trim();
|
||||
|
||||
const startIdx = jsonStrClean.indexOf('[');
|
||||
const endIdx = jsonStrClean.lastIndexOf(']');
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
jsonStrClean = jsonStrClean.substring(startIdx, endIdx + 1);
|
||||
}
|
||||
|
||||
let parsedChapters;
|
||||
try {
|
||||
parsedChapters = JSON.parse(jsonStrClean);
|
||||
} catch (err) {
|
||||
console.error("LLM json decode failed:", jsonStrClean);
|
||||
alert("模型解析出的结果不符合要求格式,请重试!\n模型原始输出片段:" + jsonStrClean.substring(0, 100) + "...");
|
||||
setParsingTemplate(false);
|
||||
setNewTemplatePending(true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(parsedChapters)) {
|
||||
parsedChapters = parsedChapters.chapters || parsedChapters.data || [];
|
||||
if (!Array.isArray(parsedChapters)) {
|
||||
parsedChapters = [parsedChapters];
|
||||
}
|
||||
}
|
||||
|
||||
const mapChapter = (c: any, i: number, parentPrefix: string) => ({
|
||||
id: c.id || `${parentPrefix}${Date.now()}-${i}`,
|
||||
title: c.title || `第${i+1}节`,
|
||||
status: 'idle' as const,
|
||||
progress: 0,
|
||||
content: c.content || '',
|
||||
children: Array.isArray(c.children)
|
||||
? c.children.map((sub: any, j: number) => mapChapter(sub, j, `${parentPrefix}${i}-`))
|
||||
: [],
|
||||
});
|
||||
|
||||
const chapters = parsedChapters.map((c: any, i: number) => mapChapter(c, i, 'gen-'));
|
||||
const newTemplate = { name: 'AI 深层解析大纲', version: 'v1.0 (Auto)', chapters };
|
||||
|
||||
const appState = useAppStore.getState();
|
||||
if (!appState.currentProjectId || !appState.projects.find(p => p.id === appState.currentProjectId)) {
|
||||
const newProjectId = await appState.createProject('默认工程');
|
||||
if (newProjectId) {
|
||||
useAppStore.getState().setProjects(prev => prev.map(p => {
|
||||
if (p.id !== newProjectId) return p;
|
||||
return { ...p, activeTemplate: newTemplate };
|
||||
}));
|
||||
}
|
||||
} else {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== appState.currentProjectId) return p;
|
||||
return { ...p, activeTemplate: newTemplate };
|
||||
}));
|
||||
}
|
||||
|
||||
setParsingTemplate(false);
|
||||
setDeliveryStandardContent('');
|
||||
setEditingOutline(true);
|
||||
|
||||
await useAppStore.getState().saveTemplateChapters();
|
||||
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
alert("模型解析遇到错误:" + (err.message || err));
|
||||
setParsingTemplate(false);
|
||||
setNewTemplatePending(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadClick = async () => {
|
||||
if (currentProject.activeTemplate.chapters && currentProject.activeTemplate.chapters.length > 0) {
|
||||
if (!window.confirm("当前已经存在解析好的交付标准目录,确定要重新上传并替换吗?已有的内容和结构将会被覆盖。")) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const markdownContent = await ParseDeliveryStandard();
|
||||
if (markdownContent) {
|
||||
setDeliveryStandardContent(markdownContent);
|
||||
setNewTemplatePending(true);
|
||||
SaveDeliveryStandard('delivery-standard', markdownContent).catch(console.error);
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[TemplateCard] ParseDeliveryStandard error:', error);
|
||||
alert("读取或转换文件内容失败: " + (error.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 mx-2 my-4 mt-auto rounded-[14px] bg-[var(--color-surface-hover)]">
|
||||
<div className="flex items-center justify-between mb-3 text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-[0.15em] px-1">
|
||||
<div className="flex items-center gap-2"><Shield size={12} className="text-[var(--color-text-primary)]" /> 交付标准</div>
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
className="p-1 hover:bg-[var(--color-surface-active)] rounded-md text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"
|
||||
title="上传交付标准"
|
||||
>
|
||||
<UploadCloud size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`relative p-3 rounded-xl border transition-all duration-300 bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] ${
|
||||
isParsingTemplate ? 'border-[var(--color-accent-primary)] animate-subtle-pulse ring-1 ring-[var(--color-accent-glow)]' : ''
|
||||
}`}>
|
||||
{isParsingTemplate ? (
|
||||
<div className="flex flex-col items-center py-1 gap-2">
|
||||
<Loader2 size={16} className="text-[var(--color-accent-primary)] animate-spin" />
|
||||
<span className="text-[10px] font-medium text-[var(--color-accent-primary)]">AI 解析目录中...</span>
|
||||
</div>
|
||||
) : hasNewTemplatePending ? (
|
||||
<button
|
||||
onClick={startTemplateParse}
|
||||
className="w-full py-1.5 bg-[var(--color-accent-primary)] text-white rounded-lg text-[11px] font-medium transition-all hover:bg-[var(--color-accent-primary-hover)] shadow-sm active:scale-[0.98]"
|
||||
>
|
||||
开始大模型解析目录
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<FileCheck size={16} className="text-[var(--color-text-tertiary)] mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-medium text-[var(--color-text-primary)] truncate text-left">{currentProject.activeTemplate.name}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono text-[var(--color-text-tertiary)]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[var(--color-accent-primary)]" />
|
||||
{currentProject.activeTemplate.version}
|
||||
</span>
|
||||
<RefreshCw size={10} className="text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { ProjectSelector } from './ProjectSelector';
|
||||
import { FileTree } from './FileTree';
|
||||
import { TemplateCard } from './TemplateCard';
|
||||
import { PanelLeftClose, PanelLeftOpen } from 'lucide-react';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function LeftSidebar() {
|
||||
const { isLeftSidebarCollapsed, toggleLeftSidebar } = useUIStore();
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
'shrink-0 border-r border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] flex flex-col z-30 relative overflow-hidden transition-[width] duration-300',
|
||||
isLeftSidebarCollapsed ? 'w-[72px]' : 'w-[280px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3">
|
||||
<div className={isLeftSidebarCollapsed ? 'opacity-0 pointer-events-none select-none' : 'opacity-100'}>
|
||||
<div className="text-[11px] font-medium tracking-wide text-[var(--color-text-tertiary)] uppercase">
|
||||
工作区
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleLeftSidebar}
|
||||
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-active)] transition-colors"
|
||||
title={isLeftSidebarCollapsed ? '展开左侧栏' : '折叠左侧栏'}
|
||||
>
|
||||
{isLeftSidebarCollapsed ? <PanelLeftOpen size={18} /> : <PanelLeftClose size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className={isLeftSidebarCollapsed ? 'opacity-0 pointer-events-none select-none' : 'opacity-100'}>
|
||||
<div className="px-4 pb-4">
|
||||
<ProjectSelector />
|
||||
</div>
|
||||
<FileTree />
|
||||
<TemplateCard />
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { X, RefreshCw, CheckCircle2, Loader2, FileSpreadsheet } from 'lucide-react';
|
||||
import { PreParseMaterial, IngestMaterial, type PreParseResult } from '../../api';
|
||||
|
||||
interface ExcelPreviewModalProps {
|
||||
fileId: string;
|
||||
fileName: string;
|
||||
onClose: () => void;
|
||||
onIngested: () => void;
|
||||
}
|
||||
|
||||
export function ExcelPreviewModal({ fileId, fileName, onClose, onIngested }: ExcelPreviewModalProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [ingesting, setIngesting] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [result, setResult] = useState<PreParseResult | null>(null);
|
||||
const [startRow, setStartRow] = useState<number>(2);
|
||||
|
||||
const fetchPreview = useCallback(async (row?: number) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data = await PreParseMaterial(fileId, row);
|
||||
setResult(data);
|
||||
if (!row) {
|
||||
setStartRow(data.suggested_start_row);
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e.message || '预解析失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [fileId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPreview();
|
||||
}, [fetchPreview]);
|
||||
|
||||
const handleRefresh = () => {
|
||||
fetchPreview(startRow);
|
||||
};
|
||||
|
||||
const handleIngest = async () => {
|
||||
setIngesting(true);
|
||||
setError('');
|
||||
try {
|
||||
await IngestMaterial(fileId, startRow);
|
||||
onIngested();
|
||||
onClose();
|
||||
} catch (e: any) {
|
||||
setError(e.message || '入库失败');
|
||||
} finally {
|
||||
setIngesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="bg-[var(--color-surface-main)] rounded-2xl shadow-2xl w-[600px] max-h-[80vh] flex flex-col border border-[var(--color-border-subtle)]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--color-border-subtle)]">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileSpreadsheet size={20} className="text-emerald-500" />
|
||||
<div>
|
||||
<h2 className="text-[15px] font-semibold text-[var(--color-text-primary)]">Excel 解析预览</h2>
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-0.5">{fileName}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1.5 rounded-lg hover:bg-[var(--color-surface-hover)] text-[var(--color-text-muted)] transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Body */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-5 custom-scrollbar">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12 gap-3 text-[var(--color-text-tertiary)]">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
<span className="text-[13px]">正在分析表格结构...</span>
|
||||
</div>
|
||||
) : error && !result ? (
|
||||
<div className="text-center py-12 text-[var(--color-danger)] text-[13px]">{error}</div>
|
||||
) : result ? (
|
||||
<>
|
||||
{/* Row info */}
|
||||
<div className="flex items-center gap-4 text-[13px]">
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
总行数: <strong>{result.total_rows}</strong>
|
||||
</span>
|
||||
<span className="text-[var(--color-text-secondary)]">
|
||||
建议数据起始行: <strong>{result.suggested_start_row}</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Start row input */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-[13px] text-[var(--color-text-secondary)] whitespace-nowrap">数据起始行:</label>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={result.total_rows}
|
||||
value={startRow}
|
||||
onChange={(e) => setStartRow(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-20 px-3 py-1.5 text-[13px] rounded-lg border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] text-[var(--color-text-primary)] focus:outline-none focus:ring-1 focus:ring-[var(--color-accent-primary)]"
|
||||
/>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={loading}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-[12px] rounded-lg bg-[var(--color-surface-side)] hover:bg-[var(--color-surface-active)] text-[var(--color-text-secondary)] transition-colors border border-[var(--color-border-subtle)]"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
刷新预览
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Sheets */}
|
||||
{result.sheets.map((sheet, idx) => (
|
||||
<div key={idx} className="space-y-3">
|
||||
<div className="text-[12px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">
|
||||
工作表: {sheet.name}
|
||||
</div>
|
||||
|
||||
{/* Headers preview */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] text-[var(--color-text-muted)]">识别出的列路径:</div>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{sheet.headers.filter(h => h).map((h, i) => (
|
||||
<span key={i} className="px-2 py-0.5 text-[11px] rounded-md bg-[var(--color-surface-side)] text-[var(--color-text-secondary)] border border-[var(--color-border-subtle)]">
|
||||
{h}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview sentences */}
|
||||
<div className="space-y-1">
|
||||
<div className="text-[11px] text-[var(--color-text-muted)]">语义化预览 (前 3 行):</div>
|
||||
<div className="space-y-2">
|
||||
{sheet.preview_sentences.map((s, i) => (
|
||||
<div key={i} className="text-[12px] text-[var(--color-text-secondary)] bg-[var(--color-surface-side)] rounded-lg px-3 py-2 border border-[var(--color-border-subtle)] leading-relaxed">
|
||||
{s}
|
||||
</div>
|
||||
))}
|
||||
{sheet.preview_sentences.length === 0 && (
|
||||
<div className="text-[12px] text-[var(--color-text-muted)] italic">无数据行</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{error && (
|
||||
<div className="text-[12px] text-[var(--color-danger)] bg-red-50 dark:bg-red-950/20 rounded-lg px-3 py-2">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-[var(--color-border-subtle)]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-[13px] rounded-lg text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
onClick={handleIngest}
|
||||
disabled={ingesting || loading || !result}
|
||||
className="flex items-center gap-2 px-5 py-2 text-[13px] font-medium rounded-lg bg-[var(--color-accent-primary)] text-white hover:opacity-90 transition-opacity disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{ingesting ? (
|
||||
<>
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
入库中...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<CheckCircle2 size={14} />
|
||||
确认解析并入库
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { X, Table } from 'lucide-react';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function InsightPopup() {
|
||||
const { insightData, setInsightData } = useUIStore();
|
||||
if (!insightData) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-[150] flex items-center justify-center p-8 bg-[var(--color-surface-overlay)] backdrop-blur-sm animate-fade-in">
|
||||
<div className="bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] rounded-3xl shadow-2xl w-full max-w-xl overflow-hidden flex flex-col animate-zoom-in">
|
||||
<div className="px-6 py-5 border-b border-[var(--color-border-subtle)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-[var(--color-accent-glow)] flex items-center justify-center text-[var(--color-accent-primary)]"><Table size={16} /></div>
|
||||
<h4 className="text-[14px] font-medium text-[var(--color-text-primary)]">{insightData.title}</h4>
|
||||
</div>
|
||||
<button onClick={() => setInsightData(null)} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<div className="p-6 text-[13px] text-[var(--color-text-secondary)] leading-relaxed whitespace-pre-wrap bg-[var(--color-surface-side)] m-4 rounded-xl border border-[var(--color-border-subtle)] font-mono max-h-[400px] overflow-y-auto custom-scrollbar">
|
||||
{insightData.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useState } from 'react';
|
||||
import { X, Briefcase } from 'lucide-react';
|
||||
import { useAppStore } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
|
||||
export function NewProjectModal() {
|
||||
const { isNewProjectModalOpen, setNewProjectModalOpen, setEditingOutline } = useUIStore();
|
||||
const createProject = useAppStore(s => s.createProject);
|
||||
const resetChat = useChatStore(s => s.resetChat);
|
||||
const closeViewers = useUIStore(s => s.closeViewers);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (!isNewProjectModalOpen) return null;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const newId = await createProject(name.trim());
|
||||
if (newId) {
|
||||
setName('');
|
||||
setNewProjectModalOpen(false);
|
||||
resetChat();
|
||||
closeViewers();
|
||||
setEditingOutline(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-[var(--color-surface-overlay)] backdrop-blur-sm z-[150] flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl flex flex-col animate-zoom-in">
|
||||
<div className="px-8 py-6 border-b border-[var(--color-border-subtle)] flex items-center justify-between">
|
||||
<div className="flex items-center gap-3 text-[var(--color-accent-primary)] font-medium text-sm">
|
||||
<div className="w-9 h-9 rounded-xl bg-[var(--color-accent-glow)] flex items-center justify-center">
|
||||
<Briefcase size={18} />
|
||||
</div>
|
||||
工程初始化
|
||||
</div>
|
||||
<button onClick={() => setNewProjectModalOpen(false)} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"><X size={20} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-8 space-y-6 text-left">
|
||||
<div className="space-y-2">
|
||||
<label className="text-[11px] text-[var(--color-text-tertiary)] font-medium uppercase tracking-wider ml-1">项目全称</label>
|
||||
<input
|
||||
autoFocus required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入工程项目名称..."
|
||||
className="w-full bg-[var(--color-surface-side)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-2 focus:ring-[var(--color-accent-glow)] rounded-xl px-4 py-3 text-sm text-[var(--color-text-primary)] outline-none transition-all placeholder:text-[var(--color-text-muted)]"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button type="button" onClick={() => setNewProjectModalOpen(false)} className="flex-1 py-3 bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-active)] rounded-xl font-medium text-[13px] transition-all">取消</button>
|
||||
<button type="submit" className="flex-1 py-3 bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)] rounded-xl font-medium text-[13px] shadow-sm transition-all active:scale-[0.98]">立即创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, BookOpen, Edit3, Save, Loader2 } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { GetMaterialContent, type MaterialContentResult } from '../../api';
|
||||
|
||||
function ExcelTable({ sheets }: { sheets: NonNullable<MaterialContentResult['sheets']> }) {
|
||||
const [activeSheet, setActiveSheet] = useState(0);
|
||||
const sheet = sheets[activeSheet];
|
||||
if (!sheet) return null;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{sheets.length > 1 && (
|
||||
<div className="flex gap-1 mb-3 flex-wrap">
|
||||
{sheets.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => setActiveSheet(i)}
|
||||
className={`px-3 py-1.5 text-[11px] rounded-md transition-colors ${
|
||||
i === activeSheet
|
||||
? 'bg-[var(--color-accent-primary)] text-white'
|
||||
: 'bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-active)]'
|
||||
}`}
|
||||
>
|
||||
{s.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="overflow-auto flex-1 border border-[var(--color-border-subtle)] rounded-lg">
|
||||
<table className="border-collapse text-[12px] min-w-full">
|
||||
<thead className="sticky top-0 z-10">
|
||||
{sheet.rows.length > 0 && (
|
||||
<tr>
|
||||
<th className="px-2 py-1.5 bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] text-[var(--color-text-muted)] font-normal text-[10px] text-center w-8">#</th>
|
||||
{sheet.rows[0].map((_, ci) => (
|
||||
<th key={ci} className="px-3 py-1.5 bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] text-[var(--color-text-primary)] font-medium text-left whitespace-nowrap">
|
||||
{sheet.rows[0][ci]}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody>
|
||||
{sheet.rows.slice(1).map((row, ri) => (
|
||||
<tr key={ri} className="hover:bg-[var(--color-surface-hover)] transition-colors">
|
||||
<td className="px-2 py-1 border border-[var(--color-border-subtle)] text-[var(--color-text-muted)] text-[10px] text-center bg-[var(--color-surface-side)]">{ri + 2}</td>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} className="px-3 py-1 border border-[var(--color-border-subtle)] text-[var(--color-text-secondary)] whitespace-nowrap max-w-[300px] truncate" title={cell}>
|
||||
{cell}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
共 {sheet.rows.length} 行 × {sheet.rows[0]?.length ?? 0} 列
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SlideOverViewer() {
|
||||
const { previewSource, previewChapter, closeViewers } = useUIStore();
|
||||
const [content, setContent] = useState<MaterialContentResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewSource) {
|
||||
setContent(null);
|
||||
setError('');
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setContent(null);
|
||||
GetMaterialContent(previewSource.id)
|
||||
.then(res => { if (!cancelled) setContent(res); })
|
||||
.catch(err => { if (!cancelled) setError(err?.message || '加载失败'); })
|
||||
.finally(() => { if (!cancelled) setLoading(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [previewSource?.id]);
|
||||
|
||||
if (!previewSource && !previewChapter) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[750px] bg-[var(--color-surface-main)] border-l border-[var(--color-border-subtle)] z-[100] shadow-[-20px_0_50px_rgba(0,0,0,0.08)] animate-slide-in-right flex flex-col">
|
||||
<header className="p-5 border-b border-[var(--color-border-subtle)] flex items-center justify-between bg-[var(--color-surface-side)] sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3 overflow-hidden min-w-0">
|
||||
<div className={`w-10 h-10 rounded-xl flex items-center justify-center shrink-0 border ${
|
||||
previewSource ? 'bg-[var(--color-accent-glow)] border-[var(--color-accent-primary)]/20 text-[var(--color-accent-primary)]' : 'bg-[var(--color-success-bg)] border-[var(--color-success-border)] text-[var(--color-success)]'
|
||||
}`}>
|
||||
{previewSource ? <BookOpen size={20} /> : <Edit3 size={20} />}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<h2 className="text-[14px] font-medium text-[var(--color-text-primary)] truncate">
|
||||
{previewSource ? previewSource.name : previewChapter?.title}
|
||||
</h2>
|
||||
<p className="text-[11px] text-[var(--color-text-tertiary)] mt-0.5">
|
||||
{previewSource ? '素材在线预览' : '成果编辑视图'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={closeViewers} className="p-2 hover:bg-[var(--color-surface-hover)] rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-auto p-6 custom-scrollbar text-[var(--color-text-secondary)] leading-relaxed text-sm text-left">
|
||||
{previewSource ? (
|
||||
loading ? (
|
||||
<div className="flex items-center justify-center h-40 gap-2 text-[var(--color-text-muted)]">
|
||||
<Loader2 size={18} className="animate-spin" />
|
||||
<span className="text-[13px]">正在加载文件内容…</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center text-[var(--color-danger)] py-10 text-[13px]">{error}</div>
|
||||
) : content?.type === 'excel' && content.sheets ? (
|
||||
<ExcelTable sheets={content.sheets} />
|
||||
) : content?.content ? (
|
||||
<div className="prose prose-sm max-w-none dark:prose-invert prose-headings:text-[var(--color-text-primary)] prose-p:text-[var(--color-text-secondary)] prose-td:text-[var(--color-text-secondary)] prose-th:text-[var(--color-text-primary)]">
|
||||
<ReactMarkdown>{content.content}</ReactMarkdown>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center text-[var(--color-text-muted)] py-10 text-[13px]">暂无内容</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre-wrap leading-7 bg-[var(--color-surface-side)] border border-[var(--color-border-subtle)] p-8 rounded-2xl outline-none focus-within:ring-2 focus-within:ring-[var(--color-accent-glow)] focus-within:border-[var(--color-accent-primary)] transition-all text-left"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{previewChapter?.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="p-6 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-side)]">
|
||||
{previewChapter ? (
|
||||
<button onClick={closeViewers} className="w-full py-3 bg-[var(--color-success)] text-white hover:brightness-110 rounded-xl font-medium text-[13px] shadow-sm transition-all active:scale-[0.98] flex items-center justify-center gap-2">
|
||||
<Save size={15} /> 保存章节并应用
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={closeViewers} className="w-full py-3 bg-[var(--color-surface-hover)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-active)] rounded-xl font-medium text-[13px] transition-all active:scale-[0.98]">
|
||||
关闭预览
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import { Hammer, Settings2, Loader2, RefreshCw, Maximize2, Trash2, Plus, Sparkles, ChevronDown, ChevronRight, PanelRightClose, PanelRightOpen } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import type { TemplateChapter } from '../../types';
|
||||
|
||||
interface ChapterNodeProps {
|
||||
chap: TemplateChapter;
|
||||
depth: number;
|
||||
isEditingOutline: boolean;
|
||||
previewChapter: TemplateChapter | null;
|
||||
selectedFileIds: Set<string>;
|
||||
updateChapterTitle: (id: string, title: string) => void;
|
||||
deleteChapter: (id: string) => void;
|
||||
triggerGeneration: (e: React.MouseEvent, id: string, isRegen: boolean) => void;
|
||||
setPreviewChapter: (c: TemplateChapter | null) => void;
|
||||
setPreviewSource: (v: any) => void;
|
||||
}
|
||||
|
||||
function ChapterNode({ chap, depth, isEditingOutline, previewChapter, selectedFileIds, updateChapterTitle, deleteChapter, triggerGeneration, setPreviewChapter, setPreviewSource }: ChapterNodeProps) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
const hasChildren = chap.children && chap.children.length > 0;
|
||||
const isSubSection = depth > 0;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={`absolute -left-6 ${isSubSection ? 'top-5' : 'top-8'} w-4 h-4 rounded-full flex items-center justify-center bg-[var(--color-surface-main)] border-[3px] border-[var(--color-surface-main)] z-10`}>
|
||||
<div className={`${isSubSection ? 'w-1.5 h-1.5' : 'w-2 h-2'} rounded-full ${
|
||||
chap.status === 'done' ? 'bg-[var(--color-success)]'
|
||||
: chap.status === 'loading' ? 'bg-[var(--color-accent-primary)] animate-pulse' : 'bg-[var(--color-border-strong)]'
|
||||
}`} />
|
||||
</div>
|
||||
<div
|
||||
onClick={() => { if (!isEditingOutline && chap.status === 'done') { setPreviewChapter(chap); setPreviewSource(null); } }}
|
||||
className={`group ${isSubSection ? 'p-3' : 'p-5'} rounded-2xl border transition-all duration-300 ${
|
||||
isEditingOutline ? 'bg-[var(--color-surface-side)] border-[var(--color-border-subtle)]'
|
||||
: previewChapter?.id === chap.id ? 'bg-[var(--color-surface-hover)] border-[var(--color-border-strong)] shadow-sm'
|
||||
: chap.status === 'done' ? 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)] hover:border-[var(--color-border-strong)] hover:shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] cursor-pointer'
|
||||
: 'bg-[var(--color-surface-main)] border-[var(--color-border-subtle)]'
|
||||
}`}
|
||||
>
|
||||
<div className={`flex items-center justify-between ${hasChildren || !isSubSection ? 'mb-3' : 'mb-0'} min-w-0`}>
|
||||
{hasChildren && !isEditingOutline && (
|
||||
<button onClick={(e) => { e.stopPropagation(); setExpanded(v => !v); }} className="mr-1.5 text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0">
|
||||
{expanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
)}
|
||||
{isEditingOutline ? (
|
||||
<input
|
||||
value={chap.title}
|
||||
onChange={(e) => updateChapterTitle(chap.id, e.target.value)}
|
||||
className={`bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] focus:border-[var(--color-border-strong)] focus:shadow-sm rounded-lg p-2 text-[var(--color-text-primary)] w-full outline-none ${isSubSection ? 'text-[12px]' : 'text-[13px]'} font-sans transition-all`}
|
||||
/>
|
||||
) : (
|
||||
<h4 className={`${isSubSection ? 'text-[12px]' : 'text-[13px]'} font-medium text-[var(--color-text-secondary)] group-hover:text-[var(--color-text-primary)] transition-colors truncate text-left`}>{chap.title}</h4>
|
||||
)}
|
||||
{!isEditingOutline && chap.status === 'done' && <Maximize2 size={12} className="text-[var(--color-text-muted)] opacity-0 group-hover:opacity-100 transition-all shrink-0 ml-2 group-hover:text-[var(--color-accent-primary)]" />}
|
||||
{isEditingOutline && <button onClick={() => deleteChapter(chap.id)} className="text-[var(--color-text-muted)] hover:text-[var(--color-danger)] transition-colors shrink-0 ml-2"><Trash2 size={14} /></button>}
|
||||
</div>
|
||||
{!isEditingOutline && !isSubSection && (
|
||||
<button
|
||||
onClick={(e) => triggerGeneration(e, chap.id, chap.status === 'done')}
|
||||
disabled={selectedFileIds.size === 0 || chap.status === 'loading'}
|
||||
className={`w-full py-2 rounded-xl transition-all flex items-center justify-center gap-2 border ${
|
||||
chap.status === 'done' ? 'bg-transparent border-[var(--color-border-subtle)] hover:bg-[var(--color-surface-hover)] hover:border-[var(--color-border-strong)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]'
|
||||
: selectedFileIds.size > 0 ? 'bg-transparent border-[var(--color-border-strong)] text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] shadow-sm'
|
||||
: 'bg-transparent border-[var(--color-border-subtle)] text-[var(--color-text-muted)] cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
{chap.status === 'loading' ? <Loader2 size={14} className="animate-spin text-[var(--color-accent-primary)]" />
|
||||
: chap.status === 'done' ? <RefreshCw size={14} className="text-[var(--color-text-muted)] group-hover:text-[var(--color-text-secondary)]" /> : <Sparkles size={14} />}
|
||||
<span className={`text-[12px] tracking-wide ${chap.status === 'done' ? 'font-normal' : 'font-medium'}`}>
|
||||
{chap.status === 'loading' ? '生成中' : chap.status === 'done' ? '重新生成' : '开始生成'}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{hasChildren && expanded && (
|
||||
<div className="ml-4 mt-2 space-y-2 relative">
|
||||
<div className="absolute left-[-10px] top-0 bottom-0 w-px bg-[var(--color-border-subtle)]" />
|
||||
{chap.children!.map(sub => (
|
||||
<ChapterNode
|
||||
key={sub.id}
|
||||
chap={sub}
|
||||
depth={depth + 1}
|
||||
isEditingOutline={isEditingOutline}
|
||||
previewChapter={previewChapter}
|
||||
selectedFileIds={selectedFileIds}
|
||||
updateChapterTitle={updateChapterTitle}
|
||||
deleteChapter={deleteChapter}
|
||||
triggerGeneration={triggerGeneration}
|
||||
setPreviewChapter={setPreviewChapter}
|
||||
setPreviewSource={setPreviewSource}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RightSidebar() {
|
||||
const currentProject = useCurrentProject();
|
||||
const currentProjectId = useAppStore(s => s.currentProjectId);
|
||||
const setProjects = useAppStore(s => s.setProjects);
|
||||
const { isEditingOutline, setEditingOutline, previewChapter, setPreviewChapter, setPreviewSource, isRightSidebarCollapsed, toggleRightSidebar } = useUIStore();
|
||||
const { selectedFileIds, addMessage, updateMessage } = useChatStore();
|
||||
const saveTemplateChapters = useAppStore(s => s.saveTemplateChapters);
|
||||
|
||||
const updateChapterTitle = (id: string, newTitle: string) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
const updateChapters = (chapters: TemplateChapter[]): TemplateChapter[] =>
|
||||
chapters.map(c => c.id === id ? { ...c, title: newTitle } : { ...c, children: c.children ? updateChapters(c.children) : undefined });
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: updateChapters(p.activeTemplate.chapters) } };
|
||||
}));
|
||||
setTimeout(() => saveTemplateChapters(), 0);
|
||||
};
|
||||
|
||||
const deleteChapter = (id: string) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
const filterChapters = (chapters: TemplateChapter[]): TemplateChapter[] =>
|
||||
chapters.filter(c => c.id !== id).map(c => ({ ...c, children: c.children ? filterChapters(c.children) : undefined }));
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: filterChapters(p.activeTemplate.chapters) } };
|
||||
}));
|
||||
setTimeout(() => saveTemplateChapters(), 0);
|
||||
};
|
||||
|
||||
const triggerGeneration = (e: React.MouseEvent, chapterId: string, isRegenerate = false) => {
|
||||
e.stopPropagation();
|
||||
if (selectedFileIds.size === 0) return;
|
||||
const chapter = currentProject.activeTemplate.chapters.find(c => c.id === chapterId);
|
||||
if (!chapter) return;
|
||||
const logId = Date.now();
|
||||
|
||||
addMessage({
|
||||
id: logId, role: 'assistant', content: '', type: 'generation-log',
|
||||
chapterTitle: chapter.title, isRegenerate, status: 'processing',
|
||||
steps: ['建立 RAG 安全链路...'],
|
||||
});
|
||||
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === chapterId ? { ...c, status: 'loading', progress: 15 } : c) } };
|
||||
}));
|
||||
|
||||
// TODO: Call backend streaming generation endpoint
|
||||
setTimeout(() => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === chapterId ? { ...c, status: 'idle', progress: 0 } : c) } };
|
||||
}));
|
||||
updateMessage(logId, { status: 'success' });
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
const addChapter = () => {
|
||||
const id = `c-${Date.now()}`;
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: [...p.activeTemplate.chapters, { id, title: '新章节', status: 'idle' as const, progress: 0, content: '' }] } };
|
||||
}));
|
||||
setTimeout(() => saveTemplateChapters(), 0);
|
||||
};
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={[
|
||||
'shrink-0 border-l border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] flex flex-col z-20 overflow-hidden relative transition-[width] duration-300',
|
||||
isRightSidebarCollapsed ? 'w-[72px]' : 'w-[320px]',
|
||||
].join(' ')}
|
||||
>
|
||||
<div className="h-14 flex items-center justify-between px-3 border-b border-[var(--color-border-subtle)]">
|
||||
<div className={isRightSidebarCollapsed ? 'opacity-0 pointer-events-none select-none' : 'opacity-100'}>
|
||||
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.15em] text-[var(--color-text-tertiary)]">
|
||||
<Hammer size={16} className="text-[var(--color-text-muted)]" /> 质量大纲
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{!isRightSidebarCollapsed && (
|
||||
<button
|
||||
onClick={() => setEditingOutline(!isEditingOutline)}
|
||||
className={`p-2 rounded-lg transition-all border ${isEditingOutline ? 'bg-[var(--color-surface-hover)] border-[var(--color-border-subtle)] text-[var(--color-text-primary)] shadow-sm' : 'border-transparent hover:bg-[var(--color-surface-hover)] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-subtle)]'}`}
|
||||
title="编辑大纲"
|
||||
>
|
||||
<Settings2 size={16} />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={toggleRightSidebar}
|
||||
className="p-2 rounded-lg text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:bg-[var(--color-surface-hover)] transition-colors"
|
||||
title={isRightSidebarCollapsed ? '展开右侧栏' : '折叠右侧栏'}
|
||||
>
|
||||
{isRightSidebarCollapsed ? <PanelRightOpen size={18} /> : <PanelRightClose size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={isRightSidebarCollapsed ? 'opacity-0 pointer-events-none select-none' : 'opacity-100 flex-1 flex flex-col'}>
|
||||
<div className="flex-1 overflow-y-auto custom-scrollbar relative pl-6 pr-2 py-2 w-full">
|
||||
<div className="absolute left-[13px] top-6 bottom-6 w-px bg-[var(--color-border-subtle)]" />
|
||||
<div className="space-y-6">
|
||||
{currentProject.activeTemplate.chapters.map(chap => (
|
||||
<ChapterNode
|
||||
key={chap.id}
|
||||
chap={chap}
|
||||
depth={0}
|
||||
isEditingOutline={isEditingOutline}
|
||||
previewChapter={previewChapter}
|
||||
selectedFileIds={selectedFileIds}
|
||||
updateChapterTitle={updateChapterTitle}
|
||||
deleteChapter={deleteChapter}
|
||||
triggerGeneration={triggerGeneration}
|
||||
setPreviewChapter={setPreviewChapter}
|
||||
setPreviewSource={setPreviewSource}
|
||||
/>
|
||||
))}
|
||||
{isEditingOutline && (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={addChapter}
|
||||
className="w-full py-4 border-2 border-dashed border-[var(--color-border-subtle)] rounded-2xl text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] hover:border-[var(--color-border-strong)] hover:bg-[var(--color-surface-hover)] transition-all text-[12px] font-medium flex items-center justify-center gap-2 animate-fade-in"
|
||||
>
|
||||
<Plus size={16} /> 添加章节
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-6 py-6 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] text-center shrink-0">
|
||||
<button className="w-full h-12 bg-[var(--color-surface-active)] hover:bg-[var(--color-border-strong)] text-[var(--color-text-primary)] font-medium rounded-xl shadow-sm active:scale-[0.98] transition-all duration-300 text-[13px] flex items-center justify-center gap-2 tracking-wide whitespace-nowrap">
|
||||
完成工作流
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
import { X, Cpu, Database, Plus, ToggleRight, ToggleLeft, Trash2, HardDrive, Cloud, Server, Globe, Key, Wifi, Loader2, Boxes, Settings } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { TestVectorDBConnection, TestLLMConnection, TestEmbeddingConnection } from '../../api';
|
||||
|
||||
export function SettingsModal() {
|
||||
const { isSettingsOpen, setSettingsOpen, activeSettingsTab, setActiveSettingsTab, isTestingConnection, setTestingConnection } = useUIStore();
|
||||
const { modelConfigs, vectorDB, embeddingConfig, addModel, deleteModel, updateModel, setVectorDB, setEmbeddingConfig } = useAppStore();
|
||||
|
||||
if (!isSettingsOpen) return null;
|
||||
|
||||
const handleTestConnection = async () => {
|
||||
setTestingConnection(true);
|
||||
try {
|
||||
const ok = await TestVectorDBConnection(vectorDB.endpoint);
|
||||
if (ok) {
|
||||
setVectorDB({ status: 'connected' });
|
||||
} else {
|
||||
setVectorDB({ status: 'disconnected' });
|
||||
alert("连接失败");
|
||||
}
|
||||
} catch (err: any) {
|
||||
setVectorDB({ status: 'disconnected' });
|
||||
alert("连接 Qdrant 出错: " + err);
|
||||
} finally {
|
||||
setTestingConnection(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-white/60 backdrop-blur-sm z-[200] flex items-center justify-center p-4 animate-fade-in">
|
||||
<div className="bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] rounded-3xl w-full max-w-4xl h-[85vh] overflow-hidden shadow-2xl flex flex-col animate-zoom-in">
|
||||
<div className="flex items-center justify-between px-8 py-6 border-b border-[var(--color-border-subtle)] bg-transparent">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-10 h-10 rounded-xl bg-[var(--color-surface-hover)] border border-[var(--color-border-subtle)] flex items-center justify-center text-[var(--color-text-secondary)] shadow-sm">
|
||||
<Settings size={20} />
|
||||
</div>
|
||||
<h2 className="text-xl font-serif text-[var(--color-text-primary)] tracking-tight text-left">配置中心</h2>
|
||||
</div>
|
||||
<button onClick={() => setSettingsOpen(false)} className="text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)] transition-colors"><X size={24} /></button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
<div className="w-56 shrink-0 border-r border-[var(--color-border-subtle)] p-4 space-y-1 bg-[var(--color-surface-side)] text-left">
|
||||
<button
|
||||
onClick={() => setActiveSettingsTab('models')}
|
||||
className={`w-full whitespace-nowrap flex items-center gap-3 px-4 py-2.5 rounded-xl text-[13px] font-medium transition-all ${
|
||||
activeSettingsTab === 'models' ? 'bg-[var(--color-surface-active)] text-[var(--color-accent-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<Cpu size={16} /> 模型提供商
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSettingsTab('vector')}
|
||||
className={`w-full whitespace-nowrap flex items-center gap-3 px-4 py-2.5 rounded-xl text-[13px] font-medium transition-all ${
|
||||
activeSettingsTab === 'vector' ? 'bg-[var(--color-surface-active)] text-[var(--color-accent-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<Database size={16} /> 知识库
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveSettingsTab('embedding')}
|
||||
className={`w-full whitespace-nowrap flex items-center gap-3 px-4 py-2.5 rounded-xl text-[13px] font-medium transition-all ${
|
||||
activeSettingsTab === 'embedding' ? 'bg-[var(--color-surface-active)] text-[var(--color-accent-primary)]' : 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)]'
|
||||
}`}
|
||||
>
|
||||
<Boxes size={16} /> 向量模型
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-8 bg-[var(--color-surface-main)] space-y-8 text-left">
|
||||
{activeSettingsTab === 'models' ? (
|
||||
<ModelsTab configs={modelConfigs} onAdd={addModel} onDelete={deleteModel} onUpdate={updateModel} />
|
||||
) : activeSettingsTab === 'vector' ? (
|
||||
<VectorTab vectorDB={vectorDB} setVectorDB={setVectorDB} isTestingConnection={isTestingConnection} onTestConnection={handleTestConnection} />
|
||||
) : (
|
||||
<EmbeddingTab config={embeddingConfig} onChange={setEmbeddingConfig} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-8 py-5 border-t border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] flex justify-end">
|
||||
<button
|
||||
onClick={() => setSettingsOpen(false)}
|
||||
className="px-6 py-2 whitespace-nowrap bg-[var(--color-accent-primary)] text-white hover:bg-[var(--color-accent-primary-hover)] rounded-lg font-medium text-[13px] shadow-sm transition-all"
|
||||
>
|
||||
保存并关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Sub-components ---------- */
|
||||
|
||||
function ModelsTab({ configs, onAdd, onDelete, onUpdate }: {
|
||||
configs: ReturnType<typeof useAppStore.getState>['modelConfigs'];
|
||||
onAdd: () => void; onDelete: (id: string) => void;
|
||||
onUpdate: (id: string, field: string, value: string | boolean) => void;
|
||||
}) {
|
||||
const [testingId, setTestingId] = useState<string | null>(null);
|
||||
const [testResult, setTestResult] = useState<{id: string, ok: boolean, msg: string} | null>(null);
|
||||
|
||||
const handleTestLLM = async (cfg: typeof configs[0]) => {
|
||||
setTestingId(cfg.id);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const ok = await TestLLMConnection(cfg.provider, cfg.url, cfg.key);
|
||||
if (ok) {
|
||||
setTestResult({ id: cfg.id, ok: true, msg: '连接成功' });
|
||||
} else {
|
||||
setTestResult({ id: cfg.id, ok: false, msg: '连接失败:未授权或技术服务不可用' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTestResult({ id: cfg.id, ok: false, msg: `Error: ${err}` });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
setTimeout(() => setTestResult(prev => (prev?.id === cfg.id && prev.ok) ? null : prev), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider">已启用的提供商 ({configs.length})</span>
|
||||
<button onClick={onAdd} className="px-4 py-2 shrink-0 whitespace-nowrap bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-hover)] rounded-lg text-[12px] font-medium transition-all flex items-center gap-2 shadow-sm">
|
||||
<Plus size={14} /> 添加提供商
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid gap-4">
|
||||
{configs.map(cfg => (
|
||||
<div key={cfg.id} className={`p-6 rounded-2xl border border-[var(--color-border-subtle)] bg-[var(--color-surface-side)] shadow-sm transition-opacity ${cfg.enabled ? 'opacity-100' : 'opacity-50'}`}>
|
||||
<div className="flex items-center justify-between gap-4 mb-6">
|
||||
<div className="flex items-center gap-4 flex-1">
|
||||
<button
|
||||
onClick={() => onUpdate(cfg.id, 'provider', cfg.provider === 'Ollama' ? 'OpenAI' : 'Ollama')}
|
||||
className="w-10 h-10 shrink-0 rounded-xl bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] text-[var(--color-text-secondary)] hover:text-[var(--color-accent-primary)] flex items-center justify-center transition-all cursor-pointer shadow-sm"
|
||||
title={`Click to switch provider (Current: ${cfg.provider})`}
|
||||
>
|
||||
{cfg.provider === 'Ollama' ? <HardDrive size={20} /> : <Cloud size={20} />}
|
||||
</button>
|
||||
<input value={cfg.name} onChange={(e) => onUpdate(cfg.id, 'name', e.target.value)} placeholder="Provider Profile Name" className="flex-1 bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] rounded-lg px-3 py-2 text-base font-medium text-[var(--color-text-primary)] outline-none shadow-sm transition-all" />
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<button onClick={() => onUpdate(cfg.id, 'enabled', !cfg.enabled)}>
|
||||
{cfg.enabled ? <ToggleRight className="text-[var(--color-success)]" size={32} strokeWidth={1.5} /> : <ToggleLeft className="text-[var(--color-text-muted)]" size={32} strokeWidth={1.5} />}
|
||||
</button>
|
||||
<button onClick={() => onDelete(cfg.id)} className="p-1.5 rounded-md text-[var(--color-text-tertiary)] hover:bg-[var(--color-surface-hover)] hover:text-[var(--color-danger)] transition-colors bg-[var(--color-surface-main)] border border-[var(--color-border-subtle)] shadow-sm"><Trash2 size={16} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1 flex justify-between">
|
||||
<span>Base URL</span>
|
||||
<span className="text-[9px] opacity-70 normal-case">{cfg.provider}</span>
|
||||
</label>
|
||||
<input value={cfg.url} onChange={(e) => onUpdate(cfg.id, 'url', e.target.value)} placeholder={cfg.provider === 'Ollama' ? 'http://127.0.0.1:11434/api' : 'https://api.openai.com/v1'} className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1">API Key</label>
|
||||
<input type="password" value={cfg.key || ''} onChange={(e) => onUpdate(cfg.id, 'key', e.target.value)} placeholder={cfg.provider === 'Ollama' ? '(Optional for Ollama)' : 'sk-...'} className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="space-y-1.5 col-span-2">
|
||||
<label className="text-[10px] font-medium text-[var(--color-text-tertiary)] uppercase tracking-wider ml-1">Model Name / ID</label>
|
||||
<input value={cfg.model} onChange={(e) => onUpdate(cfg.id, 'model', e.target.value)} placeholder="e.g. gpt-4o or qwen" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between items-center mt-6 pt-4 border-t border-[var(--color-border-subtle)]">
|
||||
<span className={`text-[11px] font-medium overflow-hidden text-ellipsis whitespace-nowrap pr-4 ${
|
||||
testingId === cfg.id ? 'text-[var(--color-accent-primary)] animate-pulse' :
|
||||
testResult?.id === cfg.id ? (testResult.ok ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]') : 'text-[var(--color-text-tertiary)]'
|
||||
}`}>
|
||||
{testingId === cfg.id ? '正在连接提供商...' : testResult?.id === cfg.id ? testResult.msg : '验证配置'}
|
||||
</span>
|
||||
<button onClick={() => handleTestLLM(cfg)} disabled={testingId === cfg.id} className="shrink-0 px-5 py-2.5 whitespace-nowrap bg-[var(--color-surface-main)] text-[var(--color-text-secondary)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] hover:text-[var(--color-accent-primary)] rounded-lg text-[12px] font-medium transition-all shadow-sm flex items-center gap-2">
|
||||
{testingId === cfg.id ? <Loader2 size={14} className="animate-spin" /> : <Wifi size={14} />} 测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function VectorTab({ vectorDB, setVectorDB, isTestingConnection, onTestConnection }: {
|
||||
vectorDB: ReturnType<typeof useAppStore.getState>['vectorDB'];
|
||||
setVectorDB: (v: Partial<typeof vectorDB>) => void;
|
||||
isTestingConnection: boolean; onTestConnection: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-serif text-[var(--color-text-primary)] tracking-tight">RAG Engine — Qdrant</h3>
|
||||
<div className={`px-3 py-1 whitespace-nowrap shrink-0 rounded-full border text-[11px] font-medium flex items-center gap-1.5 ${
|
||||
vectorDB.status === 'connected' ? 'border-[var(--color-success-border)] text-[var(--color-success)] bg-[var(--color-success-bg)]' :
|
||||
vectorDB.status === 'disconnected' ? 'border-[var(--color-danger-border)] text-[var(--color-danger)] bg-[var(--color-danger-bg)]' :
|
||||
'border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-side)]'
|
||||
}`}>
|
||||
<Wifi size={12} /> {vectorDB.status === 'connected' ? '已连接' : vectorDB.status === 'disconnected' ? '未连接' : '未验证'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl border bg-[var(--color-surface-side)] border-[var(--color-border-subtle)] shadow-sm">
|
||||
<Server size={24} className="mb-3 text-[var(--color-accent-primary)]" />
|
||||
<h4 className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">远程 Qdrant 节点</h4>
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] leading-relaxed">高性能向量数据库,通过 gRPC 连接。</p>
|
||||
</div>
|
||||
|
||||
<div className="p-6 bg-[var(--color-surface-side)] rounded-2xl border border-[var(--color-border-subtle)] space-y-6 shadow-sm">
|
||||
<div className="grid grid-cols-12 gap-6">
|
||||
<div className="col-span-7 space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Globe size={12} /> Endpoint</label>
|
||||
<input value={vectorDB.endpoint} onChange={(e) => setVectorDB({ endpoint: e.target.value })} placeholder="http://127.0.0.1:6334" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="col-span-5 space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Key size={12} /> API Key</label>
|
||||
<input type="password" value={vectorDB.apiKey} onChange={(e) => setVectorDB({ apiKey: e.target.value })} placeholder="(Optional)" className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button onClick={onTestConnection} disabled={isTestingConnection} className="px-5 py-2.5 whitespace-nowrap bg-[var(--color-surface-main)] text-[var(--color-text-secondary)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] hover:text-[var(--color-accent-primary)] rounded-lg text-[12px] font-medium transition-all shadow-sm flex items-center gap-2">
|
||||
{isTestingConnection ? <Loader2 size={14} className="animate-spin" /> : <Wifi size={14} />} 测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddingTab({ config, onChange }: {
|
||||
config: ReturnType<typeof useAppStore.getState>['embeddingConfig'];
|
||||
onChange: (v: Partial<typeof config>) => Promise<void>;
|
||||
}) {
|
||||
const [testingEmb, setTestingEmb] = useState(false);
|
||||
const [testResult, setTestResult] = useState<{ ok: boolean; msg: string } | null>(null);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!config.url || !config.model) return;
|
||||
setTestingEmb(true);
|
||||
setTestResult(null);
|
||||
try {
|
||||
const ok = await TestEmbeddingConnection(config.provider || 'OpenAI', config.url, config.model, config.key);
|
||||
setTestResult(ok ? { ok: true, msg: '连接成功,向量维度正常' } : { ok: false, msg: '连接失败' });
|
||||
} catch (err: any) {
|
||||
setTestResult({ ok: false, msg: `${err}` });
|
||||
} finally {
|
||||
setTestingEmb(false);
|
||||
setTimeout(() => setTestResult(prev => prev?.ok ? null : prev), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-8 animate-fade-in">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-serif text-[var(--color-text-primary)] tracking-tight">向量 Embedding 模型</h3>
|
||||
<button
|
||||
onClick={() => onChange({ enabled: !config.enabled })}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
{config.enabled
|
||||
? <ToggleRight className="text-[var(--color-success)]" size={32} strokeWidth={1.5} />
|
||||
: <ToggleLeft className="text-[var(--color-text-muted)]" size={32} strokeWidth={1.5} />}
|
||||
<span className="text-[11px] font-medium text-[var(--color-text-tertiary)]">{config.enabled ? '已启用' : '未启用'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl border bg-[var(--color-surface-side)] border-[var(--color-border-subtle)] shadow-sm">
|
||||
<Boxes size={24} className="mb-3 text-[var(--color-accent-primary)]" />
|
||||
<h4 className="text-[14px] font-medium text-[var(--color-text-primary)] mb-1">专用向量化模型</h4>
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] leading-relaxed">
|
||||
配置专用的 Embedding 模型,用于将素材文本转换为向量并存入向量数据库。支持 OpenAI 兼容接口和 Ollama 本地模型。
|
||||
<br />未启用时,系统将使用 SQLite 关键词检索作为替代方案。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={`p-6 bg-[var(--color-surface-side)] rounded-2xl border border-[var(--color-border-subtle)] space-y-6 shadow-sm transition-opacity ${config.enabled ? 'opacity-100' : 'opacity-50 pointer-events-none'}`}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider">Provider</label>
|
||||
<div className="flex gap-2">
|
||||
{['OpenAI', 'Ollama'].map(p => (
|
||||
<button key={p} onClick={() => onChange({ provider: p })}
|
||||
className={`px-4 py-2 rounded-lg text-[12px] font-medium border transition-all ${
|
||||
config.provider === p
|
||||
? 'border-[var(--color-accent-primary)] text-[var(--color-accent-primary)] bg-[var(--color-surface-main)]'
|
||||
: 'border-[var(--color-border-strong)] text-[var(--color-text-secondary)] bg-[var(--color-surface-main)] hover:border-[var(--color-accent-primary)]'
|
||||
}`}
|
||||
>
|
||||
{p === 'Ollama' ? <><HardDrive size={12} className="inline mr-1.5" />{p}</> : <><Cloud size={12} className="inline mr-1.5" />{p}</>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Globe size={12} /> Base URL</label>
|
||||
<input value={config.url} onChange={(e) => onChange({ url: e.target.value })}
|
||||
placeholder={config.provider === 'Ollama' ? 'http://127.0.0.1:11434' : 'https://api.siliconflow.cn/v1'}
|
||||
className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider flex items-center gap-1.5"><Key size={12} /> API Key</label>
|
||||
<input type="password" value={config.key || ''} onChange={(e) => onChange({ key: e.target.value })}
|
||||
placeholder={config.provider === 'Ollama' ? '(Ollama 无需填写)' : 'sk-...'}
|
||||
className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[10px] text-[var(--color-text-tertiary)] font-medium uppercase ml-1 tracking-wider">Model Name / ID</label>
|
||||
<input value={config.model} onChange={(e) => onChange({ model: e.target.value })}
|
||||
placeholder={config.provider === 'Ollama' ? 'bge-m3' : 'BAAI/bge-m3'}
|
||||
className="w-full bg-[var(--color-surface-main)] border border-[var(--color-border-strong)] focus:border-[var(--color-accent-primary)] focus:ring-1 focus:ring-[var(--color-accent-primary)] rounded-lg px-4 py-2.5 text-[13px] font-mono text-[var(--color-text-primary)] outline-none transition-all shadow-sm placeholder:text-[var(--color-text-muted)]" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-[var(--color-border-subtle)]">
|
||||
<span className={`text-[11px] font-medium ${
|
||||
testingEmb ? 'text-[var(--color-accent-primary)] animate-pulse' :
|
||||
testResult ? (testResult.ok ? 'text-[var(--color-success)]' : 'text-[var(--color-danger)]') : 'text-[var(--color-text-tertiary)]'
|
||||
}`}>
|
||||
{testingEmb ? '正在测试连接...' : testResult ? testResult.msg : '验证 Embedding 服务可用性'}
|
||||
</span>
|
||||
<button onClick={handleTest} disabled={testingEmb}
|
||||
className="shrink-0 px-5 py-2.5 whitespace-nowrap bg-[var(--color-surface-main)] text-[var(--color-text-secondary)] border border-[var(--color-border-strong)] hover:border-[var(--color-accent-primary)] hover:text-[var(--color-accent-primary)] rounded-lg text-[12px] font-medium transition-all shadow-sm flex items-center gap-2">
|
||||
{testingEmb ? <Loader2 size={14} className="animate-spin" /> : <Wifi size={14} />} 测试连接
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-surface-main: #FFFFFF;
|
||||
--color-surface-side: #FAF9F6;
|
||||
--color-surface-hover: #F3F2EE;
|
||||
--color-surface-active: #EBE9E4;
|
||||
--color-surface-overlay: rgba(255, 255, 255, 0.7);
|
||||
|
||||
--color-border-subtle: #E5E4E0;
|
||||
--color-border-strong: #D0CECB;
|
||||
|
||||
--color-text-primary: #2D2D2D;
|
||||
--color-text-secondary: #4A4A4A;
|
||||
--color-text-tertiary: #8E8B83;
|
||||
--color-text-muted: #C2C0B8;
|
||||
|
||||
--color-accent-primary: #D97757;
|
||||
--color-accent-primary-hover: #C86444;
|
||||
|
||||
--color-accent-glow: rgba(217, 119, 87, 0.15);
|
||||
--color-success: #10B981;
|
||||
--color-success-bg: #ECFDF5;
|
||||
--color-success-border: #A7F3D0;
|
||||
--color-danger: #EF4444;
|
||||
--color-danger-bg: #FEF2F2;
|
||||
--color-danger-border: #FECACA;
|
||||
|
||||
--font-sans: "Inter", ui-sans-serif, system-ui, -apple-system, sans-serif;
|
||||
--font-serif: "Georgia", "Times New Roman", ui-serif, serif;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--color-surface-main);
|
||||
color: var(--color-text-primary);
|
||||
font-family: var(--font-sans);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection {
|
||||
background: var(--color-accent-glow);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
button, input, select, textarea {
|
||||
-webkit-appearance: none !important;
|
||||
appearance: none !important;
|
||||
background-color: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
color: inherit;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.custom-scrollbar::-webkit-scrollbar { width: 3px; height: 3px; }
|
||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border-subtle);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--color-border-strong);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes slide-up {
|
||||
from { transform: translateY(10px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
@keyframes slide-in-right {
|
||||
from { transform: translateX(100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
@keyframes fade-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes zoom-in {
|
||||
from { transform: scale(0.95); opacity: 0; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.6; }
|
||||
}
|
||||
.animate-slide-up { animation: slide-up 0.4s cubic-bezier(0.16,1,0.3,1) forwards; }
|
||||
.animate-slide-in-right { animation: slide-in-right 0.5s cubic-bezier(0.16,1,0.3,1) forwards; }
|
||||
.animate-fade-in { animation: fade-in 0.3s ease forwards; }
|
||||
.animate-zoom-in { animation: zoom-in 0.3s cubic-bezier(0.16,1,0.3,1) forwards; }
|
||||
.animate-subtle-pulse { animation: subtle-pulse 2s ease-in-out infinite; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.animate-slide-up, .animate-slide-in-right, .animate-fade-in, .animate-zoom-in {
|
||||
animation: none !important;
|
||||
}
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { initAPI } from './api';
|
||||
|
||||
initAPI().then(() => {
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,310 @@
|
||||
import { create } from 'zustand';
|
||||
import type { Project, LLMProvider, VectorDBConfig, EmbeddingConfig, SourceFile } from '../types';
|
||||
import {
|
||||
GetAllProviders, SaveProvider, DeleteProvider,
|
||||
GetVectorDBConfig, SaveVectorDBConfig,
|
||||
GetEmbeddingConfig, SaveEmbeddingConfig,
|
||||
CreateProject, ListProjects, SwitchProject, DeleteProject,
|
||||
UploadMaterials, GetProjectFiles, DeleteMaterial,
|
||||
GetTemplateChapters, SaveTemplateChapters, GetDeliveryStandard,
|
||||
} from '../api';
|
||||
import { useChatStore } from './useChatStore';
|
||||
import { useUIStore } from './useUIStore';
|
||||
|
||||
interface AppState {
|
||||
projects: Project[];
|
||||
currentProjectId: string;
|
||||
modelConfigs: LLMProvider[];
|
||||
activeModelId: string;
|
||||
vectorDB: VectorDBConfig;
|
||||
embeddingConfig: EmbeddingConfig;
|
||||
pendingExcelPreview: { id: string; name: string } | null;
|
||||
|
||||
initConfigs: () => Promise<void>;
|
||||
initProjects: () => Promise<void>;
|
||||
setCurrentProjectId: (id: string) => void;
|
||||
switchProject: (id: string) => Promise<void>;
|
||||
setProjects: (fn: (p: Project[]) => Project[]) => void;
|
||||
addProject: (p: Project) => void;
|
||||
createProject: (name: string) => Promise<string | null>;
|
||||
deleteProject: (id: string) => Promise<void>;
|
||||
uploadMaterials: () => Promise<void>;
|
||||
loadProjectFiles: () => Promise<void>;
|
||||
deleteMaterial: (fileId: string) => Promise<void>;
|
||||
updateFileStatus: (fileId: string, status: string) => void;
|
||||
setPendingExcelPreview: (file: { id: string; name: string } | null) => void;
|
||||
setActiveModelId: (id: string) => void;
|
||||
addModel: () => Promise<void>;
|
||||
deleteModel: (id: string) => Promise<void>;
|
||||
updateModel: (id: string, field: string, value: string | boolean) => Promise<void>;
|
||||
setVectorDB: (v: Partial<VectorDBConfig>) => Promise<void>;
|
||||
setEmbeddingConfig: (v: Partial<EmbeddingConfig>) => Promise<void>;
|
||||
loadTemplateChapters: () => Promise<void>;
|
||||
saveTemplateChapters: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useAppStore = create<AppState>((set, get) => ({
|
||||
projects: [],
|
||||
currentProjectId: '',
|
||||
modelConfigs: [],
|
||||
activeModelId: '',
|
||||
vectorDB: { endpoint: '', apiKey: '', status: 'disconnected' },
|
||||
embeddingConfig: { provider: 'OpenAI', url: '', key: '', model: '', enabled: false },
|
||||
pendingExcelPreview: null,
|
||||
|
||||
initConfigs: async () => {
|
||||
try {
|
||||
const providers = await GetAllProviders();
|
||||
const v = await GetVectorDBConfig();
|
||||
const emb = await GetEmbeddingConfig();
|
||||
const configs: LLMProvider[] = (providers || []).map((x: any) => ({
|
||||
id: x.id, name: x.name, provider: x.provider,
|
||||
url: x.url, key: x.key, model: x.model, enabled: x.enabled,
|
||||
}));
|
||||
set({
|
||||
modelConfigs: configs,
|
||||
vectorDB: v ? { endpoint: v.endpoint, apiKey: v.apiKey, status: v.status } : get().vectorDB,
|
||||
embeddingConfig: emb ? { provider: emb.provider, url: emb.url, key: emb.key, model: emb.model, enabled: emb.enabled } : get().embeddingConfig,
|
||||
});
|
||||
if (configs.length > 0 && !get().activeModelId) {
|
||||
set({ activeModelId: configs.find(x => x.enabled)?.id || configs[0].id });
|
||||
}
|
||||
} catch (e) { console.error("initConfigs failed", e); }
|
||||
},
|
||||
|
||||
initProjects: async () => {
|
||||
try {
|
||||
const backendProjects = await ListProjects();
|
||||
const projects: Project[] = (backendProjects || []).map((p: any) => ({
|
||||
id: p.id, name: p.name, files: [],
|
||||
activeTemplate: { name: '无标准', version: '--', chapters: [] },
|
||||
}));
|
||||
set({ projects });
|
||||
if (projects.length > 0 && !get().currentProjectId) {
|
||||
await get().switchProject(projects[0].id);
|
||||
}
|
||||
} catch (e) { console.error("initProjects failed", e); }
|
||||
},
|
||||
|
||||
setCurrentProjectId: (id) => set({ currentProjectId: id }),
|
||||
|
||||
switchProject: async (id) => {
|
||||
try {
|
||||
await SwitchProject(id);
|
||||
set({ currentProjectId: id });
|
||||
useChatStore.getState().resetChat();
|
||||
useUIStore.getState().closeViewers();
|
||||
useUIStore.getState().setEditingOutline(false);
|
||||
useUIStore.getState().setNewTemplatePending(false);
|
||||
await get().loadProjectFiles();
|
||||
await useChatStore.getState().loadMessages();
|
||||
await get().loadTemplateChapters();
|
||||
try {
|
||||
const ds = await GetDeliveryStandard();
|
||||
if (ds && ds.content) {
|
||||
useUIStore.getState().setDeliveryStandardContent(ds.content);
|
||||
const proj = get().projects.find(p => p.id === id);
|
||||
if (proj && (!proj.activeTemplate.chapters || proj.activeTemplate.chapters.length === 0)) {
|
||||
useUIStore.getState().setNewTemplatePending(true);
|
||||
}
|
||||
} else {
|
||||
useUIStore.getState().setDeliveryStandardContent('');
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) { console.error("switchProject failed", e); }
|
||||
},
|
||||
|
||||
setProjects: (fn) => set(s => ({ projects: fn(s.projects) })),
|
||||
addProject: (p) => set(s => ({ projects: [...s.projects, p] })),
|
||||
|
||||
createProject: async (name) => {
|
||||
try {
|
||||
const proj = await CreateProject(name);
|
||||
if (!proj) return null;
|
||||
const newProject: Project = {
|
||||
id: proj.id, name: proj.name, files: [],
|
||||
activeTemplate: { name: '无标准', version: '--', chapters: [] },
|
||||
};
|
||||
set(s => ({ projects: [...s.projects, newProject], currentProjectId: proj.id }));
|
||||
return proj.id;
|
||||
} catch (e) { console.error("createProject failed", e); return null; }
|
||||
},
|
||||
|
||||
deleteProject: async (id) => {
|
||||
try {
|
||||
await DeleteProject(id);
|
||||
set(s => {
|
||||
const next = s.projects.filter(p => p.id !== id);
|
||||
return { projects: next, currentProjectId: s.currentProjectId === id ? (next[0]?.id || '') : s.currentProjectId };
|
||||
});
|
||||
} catch (e) { console.error("deleteProject failed", e); }
|
||||
},
|
||||
|
||||
uploadMaterials: async () => {
|
||||
try {
|
||||
const files = await UploadMaterials();
|
||||
if (!files || files.length === 0) return;
|
||||
const projectId = get().currentProjectId;
|
||||
const newFiles: SourceFile[] = files.map((f: any) => ({
|
||||
id: f.id, name: f.name, type: f.type as SourceFile['type'],
|
||||
category: f.category, size: f.size,
|
||||
vectorStatus: f.vectorStatus as SourceFile['vectorStatus'],
|
||||
}));
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? { ...p, files: [...p.files, ...newFiles] } : p),
|
||||
}));
|
||||
// If any Excel file was uploaded, show the preview modal for the first one
|
||||
const excelFile = newFiles.find(f => f.type === 'excel');
|
||||
if (excelFile) {
|
||||
set({ pendingExcelPreview: { id: excelFile.id, name: excelFile.name } });
|
||||
}
|
||||
} catch (e) { console.error("uploadMaterials failed", e); }
|
||||
},
|
||||
|
||||
loadProjectFiles: async () => {
|
||||
try {
|
||||
const files = await GetProjectFiles();
|
||||
const projectId = get().currentProjectId;
|
||||
const mapped: SourceFile[] = (files || []).map((f: any) => ({
|
||||
id: f.id, name: f.name, type: f.type as SourceFile['type'],
|
||||
category: f.category, size: f.size,
|
||||
vectorStatus: f.vectorStatus as SourceFile['vectorStatus'],
|
||||
}));
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? { ...p, files: mapped } : p),
|
||||
}));
|
||||
} catch (e) { console.error("loadProjectFiles failed", e); }
|
||||
},
|
||||
|
||||
deleteMaterial: async (fileId) => {
|
||||
try {
|
||||
await DeleteMaterial(fileId);
|
||||
const projectId = get().currentProjectId;
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? { ...p, files: p.files.filter(f => f.id !== fileId) } : p),
|
||||
}));
|
||||
} catch (e) { console.error("deleteMaterial failed", e); }
|
||||
},
|
||||
|
||||
updateFileStatus: (fileId, status) => {
|
||||
const projectId = get().currentProjectId;
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? {
|
||||
...p, files: p.files.map(f => f.id === fileId ? { ...f, vectorStatus: status as SourceFile['vectorStatus'] } : f),
|
||||
} : p),
|
||||
}));
|
||||
},
|
||||
|
||||
setPendingExcelPreview: (file) => set({ pendingExcelPreview: file }),
|
||||
|
||||
setActiveModelId: (id) => set({ activeModelId: id }),
|
||||
|
||||
addModel: async () => {
|
||||
const newId = `cfg-${Date.now()}`;
|
||||
const newModel: LLMProvider = {
|
||||
id: newId, name: '新模型提供商', provider: 'OpenAI',
|
||||
url: '', key: '', model: '', enabled: true,
|
||||
};
|
||||
try {
|
||||
await SaveProvider(newModel);
|
||||
set(s => ({ modelConfigs: [...s.modelConfigs, newModel] }));
|
||||
} catch (e) { console.error(e); }
|
||||
},
|
||||
|
||||
deleteModel: async (id) => {
|
||||
if (get().modelConfigs.length <= 1) return;
|
||||
try {
|
||||
await DeleteProvider(id);
|
||||
const next = get().modelConfigs.filter(m => m.id !== id);
|
||||
set({ modelConfigs: next, activeModelId: get().activeModelId === id ? next[0].id : get().activeModelId });
|
||||
} catch (e) { console.error(e); }
|
||||
},
|
||||
|
||||
updateModel: async (id, field, value) => {
|
||||
const existing = get().modelConfigs.find(m => m.id === id);
|
||||
if (!existing) return;
|
||||
const updated = { ...existing, [field]: value };
|
||||
try {
|
||||
await SaveProvider(updated);
|
||||
set(s => ({ modelConfigs: s.modelConfigs.map(m => m.id === id ? updated : m) }));
|
||||
} catch (e) { console.error(e); }
|
||||
},
|
||||
|
||||
setVectorDB: async (v) => {
|
||||
const next = { ...get().vectorDB, ...v };
|
||||
try {
|
||||
await SaveVectorDBConfig(next);
|
||||
set({ vectorDB: next });
|
||||
} catch (e) { console.error(e); }
|
||||
},
|
||||
|
||||
setEmbeddingConfig: async (v) => {
|
||||
const next = { ...get().embeddingConfig, ...v };
|
||||
try {
|
||||
await SaveEmbeddingConfig(next);
|
||||
set({ embeddingConfig: next });
|
||||
} catch (e) { console.error(e); }
|
||||
},
|
||||
|
||||
loadTemplateChapters: async () => {
|
||||
try {
|
||||
const dbChapters = await GetTemplateChapters();
|
||||
const projectId = get().currentProjectId;
|
||||
if (!dbChapters || dbChapters.length === 0) {
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? { ...p, activeTemplate: { name: '无标准', version: '--', chapters: [] } } : p),
|
||||
}));
|
||||
return;
|
||||
}
|
||||
const chapters = dbChapters.map((c: any) => ({
|
||||
id: c.id, title: c.title, status: c.status || 'idle',
|
||||
progress: c.progress || 0, content: c.content || '',
|
||||
}));
|
||||
set(s => ({
|
||||
projects: s.projects.map(p =>
|
||||
p.id === projectId ? { ...p, activeTemplate: { name: '交付标准', version: 'v1.0', chapters } } : p),
|
||||
}));
|
||||
} catch (e) { console.error("loadTemplateChapters failed", e); }
|
||||
},
|
||||
|
||||
saveTemplateChapters: async () => {
|
||||
const proj = get().projects.find(p => p.id === get().currentProjectId);
|
||||
if (!proj) return;
|
||||
const chapters = proj.activeTemplate.chapters.map((c, i) => ({
|
||||
id: c.id, title: c.title, status: c.status, progress: c.progress,
|
||||
content: c.content, sortOrder: i,
|
||||
}));
|
||||
try {
|
||||
await SaveTemplateChapters(chapters);
|
||||
} catch (e) { console.error("saveTemplateChapters failed", e); }
|
||||
},
|
||||
}));
|
||||
|
||||
// --- Derived selectors ---
|
||||
export function useCurrentProject(): Project {
|
||||
const projects = useAppStore(s => s.projects);
|
||||
const currentProjectId = useAppStore(s => s.currentProjectId);
|
||||
return projects.find(p => p.id === currentProjectId) || projects[0] || {
|
||||
id: 'empty',
|
||||
name: '未选择工程',
|
||||
files: [],
|
||||
activeTemplate: { name: '无标准', version: '--', chapters: [] }
|
||||
};
|
||||
}
|
||||
|
||||
export function useEnabledModels(): LLMProvider[] {
|
||||
const modelConfigs = useAppStore(s => s.modelConfigs);
|
||||
return modelConfigs.filter(m => m.enabled);
|
||||
}
|
||||
|
||||
export function useActiveModel(): LLMProvider | undefined {
|
||||
const modelConfigs = useAppStore(s => s.modelConfigs);
|
||||
const activeModelId = useAppStore(s => s.activeModelId);
|
||||
const enabled = modelConfigs.filter(m => m.enabled);
|
||||
return modelConfigs.find(m => m.id === activeModelId) || enabled[0];
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { create } from 'zustand';
|
||||
import type { ChatMessage } from '../types';
|
||||
import { GetChatMessages, ClearChatMessages } from '../api';
|
||||
|
||||
interface ChatState {
|
||||
selectedFileIds: Set<string>;
|
||||
messages: ChatMessage[];
|
||||
inputValue: string;
|
||||
isThinking: boolean;
|
||||
|
||||
toggleFileSelection: (id: string) => void;
|
||||
clearSelection: () => void;
|
||||
setInputValue: (v: string) => void;
|
||||
addMessage: (msg: ChatMessage) => void;
|
||||
updateMessage: (id: number, update: Partial<ChatMessage>) => void;
|
||||
appendChunk: (id: number, chunk: string) => void;
|
||||
setMessages: (msgs: ChatMessage[]) => void;
|
||||
setIsThinking: (v: boolean) => void;
|
||||
resetChat: () => void;
|
||||
loadMessages: () => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
}
|
||||
|
||||
export const useChatStore = create<ChatState>((set) => ({
|
||||
selectedFileIds: new Set<string>(),
|
||||
messages: [],
|
||||
inputValue: '',
|
||||
isThinking: false,
|
||||
|
||||
toggleFileSelection: (id) => set(s => {
|
||||
const next = new Set(s.selectedFileIds);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
return { selectedFileIds: next };
|
||||
}),
|
||||
clearSelection: () => set({ selectedFileIds: new Set() }),
|
||||
setInputValue: (v) => set({ inputValue: v }),
|
||||
addMessage: (msg) => set(s => ({ messages: [...s.messages, msg] })),
|
||||
updateMessage: (id, update) => set(s => ({
|
||||
messages: s.messages.map(m => m.id === id ? { ...m, ...update } : m),
|
||||
})),
|
||||
appendChunk: (id, chunk) => set(s => ({
|
||||
messages: s.messages.map(m => m.id === id ? { ...m, content: m.content + chunk } : m),
|
||||
})),
|
||||
setMessages: (msgs) => set({ messages: msgs }),
|
||||
setIsThinking: (v) => set({ isThinking: v }),
|
||||
resetChat: () => set({ messages: [], inputValue: '', selectedFileIds: new Set(), isThinking: false }),
|
||||
|
||||
loadMessages: async () => {
|
||||
try {
|
||||
const dbMessages = await GetChatMessages();
|
||||
if (!dbMessages || dbMessages.length === 0) {
|
||||
set({ messages: [] });
|
||||
return;
|
||||
}
|
||||
const messages: ChatMessage[] = dbMessages.map((m: any) => {
|
||||
let sources: string[] | undefined;
|
||||
let citations: any[] | undefined;
|
||||
try { if (m.sources) sources = JSON.parse(m.sources); } catch {}
|
||||
try { if (m.citations) citations = JSON.parse(m.citations); } catch {}
|
||||
return {
|
||||
id: m.id,
|
||||
role: m.role as 'user' | 'assistant',
|
||||
content: m.content,
|
||||
sources,
|
||||
citations,
|
||||
status: 'success' as const,
|
||||
};
|
||||
});
|
||||
set({ messages });
|
||||
} catch (e) {
|
||||
console.error('Failed to load chat messages', e);
|
||||
}
|
||||
},
|
||||
|
||||
clearMessages: async () => {
|
||||
try {
|
||||
await ClearChatMessages();
|
||||
set({ messages: [] });
|
||||
} catch (e) {
|
||||
console.error('Failed to clear chat messages', e);
|
||||
}
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,72 @@
|
||||
import { create } from 'zustand';
|
||||
import type { SourceFile, TemplateChapter } from '../types';
|
||||
|
||||
interface UIState {
|
||||
isSettingsOpen: boolean;
|
||||
isProjectDropdownOpen: boolean;
|
||||
isModelSelectorOpen: boolean;
|
||||
isNewProjectModalOpen: boolean;
|
||||
isEditingOutline: boolean;
|
||||
isLeftSidebarCollapsed: boolean;
|
||||
isRightSidebarCollapsed: boolean;
|
||||
activeSettingsTab: 'models' | 'vector' | 'embedding';
|
||||
isTestingConnection: boolean;
|
||||
isParsingTemplate: boolean;
|
||||
hasNewTemplatePending: boolean;
|
||||
previewSource: SourceFile | null;
|
||||
previewChapter: TemplateChapter | null;
|
||||
insightData: { id: number; title: string; content: string } | null;
|
||||
deliveryStandardContent: string;
|
||||
|
||||
setSettingsOpen: (v: boolean) => void;
|
||||
setProjectDropdownOpen: (v: boolean) => void;
|
||||
setModelSelectorOpen: (v: boolean) => void;
|
||||
setNewProjectModalOpen: (v: boolean) => void;
|
||||
setEditingOutline: (v: boolean) => void;
|
||||
toggleLeftSidebar: () => void;
|
||||
toggleRightSidebar: () => void;
|
||||
setActiveSettingsTab: (v: 'models' | 'vector' | 'embedding') => void;
|
||||
setTestingConnection: (v: boolean) => void;
|
||||
setParsingTemplate: (v: boolean) => void;
|
||||
setNewTemplatePending: (v: boolean) => void;
|
||||
setPreviewSource: (v: SourceFile | null) => void;
|
||||
setPreviewChapter: (v: TemplateChapter | null) => void;
|
||||
setInsightData: (v: { id: number; title: string; content: string } | null) => void;
|
||||
setDeliveryStandardContent: (v: string) => void;
|
||||
closeViewers: () => void;
|
||||
}
|
||||
|
||||
export const useUIStore = create<UIState>((set) => ({
|
||||
isSettingsOpen: false,
|
||||
isProjectDropdownOpen: false,
|
||||
isModelSelectorOpen: false,
|
||||
isNewProjectModalOpen: false,
|
||||
isEditingOutline: false,
|
||||
isLeftSidebarCollapsed: false,
|
||||
isRightSidebarCollapsed: false,
|
||||
activeSettingsTab: 'models',
|
||||
isTestingConnection: false,
|
||||
isParsingTemplate: false,
|
||||
hasNewTemplatePending: false,
|
||||
previewSource: null,
|
||||
previewChapter: null,
|
||||
insightData: null,
|
||||
deliveryStandardContent: '',
|
||||
|
||||
setSettingsOpen: (v) => set({ isSettingsOpen: v }),
|
||||
setProjectDropdownOpen: (v) => set({ isProjectDropdownOpen: v }),
|
||||
setModelSelectorOpen: (v) => set({ isModelSelectorOpen: v }),
|
||||
setNewProjectModalOpen: (v) => set({ isNewProjectModalOpen: v }),
|
||||
setEditingOutline: (v) => set({ isEditingOutline: v }),
|
||||
toggleLeftSidebar: () => set(s => ({ isLeftSidebarCollapsed: !s.isLeftSidebarCollapsed })),
|
||||
toggleRightSidebar: () => set(s => ({ isRightSidebarCollapsed: !s.isRightSidebarCollapsed })),
|
||||
setActiveSettingsTab: (v) => set({ activeSettingsTab: v }),
|
||||
setTestingConnection: (v) => set({ isTestingConnection: v }),
|
||||
setParsingTemplate: (v) => set({ isParsingTemplate: v }),
|
||||
setNewTemplatePending: (v) => set({ hasNewTemplatePending: v }),
|
||||
setPreviewSource: (v) => set({ previewSource: v }),
|
||||
setPreviewChapter: (v) => set({ previewChapter: v }),
|
||||
setInsightData: (v) => set({ insightData: v }),
|
||||
setDeliveryStandardContent: (v) => set({ deliveryStandardContent: v }),
|
||||
closeViewers: () => set({ previewSource: null, previewChapter: null }),
|
||||
}));
|
||||
@@ -0,0 +1,78 @@
|
||||
// --- Shared Types (same as original) ---
|
||||
|
||||
export interface LLMProvider {
|
||||
id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
url: string;
|
||||
key: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface VectorDBConfig {
|
||||
endpoint: string;
|
||||
apiKey: string;
|
||||
status: 'connected' | 'disconnected' | 'testing';
|
||||
}
|
||||
|
||||
export interface EmbeddingConfig {
|
||||
provider: string;
|
||||
url: string;
|
||||
key: string;
|
||||
model: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface SourceFile {
|
||||
id: string;
|
||||
name: string;
|
||||
type: 'pdf' | 'cad' | 'gis' | 'excel' | 'word';
|
||||
category: string;
|
||||
size: string;
|
||||
content?: string | Record<string, unknown>;
|
||||
vectorStatus?: 'pending' | 'processing' | 'done' | 'error';
|
||||
}
|
||||
|
||||
export interface TemplateChapter {
|
||||
id: string;
|
||||
title: string;
|
||||
status: 'idle' | 'loading' | 'done';
|
||||
progress: number;
|
||||
content: string;
|
||||
children?: TemplateChapter[];
|
||||
}
|
||||
|
||||
export interface Template {
|
||||
name: string;
|
||||
version: string;
|
||||
chapters: TemplateChapter[];
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string;
|
||||
name: string;
|
||||
files: SourceFile[];
|
||||
activeTemplate: Template;
|
||||
}
|
||||
|
||||
export interface Citation {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number;
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
thinking?: string;
|
||||
sources?: string[];
|
||||
citations?: Citation[];
|
||||
type?: 'generation-log' | 'material-log';
|
||||
chapterTitle?: string;
|
||||
isRegenerate?: boolean;
|
||||
status?: 'processing' | 'success' | 'error';
|
||||
steps?: string[];
|
||||
metrics?: { tokensIn: number; tokensOut: number; latency: string };
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: "./",
|
||||
build: {
|
||||
outDir: "dist",
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user