feat: 解析多表头excel

This commit is contained in:
Blizzard
2026-04-28 10:47:31 +08:00
parent 473f9226d3
commit 488026dffe
32 changed files with 11635 additions and 0 deletions
+167
View File
@@ -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();
});
+11
View File
@@ -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'),
});
+13
View File
@@ -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>
+8277
View File
File diff suppressed because it is too large Load Diff
+63
View File
@@ -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"
}
}
}
+10
View File
@@ -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;
}
+98
View File
@@ -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>
);
}
+372
View File
@@ -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();
}
+235
View File
@@ -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>
);
}
+22
View File
@@ -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>
);
}
+27
View File
@@ -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>
);
}
+110
View File
@@ -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;
}
}
+13
View File
@@ -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>,
);
});
+310
View File
@@ -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];
}
+83
View File
@@ -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);
}
},
}));
+72
View File
@@ -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 }),
}));
+78
View File
@@ -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 };
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+20
View File
@@ -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"]
}
+14
View File
@@ -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,
},
});