init: initial commit
This commit is contained in:
@@ -0,0 +1,143 @@
|
||||
import { useRef, useEffect } from 'react';
|
||||
import { Terminal, Loader2, CheckCircle2, BookOpen } from 'lucide-react';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useCurrentProject } from '../../stores/useAppStore';
|
||||
import type { ChatMessage } from '../../types';
|
||||
|
||||
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-[#E5F3ED] text-[#2H8B64] border border-[#CDE5D8]' : '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 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.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-20 mb-auto flex flex-col items-center">
|
||||
<h1 className="text-3xl font-serif text-[var(--color-text-primary)] mb-8 tracking-tight">Afternoon, Blizzard</h1>
|
||||
<div className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full bg-[var(--color-surface-hover)] border border-[var(--color-border-subtle)] text-[12px] text-[var(--color-text-secondary)]">
|
||||
<span>Free plan</span>
|
||||
<span className="text-[var(--color-text-tertiary)]">•</span>
|
||||
<a href="#" className="hover:text-[var(--color-text-primary)] underline underline-offset-2">Upgrade</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map(msg => (
|
||||
<div key={msg.id} className="animate-slide-up text-left">
|
||||
{msg.type === 'generation-log' ? <GenerationLog 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,110 @@
|
||||
import { Send, X } from 'lucide-react';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { StreamMessage } from '../../../bindings/engimind/internal/chat/chatservice.js';
|
||||
import { Events } from '@wailsio/runtime';
|
||||
|
||||
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 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 });
|
||||
|
||||
const assistantMsgId = Date.now() + 1;
|
||||
const msgIdStr = assistantMsgId.toString();
|
||||
addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
|
||||
|
||||
let unSub = Events.On("chat_stream_" + msgIdStr, (e: any) => {
|
||||
// Wails 3 event data might be the value itself or an array of values
|
||||
const fullText = Array.isArray(e.data) ? e.data[0] : e.data;
|
||||
if (typeof fullText === 'string') {
|
||||
// Only update if it's longer to avoid jitter from out-of-order events
|
||||
const state = useChatStore.getState();
|
||||
const msg = state.messages.find(m => m.id === assistantMsgId);
|
||||
if (msg && fullText.length > msg.content.length) {
|
||||
state.updateMessage(assistantMsgId, { content: fullText });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
const finalContent = await StreamMessage(question, contextFiles, activeModelId, msgIdStr);
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { content: finalContent, status: 'success' });
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
useChatStore.getState().appendChunk(assistantMsgId, `\n\n**请求失败:** ${err}`);
|
||||
useChatStore.getState().updateMessage(assistantMsgId, { status: 'success' });
|
||||
} finally {
|
||||
setIsThinking(false);
|
||||
// Wails v3 returns a cancel function from On()
|
||||
if (typeof unSub === 'function') unSub();
|
||||
else Events.Off("chat_stream_" + msgIdStr);
|
||||
}
|
||||
};
|
||||
|
||||
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-[#F9F8F6] border border-[#E5E4E0] text-[#4A4A4A] 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-[#D97757]" />
|
||||
<span>{selectedFileIds.size} files selected</span>
|
||||
<div className="flex items-center gap-2 ml-1 border-l border-[#E5E4E0] 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-[#2D2D2D] max-w-[120px] truncate">{file.name}</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => useChatStore.getState().clearSelection()}
|
||||
className="ml-2 hover:bg-[#EBE9E4] p-1 rounded-md text-[#8E8B83] hover:text-[#D97757] transition-colors"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="relative group flex items-end gap-3 bg-[#F9F8F6] border border-[#E5E4E0] rounded-[1.5rem] p-2 pr-2.5 transition-all duration-300 focus-within:bg-[#FFFFFF] focus-within:shadow-[0_4px_20px_-4px_rgba(0,0,0,0.05)] focus-within:border-[#D0CECB]">
|
||||
<textarea
|
||||
value={inputValue}
|
||||
onChange={(e) => setInputValue(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend(); } }}
|
||||
placeholder="How can I help you today?"
|
||||
className="flex-1 bg-transparent border-none focus:ring-0 text-[15px] py-4 px-4 resize-none h-[64px] text-[#2D2D2D] outline-none custom-scrollbar placeholder-[#A0A09F] 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 ${
|
||||
inputValue.trim() ? 'bg-[#D97757] text-white shadow-sm hover:bg-[#C86444]' : 'bg-[#EBE9E4] text-[#A0A09F] cursor-not-allowed'
|
||||
}`}
|
||||
>
|
||||
<Send size={16} className={`ml-0.5 ${inputValue.trim() ? '' : 'opacity-80'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Database, Settings, Scroll, Sparkles } 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 } = useUIStore();
|
||||
|
||||
return (
|
||||
<header className="h-14 flex items-center justify-end pb-2 pt-2 px-2 mt-4 sm:mt-0 relative z-10">
|
||||
<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 || 'No Configured Model'}</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>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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 style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className={`flex-1 flex flex-col relative bg-[#FFFFFF] transition-all duration-500 overflow-hidden ${
|
||||
hasOverlay ? 'scale-[0.98] opacity-30 grayscale-[0.5]' : ''
|
||||
}`}>
|
||||
<ConsoleHeader />
|
||||
<ChatArea />
|
||||
<ChatInput />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { FileText, Layers, Map as MapIcon, Table } from 'lucide-react';
|
||||
|
||||
interface Props {
|
||||
type: string;
|
||||
className?: string;
|
||||
colored?: boolean;
|
||||
}
|
||||
|
||||
export function FileIcon({ type, className = '', colored = false }: Props) {
|
||||
if (!colored) {
|
||||
switch (type) {
|
||||
case 'pdf': return <FileText className={className} />;
|
||||
case 'cad': return <Layers className={className} />;
|
||||
case 'gis': return <MapIcon className={className} />;
|
||||
case 'excel': return <Table className={className} />;
|
||||
default: return <FileText className={className} />;
|
||||
}
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'pdf': return <FileText className={`text-red-500 ${className}`} />;
|
||||
case 'cad': return <Layers className={`text-cyan-500 ${className}`} />;
|
||||
case 'gis': return <MapIcon className={`text-emerald-500 ${className}`} />;
|
||||
case 'excel': return <Table className={`text-green-500 ${className}`} />;
|
||||
default: return <FileText className={className} />;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { CheckSquare, Square, Search } from 'lucide-react';
|
||||
import { FileIcon } from '../FileIcon';
|
||||
import { useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function FileTree() {
|
||||
const currentProject = useCurrentProject();
|
||||
const { selectedFileIds, toggleFileSelection } = useChatStore();
|
||||
const { setPreviewSource } = useUIStore();
|
||||
|
||||
return (
|
||||
<div className="flex-1 overflow-y-auto px-2 py-2 custom-scrollbar">
|
||||
<div className="text-[10px] font-medium text-[#8E8B83] uppercase tracking-[0.2em] mb-4 mt-2 px-3 flex justify-between items-center">
|
||||
<span>工程素材库</span>
|
||||
<Search size={12} className="text-[#C2C0B8]" />
|
||||
</div>
|
||||
<div className="space-y-[2px]">
|
||||
{currentProject.files.map(file => (
|
||||
<div
|
||||
key={file.id}
|
||||
onClick={() => setPreviewSource(file)}
|
||||
className={`group flex items-center gap-3 p-2 5 rounded-lg transition-colors duration-200 cursor-pointer ${
|
||||
selectedFileIds.has(file.id)
|
||||
? 'bg-[#EBE9E4]'
|
||||
: 'bg-transparent hover:bg-[#F3F2EE]'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => { e.stopPropagation(); toggleFileSelection(file.id); }}
|
||||
className={`shrink-0 transition-colors ml-1 ${selectedFileIds.has(file.id) ? 'text-[#D97757]' : 'text-[#C2C0B8] group-hover:text-[#8E8B83]'}`}
|
||||
>
|
||||
{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-[#2D2D2D] font-medium' : 'text-[#4A4A4A]'}`}>{file.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ChevronDown, CheckCircle2, Plus } from 'lucide-react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
|
||||
export function ProjectSelector() {
|
||||
const { projects, currentProjectId, setCurrentProjectId } = useAppStore();
|
||||
const currentProject = useCurrentProject();
|
||||
const { isProjectDropdownOpen, setProjectDropdownOpen, setNewProjectModalOpen } = useUIStore();
|
||||
const resetChat = useChatStore(s => s.resetChat);
|
||||
const closeViewers = useUIStore(s => s.closeViewers);
|
||||
const setEditingOutline = useUIStore(s => s.setEditingOutline);
|
||||
|
||||
const handleSwitch = (id: string) => {
|
||||
setCurrentProjectId(id);
|
||||
resetChat();
|
||||
closeViewers();
|
||||
setEditingOutline(false);
|
||||
setProjectDropdownOpen(false);
|
||||
};
|
||||
|
||||
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-[#EBE9E4] 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-[#2D2D2D]">
|
||||
{currentProject.name}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronDown size={14} className="text-[#8E8B83] group-hover:text-[#2D2D2D] transition-colors" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setNewProjectModalOpen(true)}
|
||||
className="w-7 h-7 shrink-0 flex items-center justify-center bg-transparent hover:bg-[#EBE9E4] text-[#8E8B83] hover:text-[#2D2D2D] rounded-lg transition-colors duration-200"
|
||||
title="New Project"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</button>
|
||||
|
||||
{isProjectDropdownOpen && (
|
||||
<div className="absolute left-0 right-0 top-10 bg-[#FFFFFF] border border-[#E5E4E0] 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-[#F3F2EE] cursor-pointer flex items-center gap-3 text-[13px] rounded-lg transition-colors ${
|
||||
p.id === currentProjectId ? 'text-[#D97757] font-medium' : 'text-[#4A4A4A]'
|
||||
}`}
|
||||
>
|
||||
<span className="truncate">{p.name}</span>
|
||||
{p.id === currentProjectId && <CheckCircle2 size={14} className="ml-auto text-[#D97757]" />}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
import { Shield, UploadCloud, FileCheck, Loader2, RefreshCw } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
|
||||
import { ParseDeliveryStandard } from '../../../bindings/engimind/internal/parser/parseservice.js';
|
||||
import { StreamTemplateDirectory } from '../../../bindings/engimind/internal/chat/chatservice.js';
|
||||
import { Events } from '@wailsio/runtime';
|
||||
|
||||
export function TemplateCard() {
|
||||
const currentProject = useCurrentProject();
|
||||
const { isParsingTemplate, hasNewTemplatePending, setParsingTemplate, setNewTemplatePending, setEditingOutline } = useUIStore();
|
||||
const setProjects = useAppStore(s => s.setProjects);
|
||||
const currentProjectId = useAppStore(s => s.currentProjectId);
|
||||
const [pendingMarkdown, setPendingMarkdown] = useState('');
|
||||
|
||||
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;
|
||||
const msgIdStr = assistantMsgId.toString();
|
||||
|
||||
cs.addMessage({ id: userMsgId, role: 'user', content: '我上传了一份工程交付文档。请帮我深度解析并归纳出标准大纲结构。' });
|
||||
cs.addMessage({ id: assistantMsgId, role: 'assistant', content: '', status: 'processing' });
|
||||
|
||||
let unSub = Events.On("chat_stream_" + msgIdStr, (e: any) => {
|
||||
const fullText = Array.isArray(e.data) ? e.data[0] : e.data;
|
||||
if (typeof fullText === 'string') {
|
||||
const msg = cs.messages.find(m => m.id === assistantMsgId);
|
||||
if (msg && fullText.length > msg.content.length) {
|
||||
cs.updateMessage(assistantMsgId, { content: fullText });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Invoke LLM to extract standard chapters
|
||||
let jsonStr = await StreamTemplateDirectory(pendingMarkdown, activeModelId, msgIdStr);
|
||||
unSub();
|
||||
|
||||
cs.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 chapters = parsedChapters.map((c: any, i: number) => ({
|
||||
id: c.id || `generated-${Date.now()}-${i}`,
|
||||
title: c.title || `第${i+1}节 内容生成`,
|
||||
status: 'idle',
|
||||
progress: 0,
|
||||
content: c.content || ''
|
||||
}));
|
||||
|
||||
// Update project directory globally
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return {
|
||||
...p,
|
||||
activeTemplate: {
|
||||
name: 'AI 深层解析大纲', version: 'v1.0 (Auto)',
|
||||
chapters,
|
||||
},
|
||||
};
|
||||
}));
|
||||
|
||||
setParsingTemplate(false);
|
||||
setPendingMarkdown('');
|
||||
setEditingOutline(true);
|
||||
|
||||
} 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) {
|
||||
setPendingMarkdown(markdownContent);
|
||||
setNewTemplatePending(true);
|
||||
}
|
||||
} catch (error: any) {
|
||||
alert("读取或转换文件内容失败: " + (error.message || error));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-3 mx-2 my-4 mt-auto rounded-[14px] bg-[#F1EFEA]">
|
||||
<div className="flex items-center justify-between mb-3 text-[10px] font-medium text-[#8E8B83] uppercase tracking-[0.15em] px-1">
|
||||
<div className="flex items-center gap-2"><Shield size={12} className="text-[#2D2D2D]" /> 交付标准</div>
|
||||
<button
|
||||
onClick={handleUploadClick}
|
||||
className="p-1 hover:bg-[#EBE9E4] rounded-md text-[#8E8B83] hover:text-[#2D2D2D] transition-colors"
|
||||
title="上传交付标准"
|
||||
>
|
||||
<UploadCloud size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className={`relative p-3 rounded-xl border transition-all duration-300 bg-[#FFFFFF] border-[#E5E4E0] shadow-[0_2px_10px_-4px_rgba(0,0,0,0.05)] ${
|
||||
isParsingTemplate ? 'border-[#E5E4E0] animate-pulse ring-1 ring-[#E5E4E0]' : ''
|
||||
}`}>
|
||||
{isParsingTemplate ? (
|
||||
<div className="flex flex-col items-center py-1 gap-2">
|
||||
<Loader2 size={16} className="text-[#D97757] animate-spin" />
|
||||
<span className="text-[9px] font-medium text-[#D97757] uppercase">AI 解析目录中...</span>
|
||||
</div>
|
||||
) : hasNewTemplatePending ? (
|
||||
<button
|
||||
onClick={startTemplateParse}
|
||||
className="w-full py-1.5 bg-[#D97757] text-white rounded-lg text-[11px] font-medium transition-all hover:bg-[#C86444] shadow-sm transform hover:scale-[1.01]"
|
||||
>
|
||||
开始大模型解析目录
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex items-start gap-3">
|
||||
<FileCheck size={16} className="text-[#8E8B83] mt-0.5" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-[12px] font-medium text-[#2D2D2D] 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-[9px] font-mono text-[#8E8B83]">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-[#D97757]" />
|
||||
{currentProject.activeTemplate.version} ACTIVE
|
||||
</span>
|
||||
<RefreshCw size={10} className="text-[#C2C0B8]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ProjectSelector } from './ProjectSelector';
|
||||
import { FileTree } from './FileTree';
|
||||
import { TemplateCard } from './TemplateCard';
|
||||
|
||||
export function LeftSidebar() {
|
||||
return (
|
||||
<aside style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className="w-[280px] shrink-0 border-r border-[#E5E4E0] bg-[#FAF9F6] flex flex-col z-30 pt-10 p-4 relative overflow-hidden transition-all duration-300">
|
||||
<ProjectSelector />
|
||||
<FileTree />
|
||||
<TemplateCard />
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -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-slate-950/60 backdrop-blur-md animate-fade-in">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] shadow-2xl w-full max-w-xl overflow-hidden flex flex-col animate-zoom-in">
|
||||
<div className="px-8 py-6 border-b border-slate-800 flex items-center justify-between bg-slate-900">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="p-3 bg-blue-500/10 rounded-2xl text-blue-500 shadow-inner"><Table size={20} /></div>
|
||||
<h4 className="text-sm font-black text-white uppercase">{insightData.title}</h4>
|
||||
</div>
|
||||
<button onClick={() => setInsightData(null)}><X size={24} /></button>
|
||||
</div>
|
||||
<div className="p-10 bg-slate-950/50 font-mono text-[11px] text-blue-100/90 leading-relaxed whitespace-pre-wrap bg-blue-900/10 rounded-3xl border border-blue-500/10 shadow-inner m-6">
|
||||
{insightData.content}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 { addProject, setCurrentProjectId } = useAppStore();
|
||||
const resetChat = useChatStore(s => s.resetChat);
|
||||
const closeViewers = useUIStore(s => s.closeViewers);
|
||||
const [name, setName] = useState('');
|
||||
|
||||
if (!isNewProjectModalOpen) return null;
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!name.trim()) return;
|
||||
const newId = `p-${Date.now()}`;
|
||||
addProject({
|
||||
id: newId, name,
|
||||
files: [],
|
||||
activeTemplate: {
|
||||
name: '基础交付规范', version: 'v1.0',
|
||||
chapters: [{ id: 'def-1', title: '1.1 项目概况简述', status: 'idle', progress: 0, content: '' }],
|
||||
},
|
||||
});
|
||||
setName('');
|
||||
setNewProjectModalOpen(false);
|
||||
setCurrentProjectId(newId);
|
||||
resetChat();
|
||||
closeViewers();
|
||||
setEditingOutline(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-slate-950/90 backdrop-blur-2xl z-[150] flex items-center justify-center p-4">
|
||||
<div className="bg-slate-900 border border-slate-800 rounded-[3rem] w-full max-w-lg overflow-hidden shadow-2xl flex flex-col animate-zoom-in">
|
||||
<div className="px-10 py-8 border-b border-slate-800 flex items-center justify-between bg-slate-900/50">
|
||||
<div className="flex items-center gap-4 text-blue-500 font-black uppercase text-sm">
|
||||
<Briefcase size={24} /> 工程初始化
|
||||
</div>
|
||||
<button onClick={() => setNewProjectModalOpen(false)}><X size={24} /></button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-10 space-y-8 text-left">
|
||||
<div className="space-y-3">
|
||||
<label className="text-[10px] text-slate-500 font-black uppercase tracking-widest ml-1">项目全称</label>
|
||||
<input
|
||||
autoFocus required value={name} onChange={(e) => setName(e.target.value)}
|
||||
placeholder="输入名称..."
|
||||
className="w-full bg-slate-950 border border-slate-800 focus:border-blue-500 rounded-2xl px-6 py-4 text-sm text-slate-200 outline-none"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-4">
|
||||
<button type="button" onClick={() => setNewProjectModalOpen(false)} className="flex-1 py-4 bg-slate-800 text-white rounded-2xl font-black text-[10px] uppercase tracking-widest">取消</button>
|
||||
<button type="submit" className="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl">立即创建</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { X, BookOpen, Edit3, Save } from 'lucide-react';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
|
||||
export function SlideOverViewer() {
|
||||
const { previewSource, previewChapter, closeViewers } = useUIStore();
|
||||
if (!previewSource && !previewChapter) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-y-0 right-0 w-[650px] bg-[#0F172A] border-l border-slate-800 z-[100] shadow-[-30px_0_60px_rgba(0,0,0,0.8)] animate-slide-in-right flex flex-col backdrop-blur-xl">
|
||||
<header className="p-6 border-b border-slate-800 flex items-center justify-between bg-slate-950/80 backdrop-blur-xl sticky top-0 z-10">
|
||||
<div className="flex items-center gap-4 overflow-hidden min-w-0">
|
||||
<div className={`w-12 h-12 rounded-2xl flex items-center justify-center shrink-0 border shadow-inner ${
|
||||
previewSource ? 'bg-blue-500/10 border-blue-500/20 text-blue-500' : 'bg-emerald-500/10 border-emerald-500/20 text-emerald-500'
|
||||
}`}>
|
||||
{previewSource ? <BookOpen size={24} /> : <Edit3 size={24} />}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<h2 className="text-sm font-black text-white truncate uppercase">
|
||||
{previewSource ? previewSource.name : previewChapter?.title}
|
||||
</h2>
|
||||
<p className="text-[10px] text-slate-500 font-black uppercase tracking-[0.2em] mt-1">
|
||||
{previewSource ? '素材源预览' : '成果编辑视图'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={closeViewers} className="p-2 hover:bg-slate-800 rounded-full text-slate-500 hover:text-white transition-colors group">
|
||||
<X size={28} className="group-hover:rotate-90 transition-transform" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div className="flex-1 overflow-y-auto p-12 bg-slate-950/30 custom-scrollbar relative text-slate-300 leading-relaxed text-sm text-left">
|
||||
{previewSource ? (
|
||||
previewSource.type === 'pdf' ? (
|
||||
<div className="whitespace-pre-wrap leading-8">{previewSource.content as string}</div>
|
||||
) : (
|
||||
<div className="p-8 border border-slate-800 rounded-2xl bg-slate-900/50 text-left font-mono text-[11px] text-slate-400">
|
||||
<pre>{JSON.stringify(typeof previewSource.content === 'string' ? JSON.parse(previewSource.content) : previewSource.content, null, 2)}</pre>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<div
|
||||
className="whitespace-pre-wrap leading-8 bg-slate-900/50 border border-slate-800 p-10 rounded-[2.5rem] outline-none shadow-2xl focus-within:ring-2 focus-within:ring-blue-500/20 transition-all text-left"
|
||||
contentEditable
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{previewChapter?.content}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<footer className="p-8 border-t border-slate-800 bg-[#0F172A]">
|
||||
{previewChapter ? (
|
||||
<button onClick={closeViewers} className="w-full py-4 bg-emerald-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl flex items-center justify-center gap-3">
|
||||
<Save size={16} /> 保存章节并应用
|
||||
</button>
|
||||
) : (
|
||||
<button className="w-full py-4 bg-blue-600 text-white rounded-2xl font-black text-[10px] uppercase shadow-xl">
|
||||
加入当前上下文
|
||||
</button>
|
||||
)}
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { Hammer, Settings2, Loader2, RefreshCw, Maximize2, Trash2, Plus, Sparkles } from 'lucide-react';
|
||||
import { useAppStore, useCurrentProject } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { useChatStore } from '../../stores/useChatStore';
|
||||
|
||||
export function RightSidebar() {
|
||||
const currentProject = useCurrentProject();
|
||||
const currentProjectId = useAppStore(s => s.currentProjectId);
|
||||
const setProjects = useAppStore(s => s.setProjects);
|
||||
const { isEditingOutline, setEditingOutline, previewChapter, setPreviewChapter, setPreviewSource } = useUIStore();
|
||||
const { selectedFileIds, addMessage, updateMessage } = useChatStore();
|
||||
|
||||
const updateChapterTitle = (id: string, newTitle: string) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.map(c => c.id === id ? { ...c, title: newTitle } : c) } };
|
||||
}));
|
||||
};
|
||||
|
||||
const deleteChapter = (id: string) => {
|
||||
setProjects(prev => prev.map(p => {
|
||||
if (p.id !== currentProjectId) return p;
|
||||
return { ...p, activeTemplate: { ...p.activeTemplate, chapters: p.activeTemplate.chapters.filter(c => c.id !== id) } };
|
||||
}));
|
||||
};
|
||||
|
||||
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: 调用后端流式生成接口,实时触发步骤回调,并最终写入结果。
|
||||
// 在这里暂时直接重置状态为 idle 或者保留 loading 状态让后端回调接管
|
||||
// Mock removed
|
||||
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: '' }] } };
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<aside style={{ "--wails-draggable": "no-drag" } as React.CSSProperties} className="w-[320px] shrink-0 border-l border-[var(--color-border-subtle)] bg-[var(--color-surface-main)] p-6 pt-10 flex flex-col z-20 overflow-hidden relative transition-all duration-300">
|
||||
<div className="pb-4 mb-4 border-b border-[var(--color-border-subtle)] flex items-center justify-between text-[11px] font-medium uppercase tracking-[0.15em] text-[var(--color-text-tertiary)]">
|
||||
<div className="flex items-center gap-2"><Hammer size={16} className="text-[var(--color-text-muted)]" /> 质量大纲</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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 => (
|
||||
<div key={chap.id} className="relative">
|
||||
<div className="absolute -left-6 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={`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 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)] opacity-60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4 min-w-0">
|
||||
{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 text-[13px] font-sans transition-all"
|
||||
/>
|
||||
) : (
|
||||
<h4 className="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 && (
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
{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="pt-6 mt-4 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-[#DFDDD8] 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 uppercase tracking-[0.1em] whitespace-nowrap">
|
||||
Complete Workflow
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
import { X, Cpu, Database, Plus, ToggleRight, ToggleLeft, Trash2, HardDrive, Cloud, Server, Globe, Key, Wifi, Loader2 } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useAppStore } from '../../stores/useAppStore';
|
||||
import { useUIStore } from '../../stores/useUIStore';
|
||||
import { TestVectorDBConnection, TestLLMConnection } from '../../../bindings/engimind/internal/config/configservice.js';
|
||||
|
||||
export function SettingsModal() {
|
||||
const { isSettingsOpen, setSettingsOpen, activeSettingsTab, setActiveSettingsTab, isTestingConnection, setTestingConnection } = useUIStore();
|
||||
const { modelConfigs, vectorDB, addModel, deleteModel, updateModel, setVectorDB } = 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">
|
||||
{/* Header */}
|
||||
<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">Configuration</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">
|
||||
{/* Sidebar tabs */}
|
||||
<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} /> Models
|
||||
</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} /> Knowledge Base
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<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}
|
||||
/>
|
||||
) : (
|
||||
<VectorTab
|
||||
vectorDB={vectorDB}
|
||||
setVectorDB={setVectorDB}
|
||||
isTestingConnection={isTestingConnection}
|
||||
onTestConnection={handleTestConnection}
|
||||
/>
|
||||
)}
|
||||
</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"
|
||||
>
|
||||
Save & Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------- Sub-components ---------- */
|
||||
import { Settings } from 'lucide-react'; // ensure Settings is imported
|
||||
|
||||
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: 'Connection Successful' });
|
||||
} else {
|
||||
setTestResult({ id: cfg.id, ok: false, msg: 'Connection Failed: Unauthorized or Host Down' });
|
||||
}
|
||||
} catch (err: any) {
|
||||
setTestResult({ id: cfg.id, ok: false, msg: `Error: ${err}` });
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
// Auto-clear success message after 3 seconds
|
||||
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">Active Providers ({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} /> Add Provider
|
||||
</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 ? 'Connecting to Provider...' : testResult?.id === cfg.id ? testResult.msg : 'Validate configuration'}
|
||||
</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} />} Test Connection
|
||||
</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-[#CDE5D8] text-[#2H8B64] bg-[#E5F3ED]' :
|
||||
vectorDB.status === 'disconnected' ? 'border-[#F8D7D7] text-[var(--color-danger)] bg-[#FEF2F2]' :
|
||||
'border-[var(--color-border-subtle)] text-[var(--color-text-tertiary)] bg-[var(--color-surface-side)]'
|
||||
}`}>
|
||||
<Wifi size={12} /> {vectorDB.status === 'connected' ? 'Connected' : vectorDB.status === 'disconnected' ? 'Disconnected' : 'Not Validated'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 rounded-2xl border bg-[#F8F5F2] 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">Remote Qdrant Node</h4>
|
||||
<p className="text-[12px] text-[var(--color-text-secondary)] leading-relaxed">High-performance vector database, connected via 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} />} Test Connection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user