feat(frontend):优化前端页面设计

This commit is contained in:
2026-05-29 16:44:17 +00:00
parent a83c5fa5bd
commit affe460180
80 changed files with 2670 additions and 2678 deletions
+155 -11
View File
@@ -8,11 +8,17 @@
"name": "kilostar-dashboard",
"version": "0.0.0",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.15.1",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
@@ -223,6 +229,15 @@
"node": ">=6.0.0"
}
},
"node_modules/@babel/runtime": {
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz",
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/template": {
"version": "7.28.6",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
@@ -462,6 +477,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@fontsource/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/jetbrains-mono": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource/jetbrains-mono/-/jetbrains-mono-5.2.8.tgz",
"integrity": "sha512-6w8/SG4kqvIMu7xd7wt6x3idn1Qux3p9N62s6G3rfldOUYHpWcc2FKrqf+Vo44jRvqWj2oAtTHrZXEP23oSKwQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.2",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
@@ -1583,6 +1616,34 @@
"react-dom": ">=17"
}
},
"node_modules/@xyflow/react/node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"immer": ">=9.0.6",
"react": ">=16.8"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/@xyflow/system": {
"version": "0.0.76",
"resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.76.tgz",
@@ -2624,6 +2685,52 @@
"hermes-estree": "0.25.1"
}
},
"node_modules/html-parse-stringify": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/html-parse-stringify/-/html-parse-stringify-3.0.1.tgz",
"integrity": "sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==",
"license": "MIT",
"dependencies": {
"void-elements": "3.1.0"
}
},
"node_modules/i18next": {
"version": "26.3.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-26.3.0.tgz",
"integrity": "sha512-gHSgGpUXVmuqE2El1W61DmxeyeTlFfZgdJRWMo9jScAn5pu7TuTuiccb1zh3E2J9hEBVGJ23+96x0ieBhfuIHA==",
"funding": [
{
"type": "individual",
"url": "https://www.locize.com/i18next"
},
{
"type": "individual",
"url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project"
},
{
"type": "individual",
"url": "https://www.locize.com"
}
],
"license": "MIT",
"peerDependencies": {
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/i18next-browser-languagedetector": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.2.1.tgz",
"integrity": "sha512-bZg8+4bdmaOiApD7N7BPT9W8MLZG+nPTOFlLiJiT8uzKXFjhxw4v2ierCXOwB5sFDMtuA5G4kgYZ0AznZxQ/cw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.23.2"
}
},
"node_modules/ignore": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -3370,6 +3477,33 @@
"react": "^19.2.5"
}
},
"node_modules/react-i18next": {
"version": "17.0.8",
"resolved": "https://registry.npmjs.org/react-i18next/-/react-i18next-17.0.8.tgz",
"integrity": "sha512-0ooKbGLU8JXhe1zwpQUWIeXSgLPOfwJmgheWRIUpcoA0CpyabpGhayjdG+/eA5esC1AQ8h2jWpXjJfzQzeDOCw==",
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.29.2",
"html-parse-stringify": "^3.0.1",
"use-sync-external-store": "^1.6.0"
},
"peerDependencies": {
"i18next": ">= 26.2.0",
"react": ">= 16.8.0",
"typescript": "^5 || ^6"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
},
"react-native": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/resolve-from": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz",
@@ -3572,7 +3706,7 @@
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
@@ -3741,6 +3875,15 @@
}
}
},
"node_modules/void-elements": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/void-elements/-/void-elements-3.1.0.tgz",
"integrity": "sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -3811,20 +3954,18 @@
}
},
"node_modules/zustand": {
"version": "4.5.7",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
"integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==",
"version": "5.0.14",
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz",
"integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==",
"license": "MIT",
"dependencies": {
"use-sync-external-store": "^1.2.2"
},
"engines": {
"node": ">=12.7.0"
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=16.8",
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=16.8"
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
@@ -3835,6 +3976,9 @@
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
+7 -1
View File
@@ -10,11 +10,17 @@
"preview": "vite preview"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fontsource/jetbrains-mono": "^5.2.8",
"@xyflow/react": "^12.10.2",
"axios": "^1.15.1",
"i18next": "^26.3.0",
"i18next-browser-languagedetector": "^8.2.1",
"lucide-react": "^1.8.0",
"react": "^19.2.4",
"react-dom": "^19.2.4"
"react-dom": "^19.2.4",
"react-i18next": "^17.0.8",
"zustand": "^5.0.14"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
+63 -123
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react';
import { useEffect } from 'react';
import { TopBar } from './components/Layout/TopBar';
import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar';
import { SettingsLayout } from './components/Settings/SettingsLayout';
@@ -10,162 +10,75 @@ import { RightPanel } from './components/Chat/RightPanel';
import { WorkflowListView } from './components/Chat/WorkflowListView';
import { NewWorkflowDialog } from './components/Chat/NewWorkflowDialog';
import { AuthPage } from './components/Auth/AuthPage';
import apiClient from './api/client';
import type { ChatSessionDB } from './types';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface ChatSession {
id: string;
title: string;
messages: Message[];
updatedAt: number;
}
function mapSessionFromDB(s: ChatSessionDB): ChatSession {
return {
id: s.chat_id,
title: s.title,
messages: [],
updatedAt: new Date(s.updated_at).getTime(),
};
}
import { useAppStore } from './store/useAppStore';
import { useChatStore } from './store/useChatStore';
function App() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const {
isAuthenticated,
setIsAuthenticated,
mode,
showSettings,
workTab,
agentTab,
applyTheme,
} = useAppStore();
const [mode, setMode] = useState<'work' | 'agent'>('work');
const [showSettings, setShowSettings] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
const { loadSessions } = useChatStore();
const [workTab, setWorkTab] = useState<'chat' | 'workflow'>('chat');
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
const [agentTab, setAgentTab] = useState<'plugin' | 'agents'>('plugin');
const [settingsTab, setSettingsTab] = useState('users');
const [innerAgentTab, setInnerAgentTab] = useState('worker');
const [resourceTab, setResourceTab] = useState('skill');
const [chatSessions, setChatSessions] = useState<ChatSession[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const loadChatSessions = useCallback(async () => {
try {
const response = await apiClient.get('/api/v1/chat');
const sessions: ChatSessionDB[] = response.data?.sessions || [];
setChatSessions(sessions.map(mapSessionFromDB));
} catch (error) {
console.error('Failed to load chat sessions', error);
}
}, []);
// Initialize theme on mount
useEffect(() => {
applyTheme();
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
const handler = () => applyTheme();
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}, [applyTheme]);
// Check auth and load sessions
useEffect(() => {
const token = localStorage.getItem('token');
if (token) {
setIsAuthenticated(true);
}
}, []);
}, [setIsAuthenticated]);
useEffect(() => {
if (isAuthenticated) {
loadChatSessions();
loadSessions();
}
}, [isAuthenticated, loadChatSessions]);
}, [isAuthenticated, loadSessions]);
if (!isAuthenticated) {
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
}
return (
<div className="flex flex-col h-screen w-screen bg-slate-50 text-slate-800 font-sans overflow-hidden">
<TopBar
mode={mode}
setMode={setMode}
showSettings={showSettings}
setShowSettings={setShowSettings}
/>
<div className="flex flex-col h-screen w-screen bg-bg-primary text-text-primary font-sans overflow-hidden">
<TopBar />
<div className="flex flex-1 overflow-hidden relative">
{showSettings ? (
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
<SettingsLayout />
) : (
<>
<CollapsibleSidebar
mode={mode}
isOpen={isSidebarOpen}
setIsOpen={setIsSidebarOpen}
workTab={workTab}
setWorkTab={setWorkTab}
agentTab={agentTab}
setAgentTab={setAgentTab}
/>
<CollapsibleSidebar />
<div className="flex-1 flex overflow-hidden">
{mode === 'work' && workTab === 'chat' && (
<div className="flex-1 p-6 flex overflow-hidden">
<div className="flex-1 flex bg-white rounded-3xl shadow-md border border-slate-200 overflow-hidden relative">
<LeftPanel
activeTab="chats"
selectedWorkflow={null}
setSelectedWorkflow={() => {}}
chatSessions={chatSessions}
setChatSessions={setChatSessions}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
onSessionsChanged={loadChatSessions}
/>
<ChatPanel
chatSessions={chatSessions}
setChatSessions={setChatSessions}
activeSessionId={activeSessionId}
setActiveSessionId={setActiveSessionId}
onSessionsChanged={loadChatSessions}
/>
<div className="flex-1 p-4 flex overflow-hidden gap-4">
<div className="flex-1 flex bg-bg-card rounded-2xl shadow-sm border border-border-primary overflow-hidden relative">
<LeftPanel activeTab="chats" />
<ChatPanel />
</div>
</div>
)}
{mode === 'work' && workTab === 'workflow' && (
<>
{selectedWorkflow === 'new' ? (
<>
<LeftPanel
activeTab="workflows"
selectedWorkflow={selectedWorkflow}
setSelectedWorkflow={setSelectedWorkflow}
/>
<NewWorkflowDialog
onClose={() => setSelectedWorkflow(null)}
onSuccess={(traceId: string) => setSelectedWorkflow(traceId)}
/>
</>
) : selectedWorkflow ? (
<>
<LeftPanel
activeTab="workflows"
selectedWorkflow={selectedWorkflow}
setSelectedWorkflow={setSelectedWorkflow}
/>
<RightPanel selectedWorkflow={selectedWorkflow} />
</>
) : (
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
)}
</>
)}
{mode === 'work' && workTab === 'workflow' && <WorkflowShell />}
{mode === 'agent' && agentTab === 'agents' && (
<AgentLayout agentTab={innerAgentTab} setAgentTab={setInnerAgentTab} />
)}
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
{mode === 'agent' && agentTab === 'plugin' && (
<PluginLayout resourceTab={resourceTab} setResourceTab={setResourceTab} />
)}
{mode === 'agent' && agentTab === 'plugin' && <PluginLayout />}
</div>
</>
)}
@@ -174,4 +87,31 @@ function App() {
);
}
function WorkflowShell() {
const { selectedWorkflow, setSelectedWorkflow } = useChatStore();
if (selectedWorkflow === 'new') {
return (
<>
<LeftPanel activeTab="workflows" />
<NewWorkflowDialog
onClose={() => setSelectedWorkflow(null)}
onSuccess={(traceId: string) => setSelectedWorkflow(traceId)}
/>
</>
);
}
if (selectedWorkflow) {
return (
<>
<LeftPanel activeTab="workflows" />
<RightPanel selectedWorkflow={selectedWorkflow} />
</>
);
}
return <WorkflowListView onSelectWorkflow={setSelectedWorkflow} />;
}
export default App;
+27 -28
View File
@@ -1,38 +1,37 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { ProvidersSettings } from './ProvidersSettings';
import { WorkerIndividualSettings } from './WorkerIndividualSettings';
interface AgentLayoutProps {
agentTab: string;
setAgentTab: (tab: string) => void;
}
export function AgentLayout() {
const { t } = useTranslation();
const { innerAgentTab, setInnerAgentTab } = useAppStore();
const tabs = [
{ key: 'worker', label: t('agent.individual') },
{ key: 'providers', label: t('agent.providerManagement') },
];
export function AgentLayout({ agentTab, setAgentTab }: AgentLayoutProps) {
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
{/* Top Tabs for Agent Module */}
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shadow-sm z-10 shrink-0 space-x-6">
<button
onClick={() => setAgentTab('worker')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
agentTab === 'worker' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
Individual
</button>
<button
onClick={() => setAgentTab('providers')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
agentTab === 'providers' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
Provider Management
</button>
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0 gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setInnerAgentTab(tab.key)}
className={`px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
innerAgentTab === tab.key
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto p-8">
{agentTab === 'worker' && <WorkerIndividualSettings />}
{agentTab === 'providers' && <ProvidersSettings />}
{innerAgentTab === 'worker' && <WorkerIndividualSettings />}
{innerAgentTab === 'providers' && <ProvidersSettings />}
</div>
</div>
);
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Box, Plus, X } from 'lucide-react';
import { Box, Plus, X, Server } from 'lucide-react';
import type { Provider } from '../../types';
import apiClient from '../../api/client';
@@ -7,12 +7,7 @@ export function ProvidersSettings() {
const [providers, setProviders] = useState<Provider[]>([]);
const [loading, setLoading] = useState(true);
const [isModalOpen, setIsModalOpen] = useState(false);
const [formData, setFormData] = useState({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
const [formData, setFormData] = useState({ provider_type: 'openai', provider_title: '', provider_url: '', provider_apikey: '' });
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
@@ -20,9 +15,7 @@ export function ProvidersSettings() {
setLoading(true);
try {
const response = await apiClient.get('/api/v1/provider/list');
const data = response.data.provider_list || {};
const providerArray: Provider[] = Object.values(data);
setProviders(providerArray);
setProviders(Object.values(response.data.provider_list || {}));
} catch (error) {
console.error("Failed to fetch providers", error);
setProviders([]);
@@ -31,32 +24,7 @@ export function ProvidersSettings() {
}
};
useEffect(() => {
// eslint-disable-next-line react-hooks/set-state-in-effect
fetchProviders();
}, []);
const handleOpenModal = () => {
setFormData({
provider_type: 'openai',
provider_title: '',
provider_url: '',
provider_apikey: ''
});
setError('');
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
useEffect(() => { fetchProviders(); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -64,15 +32,13 @@ export function ProvidersSettings() {
setError('Please fill in all fields.');
return;
}
setSubmitLoading(true);
setError('');
try {
await apiClient.post('/api/v1/provider', formData);
await fetchProviders();
handleCloseModal();
setIsModalOpen(false);
} catch (err) {
console.error("Error adding provider", err);
setError('Failed to add provider. Please check your inputs and try again.');
} finally {
setSubmitLoading(false);
@@ -80,174 +46,97 @@ export function ProvidersSettings() {
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="max-w-4xl mx-auto space-y-6">
<div className="flex justify-between items-end">
<div>
<h3 className="text-xl font-semibold text-slate-800">Provider Management</h3>
<p className="text-sm text-slate-500 mt-1">Configure external AI model providers and API keys.</p>
<h3 className="text-lg font-bold text-text-primary">Provider Management</h3>
<p className="text-sm text-text-muted mt-0.5">Configure external AI model providers</p>
</div>
<button
onClick={handleOpenModal}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
>
<Plus size={16} className="mr-2" />
Add Provider
<button onClick={() => { setFormData({ provider_type: 'openai', provider_title: '', provider_url: '', provider_apikey: '' }); setError(''); setIsModalOpen(true); }}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
<Plus size={14} /> Add Provider
</button>
</div>
{loading ? (
<div className="text-center text-slate-500 py-8">Loading providers...</div>
<div className="text-center text-text-muted py-12 text-sm">Loading providers...</div>
) : providers.length === 0 ? (
<div className="text-center text-slate-500 py-8 bg-white rounded-xl border border-slate-200">
No providers configured yet. Click "Add Provider" to get started.
<div className="text-center text-text-muted py-12 bg-bg-card rounded-2xl border border-border-primary border-dashed text-sm">
No providers configured yet
</div>
) : (
<div className="grid grid-cols-2 gap-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{providers.map((provider, i) => (
<div key={i} className="bg-white border border-slate-200 p-5 rounded-xl shadow-sm hover:border-blue-200 transition-colors flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-4">
<div className="flex items-center">
<div className="w-10 h-10 rounded-lg bg-slate-50 border border-slate-100 flex items-center justify-center mr-3">
<Box size={20} className="text-slate-600" />
</div>
<div>
<h4 className="font-semibold text-slate-800">{provider.provider_title}</h4>
<span className="text-xs text-slate-500 font-mono uppercase">{provider.provider_type}</span>
</div>
<div key={i} className="bg-bg-card border border-border-primary rounded-2xl p-5 card-hover">
<div className="flex justify-between items-start mb-4">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-bg-secondary border border-border-secondary flex items-center justify-center">
<Box size={18} className="text-text-secondary" />
</div>
<span className={`flex items-center text-xs font-medium px-2 py-1 rounded-md border ${provider.status === 'Connected' ? 'bg-green-50 text-green-700 border-green-200' : 'bg-slate-50 text-slate-500 border-slate-200'}`}>
{provider.status === 'Connected' && <span className="w-1.5 h-1.5 rounded-full bg-green-500 mr-1.5"></span>}
{provider.status || 'Unknown'}
</span>
</div>
<div className="mb-4">
<p className="text-sm text-slate-600 mb-1">URL / Endpoint:</p>
<div className="bg-slate-50 border border-slate-100 rounded text-sm px-3 py-1.5 font-mono text-slate-700 truncate" title={provider.provider_url}>
{provider.provider_url || 'Default'}
<div>
<h4 className="font-semibold text-sm text-text-primary">{provider.provider_title}</h4>
<span className="text-[10px] text-text-muted font-mono uppercase">{provider.provider_type}</span>
</div>
</div>
<span className={`flex items-center gap-1 text-[10px] font-medium px-2 py-1 rounded-lg border ${provider.status === 'Connected' ? 'bg-success-bg text-success border-success/20' : 'bg-bg-secondary text-text-muted border-border-primary'}`}>
{provider.status === 'Connected' && <span className="w-1 h-1 rounded-full bg-success" />}
{provider.status || 'Unknown'}
</span>
</div>
<div className="flex justify-end space-x-2 mt-2">
<button className="px-3 py-1.5 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded hover:bg-slate-50 transition-colors cursor-pointer">Edit</button>
<button
onClick={async () => {
if (!confirm('Are you sure you want to delete this provider?')) return;
try {
await apiClient.delete(`/api/v1/provider/${provider.provider_title}`);
fetchProviders();
} catch (err) {
console.error('Failed to delete provider', err);
alert('Failed to delete provider');
}
}}
className="px-3 py-1.5 text-sm font-medium text-red-600 bg-white border border-slate-200 rounded hover:bg-red-50 transition-colors cursor-pointer"
>
Delete
</button>
<div className="bg-bg-secondary rounded-lg px-3 py-2 mb-4">
<p className="text-[10px] text-text-muted mb-0.5">Endpoint</p>
<p className="text-xs font-mono text-text-secondary truncate">{provider.provider_url || 'Default'}</p>
</div>
<div className="flex justify-end gap-2">
<button className="px-3 py-1.5 text-xs font-medium text-text-secondary bg-bg-secondary hover:bg-bg-hover rounded-lg transition-colors border border-border-primary">Edit</button>
<button onClick={async () => {
if (!confirm('Delete this provider?')) return;
try { await apiClient.delete(`/api/v1/provider/${provider.provider_title}`); fetchProviders(); } catch { alert('Failed'); }
}} className="px-3 py-1.5 text-xs font-medium text-danger bg-danger-bg hover:bg-danger-bg/80 rounded-lg transition-colors border border-danger/20">Delete</button>
</div>
</div>
))}
</div>
)}
{/* Add Provider Modal */}
{/* Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Add New Provider</h3>
<button
onClick={handleCloseModal}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
</button>
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-md overflow-hidden border border-border-primary animate-fade-in-scale">
<div className="flex justify-between items-center p-5 border-b border-border-primary">
<div className="flex items-center gap-2">
<Server size={16} className="text-accent" />
<h3 className="text-base font-bold text-text-primary">Add New Provider</h3>
</div>
<button onClick={() => setIsModalOpen(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={18} /></button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Type</label>
<select
name="provider_type"
value={formData.provider_type}
onChange={handleChange}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Type</label>
<select name="provider_type" value={formData.provider_type} onChange={(e) => setFormData({...formData, provider_type: e.target.value})}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary">
<option value="openai">OpenAI</option>
<option value="deepseek">DeepSeek</option>
<option value="claude">Claude</option>
<option value="local">Local</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Provider Title</label>
<input
type="text"
name="provider_title"
placeholder="e.g. My OpenAI Instance"
value={formData.provider_title}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Base URL</label>
<input
type="url"
name="provider_url"
placeholder="e.g. https://api.openai.com/v1"
value={formData.provider_url}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">API Key</label>
<input
type="password"
name="provider_apikey"
placeholder="sk-..."
value={formData.provider_apikey}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400 font-mono"
/>
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Saving...
</span>
) : (
'Add Provider'
)}
</button>
{['provider_title', 'provider_url', 'provider_apikey'].map((field) => (
<div key={field}>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">
{field === 'provider_title' ? 'Title' : field === 'provider_url' ? 'Base URL' : 'API Key'}
</label>
<input type={field === 'provider_apikey' ? 'password' : field === 'provider_url' ? 'url' : 'text'}
name={field} value={(formData as any)[field]}
onChange={(e) => setFormData({...formData, [field]: e.target.value})}
placeholder={field === 'provider_title' ? 'My OpenAI' : field === 'provider_url' ? 'https://api.openai.com/v1' : 'sk-...'}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50 font-mono" />
</div>
))}
<div className="pt-2 flex justify-end gap-2">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">Cancel</button>
<button type="submit" disabled={submitLoading} className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">{submitLoading ? 'Saving...' : 'Add Provider'}</button>
</div>
</form>
</div>
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import apiClient from '../../api/client';
import { Save, Plus, Edit2, Trash2, X } from 'lucide-react';
import { Save, Plus, Edit2, Trash2, X, Bot } from 'lucide-react';
import type { Provider } from '../../types';
interface WorkerIndividual {
@@ -11,10 +11,10 @@ interface WorkerIndividual {
provider_title: string;
model_id: string;
system_prompt?: string;
output_template?: string; // Change to string for the form state
bound_skill?: string; // Change to string for the form state
workspace?: string; // Change to string for the form state
tools?: string; // Form state for tools JSON array
output_template?: string;
bound_skill?: string;
workspace?: string;
tools?: string;
}
export function WorkerIndividualSettings() {
@@ -25,12 +25,11 @@ export function WorkerIndividualSettings() {
const [availableTools, setAvailableTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<WorkerIndividual>>({});
const [isNew, setIsNew] = useState(false);
const [modalMessage, setModalMessage] = useState('');
const [submitLoading, setSubmitLoading] = useState(false);
const fetchData = async () => {
setLoading(true);
@@ -44,43 +43,34 @@ export function WorkerIndividualSettings() {
]);
setProviders(Object.values(provRes.data.provider_list || {}));
setWorkers(workRes.data.workers || []);
const allTools = toolsRes.data.tools || [];
setAvailableTools(allTools);
setAvailableTools(toolsRes.data.tools || []);
setAvailableSkills(Object.keys(skillsRes.data.skills || {}));
const sysNodesData = sysRes.data.system_nodes || [];
const defaultSysNodes = ['regulatory_node', 'consciousness_node', 'control_node'];
const providersList = Object.values(provRes.data.provider_list || {}) as Provider[];
const defaultProvider = providersList.length > 0 ? providersList[0].provider_title : '';
const sysNodesData = sysRes.data.system_nodes || [];
const defaultSysNodes = ['regulatory_node', 'consciousness_node', 'control_node'];
const formattedSysNodes = defaultSysNodes.map(nodeName => {
setSystemNodes(defaultSysNodes.map(nodeName => {
const found = sysNodesData.find((n: any) => n.node_name === nodeName);
return {
agent_id: nodeName,
agent_name: nodeName,
agent_type: 'System Node',
provider_title: found && found.provider_title ? found.provider_title : defaultProvider,
model_id: found && found.model_id ? found.model_id : '',
tools: found && found.tools ? JSON.stringify(found.tools) : '[]',
agent_id: nodeName, agent_name: nodeName, agent_type: 'System Node',
provider_title: found?.provider_title || defaultProvider,
model_id: found?.model_id || '',
tools: found?.tools ? JSON.stringify(found.tools) : '[]',
is_system: true
};
});
setSystemNodes(formattedSysNodes);
}));
} catch (err: any) {
console.error(err);
setError('Failed to load data');
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => { fetchData(); }, []);
const handleEdit = (worker: any) => { // Accept the backend object which might have objects instead of strings
const handleEdit = (worker: any) => {
setEditData({
...worker,
output_template: typeof worker.output_template === 'string' ? worker.output_template : JSON.stringify(worker.output_template || {}),
@@ -94,46 +84,31 @@ export function WorkerIndividualSettings() {
};
const handleAddNew = () => {
setEditData({
agent_name: '',
agent_type: 'ordinary_individual',
description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '',
model_id: '',
system_prompt: '',
output_template: '{}',
bound_skill: '{}',
workspace: '[]',
tools: '[]'
});
setEditData({ agent_name: '', agent_type: 'ordinary_individual', description: '',
provider_title: providers.length > 0 ? providers[0].provider_title : '', model_id: '',
system_prompt: '', output_template: '{}', bound_skill: '{}', workspace: '[]', tools: '[]' });
setIsNew(true);
setIsEditing(true);
setModalMessage('');
};
const handleDelete = async (agent_id: string) => {
if (!confirm('Are you sure you want to delete this agent?')) return;
try {
await apiClient.delete(`/api/v1/agent/worker/${agent_id}`);
fetchData();
} catch (err: any) {
console.error(err);
alert('Failed to delete agent');
}
if (!confirm('Delete this agent?')) return;
try { await apiClient.delete(`/api/v1/agent/worker/${agent_id}`); fetchData(); } catch { alert('Failed'); }
};
const handleModalSave = async (e: React.FormEvent) => {
e.preventDefault();
setModalMessage('');
setSubmitLoading(true);
try {
if ((editData as any).is_system) {
const payload = {
await apiClient.post('/api/v1/agent', {
individual_name: editData.agent_name,
provider_title: editData.provider_title,
model_id: editData.model_id,
tools: JSON.parse(editData.tools || '[]')
};
await apiClient.post('/api/v1/agent', payload);
});
} else {
const payload = {
...editData,
@@ -142,305 +117,208 @@ export function WorkerIndividualSettings() {
workspace: JSON.parse(editData.workspace || '[]'),
tools: JSON.parse(editData.tools || '[]')
};
if (isNew) {
await apiClient.post('/api/v1/agent/worker', payload);
} else {
await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
}
if (isNew) await apiClient.post('/api/v1/agent/worker', payload);
else await apiClient.put(`/api/v1/agent/worker/${editData.agent_id}`, payload);
}
setIsEditing(false);
fetchData();
} catch (err: any) {
console.error(err);
setModalMessage(err.response?.data?.detail || err.message || 'Failed to save');
} finally {
setSubmitLoading(false);
}
};
const getTypeBadge = (type: string, isSystem?: boolean) => {
if (isSystem) return <span className="px-2 py-0.5 rounded-md text-[10px] font-bold bg-accent-light text-accent uppercase tracking-wider">System</span>;
const colors: Record<string, string> = {
ordinary_individual: 'bg-bg-secondary text-text-muted',
skill_individual: 'bg-success-bg text-success',
special_individual: 'bg-warning-bg text-warning',
};
return <span className={`px-2 py-0.5 rounded-md text-[10px] font-medium ${colors[type] || colors.ordinary_individual}`}>{type.replace('_', ' ')}</span>;
};
return (
<div className="max-w-5xl space-y-6 relative">
<div className="mb-8 flex justify-between items-end">
<div className="max-w-5xl space-y-6">
<div className="flex justify-between items-end">
<div>
<h1 className="text-2xl font-bold text-slate-800">Individual</h1>
<p className="text-slate-500 mt-1">Manage all system nodes and custom workers.</p>
<h1 className="text-lg font-bold text-text-primary">Individual</h1>
<p className="text-sm text-text-muted mt-0.5">Manage system nodes and custom workers</p>
</div>
<button
onClick={handleAddNew}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Plus size={16} className="mr-2" />
Add Worker
<button onClick={handleAddNew} className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium">
<Plus size={14} /> Add Worker
</button>
</div>
{error && <div className="text-red-600">{error}</div>}
{error && <div className="text-sm text-danger bg-danger-bg border border-danger/20 rounded-xl p-3">{error}</div>}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-0">
{loading ? (
<div className="p-6 text-slate-500">Loading...</div>
) : (workers.length === 0 && systemNodes.length === 0) ? (
<div className="p-6 text-slate-500">No individuals found.</div>
) : (
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-slate-50 border-b border-slate-200 text-slate-600 text-sm">
<th className="p-4 font-semibold">Name</th>
<th className="p-4 font-semibold">Type</th>
<th className="p-4 font-semibold">Provider / Model ID</th>
<th className="p-4 font-semibold text-right">Actions</th>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
{loading ? (
<div className="p-6 text-text-muted text-sm">Loading...</div>
) : (workers.length === 0 && systemNodes.length === 0) ? (
<div className="p-6 text-text-muted text-sm">No individuals found.</div>
) : (
<table className="w-full text-left text-sm">
<thead>
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
<th className="px-5 py-3 font-semibold">Name</th>
<th className="px-5 py-3 font-semibold">Type</th>
<th className="px-5 py-3 font-semibold">Provider / Model</th>
<th className="px-5 py-3 font-semibold text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border-secondary">
{systemNodes.map((w) => (
<tr key={w.agent_id} className="bg-bg-secondary/50 hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
<Bot size={14} className="text-accent" />
</div>
<span className="font-medium text-text-primary text-xs">{w.agent_name}</span>
</div>
</td>
<td className="px-5 py-3">{getTypeBadge(w.agent_type, true)}</td>
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all"><Edit2 size={14} /></button>
</td>
</tr>
</thead>
<tbody>
{systemNodes.map((w) => (
<tr key={w.agent_id} className="border-b border-slate-100 bg-slate-50 hover:bg-slate-100 transition-colors">
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
<td className="p-4 text-slate-600">
<span className="px-2 py-1 bg-blue-100 text-blue-800 rounded text-xs">{w.agent_type}</span>
</td>
<td className="p-4 text-slate-600 text-sm">
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
</td>
<td className="p-4 text-right space-x-2">
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
<Edit2 size={16} />
</button>
</td>
</tr>
))}
{workers.map((w) => (
<tr key={w.agent_id} className="border-b border-slate-100 hover:bg-slate-50 transition-colors">
<td className="p-4 font-medium text-slate-800">{w.agent_name}</td>
<td className="p-4 text-slate-600">
<span className="px-2 py-1 bg-slate-100 rounded text-xs">{w.agent_type}</span>
</td>
<td className="p-4 text-slate-600 text-sm">
{w.provider_title} <span className="text-slate-400">/</span> {w.model_id}
</td>
<td className="p-4 text-right space-x-2">
<button onClick={() => handleEdit(w)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors" title="Edit">
<Edit2 size={16} />
</button>
<button onClick={() => handleDelete(w.agent_id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors" title="Delete">
<Trash2 size={16} />
</button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
))}
{workers.map((w) => (
<tr key={w.agent_id} className="hover:bg-bg-hover transition-colors">
<td className="px-5 py-3">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-bg-secondary border border-border-primary flex items-center justify-center">
<Bot size={14} className="text-text-muted" />
</div>
<span className="font-medium text-text-primary text-xs">{w.agent_name}</span>
</div>
</td>
<td className="px-5 py-3">{getTypeBadge(w.agent_type)}</td>
<td className="px-5 py-3 text-xs text-text-muted">{w.provider_title} / {w.model_id}</td>
<td className="px-5 py-3 text-right">
<button onClick={() => handleEdit(w)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-0.5"><Edit2 size={14} /></button>
<button onClick={() => handleDelete(w.agent_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all"><Trash2 size={14} /></button>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
{/* Edit/Create Modal */}
{/* Modal */}
{isEditing && (
<div className="fixed inset-0 bg-black/50 z-50 flex items-center justify-center p-4">
<div className="bg-white rounded-xl shadow-xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
<div className="flex justify-between items-center p-6 border-b border-slate-100 sticky top-0 bg-white z-10">
<h2 className="text-xl font-bold text-slate-800">
{(editData as any).is_system ? 'Edit System Node' : (isNew ? 'Create Worker' : 'Edit Worker')}
</h2>
<button onClick={() => setIsEditing(false)} className="text-slate-400 hover:text-slate-600">
<X size={24} />
</button>
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto border border-border-primary">
<div className="flex justify-between items-center p-5 border-b border-border-primary sticky top-0 bg-bg-card z-10">
<h2 className="text-base font-bold text-text-primary">{(editData as any).is_system ? 'Edit System Node' : (isNew ? 'Create Worker' : 'Edit Worker')}</h2>
<button onClick={() => setIsEditing(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors"><X size={20} /></button>
</div>
<form onSubmit={handleModalSave} className="p-6 space-y-4">
<form onSubmit={handleModalSave} className="p-5 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Name</label>
<input
type="text"
required
value={editData.agent_name || ''}
onChange={(e) => setEditData({...editData, agent_name: e.target.value})}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={(editData as any).is_system}
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Name</label>
<input type="text" required value={editData.agent_name || ''} onChange={(e) => setEditData({...editData, agent_name: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={(editData as any).is_system} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Agent Type</label>
<select
value={editData.agent_type || 'ordinary_individual'}
onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={(editData as any).is_system}
>
<option value="ordinary_individual">Ordinary Individual</option>
<option value="skill_individual">Skill Individual</option>
<option value="special_individual">Special Individual</option>
{(editData as any).is_system && (
<option value="System Node">System Node</option>
)}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Type</label>
<select value={editData.agent_type || 'ordinary_individual'} onChange={(e) => setEditData({...editData, agent_type: e.target.value})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={(editData as any).is_system}>
<option value="ordinary_individual">Ordinary</option>
<option value="skill_individual">Skill</option>
<option value="special_individual">Special</option>
{(editData as any).is_system && <option value="System Node">System Node</option>}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Provider Title</label>
<select
value={editData.provider_title || ''}
onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})}
required
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="" disabled>Select Provider</option>
{providers.map((p) => (
<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>
))}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Provider</label>
<select value={editData.provider_title || ''} onChange={(e) => setEditData({...editData, provider_title: e.target.value, model_id: ''})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>Select</option>
{providers.map((p) => (<option key={p.provider_title} value={p.provider_title}>{p.provider_title}</option>))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Model ID</label>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Model</label>
{(() => {
const selectedProvider = providers.find(p => p.provider_title === editData.provider_title);
const models = selectedProvider?.provider_models || [];
const sp = providers.find(p => p.provider_title === editData.provider_title);
const models = sp?.provider_models || [];
return (
<select
value={editData.model_id || ''}
onChange={(e) => setEditData({...editData, model_id: e.target.value})}
required
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
>
<option value="" disabled>Select a model</option>
<select value={editData.model_id || ''} onChange={(e) => setEditData({...editData, model_id: e.target.value})} required
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent">
<option value="" disabled>Select</option>
{models.map(m => <option key={m} value={m}>{m}</option>)}
</select>
);
})()}
</div>
</div>
{!(editData as any).is_system && (
<>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Description</label>
<textarea
value={editData.description || ''}
onChange={(e) => setEditData({...editData, description: e.target.value})}
rows={2}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Description</label>
<textarea value={editData.description || ''} onChange={(e) => setEditData({...editData, description: e.target.value})} rows={2}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">System Prompt</label>
<textarea
value={editData.system_prompt || ''}
onChange={(e) => setEditData({...editData, system_prompt: e.target.value})}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">System Prompt</label>
<textarea value={editData.system_prompt || ''} onChange={(e) => setEditData({...editData, system_prompt: e.target.value})} rows={3}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Output Template (JSON)</label>
<textarea
value={editData.output_template || '{}'}
onChange={(e) => setEditData({...editData, output_template: e.target.value})}
rows={3}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Output Template (JSON)</label>
<textarea value={editData.output_template || '{}'} onChange={(e) => setEditData({...editData, output_template: e.target.value})} rows={3}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Bound Skill (Select)</label>
<select
value={(() => {
try {
const parsed = JSON.parse(editData.bound_skill || '{}');
return Object.keys(parsed)[0] || '';
} catch { return ''; }
})()}
onChange={(e) => {
const val = e.target.value;
const newSkill = val ? { [val]: [] } : {};
setEditData({...editData, bound_skill: JSON.stringify(newSkill)});
}}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500"
disabled={editData.agent_type !== 'skill_individual'}
>
<option value="">No Skill Bound</option>
{availableSkills.map(skill => (
<option key={skill} value={skill}>{skill}</option>
))}
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Bound Skill</label>
<select value={(() => { try { return Object.keys(JSON.parse(editData.bound_skill || '{}'))[0] || ''; } catch { return ''; } })()}
onChange={(e) => setEditData({...editData, bound_skill: JSON.stringify(e.target.value ? { [e.target.value]: [] } : {})})}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" disabled={editData.agent_type !== 'skill_individual'}>
<option value="">None</option>
{availableSkills.map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Workspace (JSON Array)</label>
<textarea
value={editData.workspace || '[]'}
onChange={(e) => setEditData({...editData, workspace: e.target.value})}
rows={2}
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:ring-2 focus:ring-blue-500 font-mono text-sm"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Workspace (JSON)</label>
<textarea value={editData.workspace || '[]'} onChange={(e) => setEditData({...editData, workspace: e.target.value})} rows={2}
className="w-full px-3 py-2 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary font-mono focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent" />
</div>
</>
)}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Tools (Select Multiple)</label>
<div className="flex flex-wrap gap-2 p-4 border border-slate-200 rounded-lg max-h-48 overflow-y-auto">
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Tools</label>
<div className="flex flex-wrap gap-1.5 p-3 bg-bg-input border border-border-primary rounded-xl max-h-40 overflow-y-auto">
{availableTools.map(tool => {
let currentTools: string[] = [];
try {
currentTools = JSON.parse(editData.tools || '[]');
} catch { currentTools = []; }
try { currentTools = JSON.parse(editData.tools || '[]'); } catch { }
const isSelected = currentTools.includes(tool);
return (
<button
key={tool}
type="button"
onClick={() => {
let updatedTools = [...currentTools];
if (isSelected) {
updatedTools = updatedTools.filter(t => t !== tool);
} else {
updatedTools.push(tool);
}
setEditData({...editData, tools: JSON.stringify(updatedTools)});
}}
className={`px-3 py-1.5 text-sm rounded-full transition-colors ${
isSelected
? 'bg-blue-100 text-blue-700 border border-blue-200'
: 'bg-slate-50 text-slate-600 border border-slate-200 hover:bg-slate-100'
}`}
>
<button key={tool} type="button" onClick={() => {
const updated = isSelected ? currentTools.filter(t => t !== tool) : [...currentTools, tool];
setEditData({...editData, tools: JSON.stringify(updated)});
}}
className={`px-2.5 py-1 rounded-lg text-xs font-medium transition-all ${isSelected ? 'bg-accent-light text-accent border border-accent/20' : 'bg-bg-secondary text-text-muted border border-border-primary hover:border-text-muted'}`}>
{tool}
</button>
);
})}
{availableTools.length === 0 && (
<span className="text-sm text-slate-500">No tools available</span>
)}
{availableTools.length === 0 && <span className="text-xs text-text-muted">No tools</span>}
</div>
</div>
{modalMessage && (
<div className="p-3 bg-red-50 text-red-700 text-sm rounded-lg">
{modalMessage}
</div>
)}
<div className="pt-4 flex justify-end space-x-3 border-t border-slate-100">
<button
type="button"
onClick={() => setIsEditing(false)}
className="px-4 py-2 text-slate-600 hover:bg-slate-100 rounded-lg transition-colors"
>
Cancel
</button>
<button
type="submit"
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors"
>
<Save size={16} className="mr-2" />
Save Worker
{modalMessage && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{modalMessage}</div>}
<div className="pt-3 flex justify-end gap-2 border-t border-border-primary">
<button type="button" onClick={() => setIsEditing(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">Cancel</button>
<button type="submit" disabled={submitLoading} className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
<Save size={14} /> {submitLoading ? 'Saving...' : 'Save'}
</button>
</div>
</form>
+93 -70
View File
@@ -1,12 +1,14 @@
import React, { useState } from 'react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import { Activity } from 'lucide-react';
import { Zap, ArrowRight, ShieldCheck } from 'lucide-react';
interface AuthPageProps {
onLoginSuccess: () => void;
}
export function AuthPage({ onLoginSuccess }: AuthPageProps) {
const { t } = useTranslation();
const [isLogin, setIsLogin] = useState(true);
const [userName, setUserName] = useState('');
const [password, setPassword] = useState('');
@@ -20,28 +22,22 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
try {
if (isLogin) {
// Login
const response = await apiClient.post('/api/v1/auth/login', {
user_name: userName,
password: password,
});
if (response.data.token) {
localStorage.setItem('token', response.data.token);
onLoginSuccess();
}
} else {
// Register
const response = await apiClient.post('/api/v1/auth/register', {
user_name: userName,
password: password,
});
// After successful register, we can automatically log them in
// or just show a message and switch to login. Let's switch to login.
if (response.data.message === 'success') {
setIsLogin(true);
setError('Registration successful. Please log in.');
setError(t('auth.registerSuccess'));
}
}
} catch (err: any) {
@@ -53,70 +49,97 @@ export function AuthPage({ onLoginSuccess }: AuthPageProps) {
};
return (
<div className="flex min-h-screen w-full items-center justify-center bg-slate-50">
<div className="w-full max-w-md bg-white rounded-xl shadow-lg p-8 border border-slate-100">
<div className="flex flex-col items-center mb-8">
<div className="w-12 h-12 bg-blue-600 rounded-xl flex items-center justify-center text-white mb-4 shadow-md shadow-blue-200">
<Activity size={24} />
<div className="flex min-h-screen w-full relative overflow-hidden">
{/* Background decoration */}
<div className="absolute inset-0 bg-bg-primary">
<div className="absolute top-0 left-1/4 w-96 h-96 bg-accent/5 rounded-full blur-3xl" />
<div className="absolute bottom-0 right-1/4 w-96 h-96 bg-glow-purple/5 rounded-full blur-3xl" />
</div>
<div className="relative z-10 flex w-full items-center justify-center p-6">
<div className="w-full max-w-md animate-fade-in-scale">
{/* Logo */}
<div className="flex flex-col items-center mb-8">
<div className="w-14 h-14 rounded-2xl bg-gradient-to-br from-accent to-glow-purple flex items-center justify-center text-white shadow-xl shadow-accent/20 mb-5">
<Zap size={28} fill="currentColor" />
</div>
<h1 className="text-2xl font-bold text-text-primary tracking-tight">{t('app.name')}</h1>
<p className="text-sm text-text-muted mt-1.5">{t('app.tagline')}</p>
</div>
<h2 className="text-2xl font-bold text-slate-800">
{isLogin ? 'Welcome Back' : 'Create Account'}
</h2>
<p className="text-slate-500 mt-2 text-sm">
{isLogin ? 'Enter your credentials to access your account' : 'Sign up to start using the platform'}
<div className="bg-bg-card/80 backdrop-blur-xl rounded-2xl border border-border-primary shadow-xl shadow-black/5 p-8">
<div className="flex items-center gap-2 mb-6">
<ShieldCheck size={18} className="text-accent" />
<h2 className="text-lg font-semibold text-text-primary">
{isLogin ? t('auth.welcomeBack') : t('auth.createAccount')}
</h2>
</div>
{error && (
<div className={`mb-5 p-3 rounded-xl text-sm border ${error.includes('success') || error.includes('成功') ? 'bg-success-bg/50 text-success border-success/20' : 'bg-danger-bg/50 text-danger border-danger/20'}`}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('auth.username')}</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full px-4 py-2.5 bg-bg-input border border-border-primary rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all text-text-primary placeholder:text-text-muted/60 text-sm"
placeholder="Enter your username"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('auth.password')}</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2.5 bg-bg-input border border-border-primary rounded-xl focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent transition-all text-text-primary placeholder:text-text-muted/60 text-sm"
placeholder="Enter your password"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-accent text-white rounded-xl font-semibold hover:bg-accent-hover focus:outline-none focus:ring-2 focus:ring-accent/30 transition-all disabled:opacity-50 cursor-pointer text-sm flex items-center justify-center gap-2 group"
>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" />
{t('auth.processing')}
</span>
) : (
<>
{isLogin ? t('auth.signIn') : t('auth.signUp')}
<ArrowRight size={16} className="transition-transform group-hover:translate-x-0.5" />
</>
)}
</button>
</form>
<div className="mt-6 text-center text-sm text-text-muted">
{isLogin ? t('auth.noAccount') : t('auth.hasAccount')}{' '}
<button
onClick={() => { setIsLogin(!isLogin); setError(''); }}
className="text-accent font-semibold hover:text-accent-hover transition-colors"
>
{isLogin ? t('auth.signUp') : t('auth.signIn')}
</button>
</div>
</div>
<p className="text-center text-xs text-text-muted/60 mt-6">
KiloStar Distributed Multi-Agent System
</p>
</div>
{error && (
<div className={`mb-4 p-3 rounded-lg text-sm ${error.includes('successful') ? 'bg-green-50 text-green-700 border border-green-200' : 'bg-red-50 text-red-700 border border-red-200'}`}>
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Username</label>
<input
type="text"
value={userName}
onChange={(e) => setUserName(e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Password</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full px-4 py-2 border border-slate-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-shadow"
required
/>
</div>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-blue-600 text-white rounded-lg font-medium hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors disabled:opacity-50"
>
{loading ? 'Processing...' : (isLogin ? 'Sign In' : 'Sign Up')}
</button>
</form>
<div className="mt-6 text-center text-sm text-slate-500">
{isLogin ? "Don't have an account? " : "Already have an account? "}
<button
onClick={() => {
setIsLogin(!isLogin);
setError('');
}}
className="text-blue-600 font-medium hover:text-blue-700 focus:outline-none"
>
{isLogin ? 'Sign up' : 'Sign in'}
</button>
</div>
</div>
</div>
);
+140 -202
View File
@@ -1,275 +1,213 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { MessageSquare, Activity, ChevronRight, Plus } from 'lucide-react';
import apiClient from '../../api/client';
import type { ChatSession, Message } from '../../App';
import type { ChatMessageDB } from '../../types';
import { useState, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Activity, ArrowUp, Plus, Sparkles, Code, FileText, Search } from 'lucide-react';
import { useChatStore } from '../../store/useChatStore';
interface ChatPanelProps {
chatSessions: ChatSession[];
setChatSessions: React.Dispatch<React.SetStateAction<ChatSession[]>>;
activeSessionId: string | null;
setActiveSessionId: React.Dispatch<React.SetStateAction<string | null>>;
onSessionsChanged?: () => void;
}
const QUICK_ACTIONS = [
{ icon: Sparkles, label: ' brainstorm', prompt: '帮我头脑风暴一些创意' },
{ icon: Code, label: '写代码', prompt: '帮我写一段 Python 代码' },
{ icon: FileText, label: '总结文档', prompt: '帮我总结这篇文档' },
{ icon: Search, label: '查找资料', prompt: '帮我搜索相关资料' },
];
export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setActiveSessionId, onSessionsChanged }: ChatPanelProps) {
export function ChatPanel() {
const { t } = useTranslation();
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [mode, setMode] = useState<'chat' | 'deploy'>('chat');
const [loadingMessages, setLoadingMessages] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const messagesEndRef = useRef<HTMLDivElement>(null);
const activeSession = chatSessions.find((s) => s.id === activeSessionId) || null;
const {
sessions,
activeSessionId,
loadingMessages,
loadMessages,
createChat,
sendMessage,
} = useChatStore();
const activeSession = sessions.find((s) => s.id === activeSessionId) || null;
const messages = activeSession ? activeSession.messages : [];
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const loadMessages = useCallback(async (chatId: string) => {
setLoadingMessages(true);
try {
const response = await apiClient.get(`/api/v1/chat/${chatId}`);
const dbMessages: ChatMessageDB[] = response.data?.messages || [];
const mapped: Message[] = dbMessages.map((m) => ({
id: m.message_id,
role: m.message_owner === 'user' ? 'user' : 'assistant',
content: m.message,
timestamp: new Date(m.created_at).getTime(),
}));
setChatSessions((prev) =>
prev.map((s) => (s.id === chatId ? { ...s, messages: mapped } : s))
);
} catch (error) {
console.error('Failed to load messages', error);
} finally {
setLoadingMessages(false);
}
}, [setChatSessions]);
useEffect(() => {
if (activeSessionId) {
const session = chatSessions.find((s) => s.id === activeSessionId);
const session = sessions.find((s) => s.id === activeSessionId);
if (session && session.messages.length === 0) {
loadMessages(activeSessionId);
}
}
}, [activeSessionId]);
const updateSessionMessages = (newMessages: Message[]) => {
if (!activeSessionId) return;
setChatSessions((prev) =>
prev.map((s) =>
s.id === activeSessionId
? { ...s, messages: newMessages, updatedAt: Date.now() }
: s
)
);
};
const handleNewChat = async () => {
if (!onSessionsChanged) return;
try {
const response = await apiClient.post('/api/v1/chat', {
title: '新对话',
initial_message: '你好',
});
const chatId: string = response.data.chat_id;
const reply: string = response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = {
id: chatId,
title: '新对话',
messages: [
{ id: chatId + '_user', role: 'user', content: '你好', timestamp: Date.now() },
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() },
],
updatedAt: Date.now(),
};
setChatSessions((prev) => [newSession, ...prev]);
setActiveSessionId(chatId);
onSessionsChanged();
} catch (error) {
console.error('Failed to create chat session', error);
}
await createChat(t('chat.newChat'), '你好');
};
const handleSendMessage = async () => {
if (!input.trim() || !activeSessionId) return;
const userText = input;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: userText,
timestamp: Date.now(),
};
const currentMessages = activeSession?.messages || [];
updateSessionMessages([...currentMessages, userMessage]);
const text = input.trim();
setInput('');
setLoading(true);
await sendMessage(activeSessionId, text);
};
try {
const response = await apiClient.post(`/api/v1/chat/${activeSessionId}/reply`, {
message: userText,
const handleQuickAction = (prompt: string) => {
if (!activeSessionId) {
createChat(prompt.slice(0, 20), prompt).then((id) => {
if (id) sendMessage(id, prompt);
});
const replyContent: string = response.data?.reply || '收到你的消息。';
if (currentMessages.length <= 1 && userText.length > 0) {
setChatSessions((prev) =>
prev.map((s) =>
s.id === activeSessionId
? { ...s, title: userText.slice(0, 20) + (userText.length > 20 ? '...' : '') }
: s
)
);
}
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: replyContent,
timestamp: Date.now(),
};
const updatedMessages = [...(currentMessages.length > 0 ? currentMessages : []), userMessage, aiMessage];
updateSessionMessages(updatedMessages);
} catch (error) {
console.error('Error sending message', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,与服务器通信时出错。',
timestamp: Date.now(),
};
updateSessionMessages([...currentMessages, userMessage, errorMessage]);
} finally {
setLoading(false);
} else {
sendMessage(activeSessionId, prompt);
}
};
if (!activeSessionId) {
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden items-center justify-center">
<Activity size={48} className="text-slate-300 mb-4" />
<h2 className="text-xl font-semibold text-slate-600">kilostar Assistant</h2>
<p className="text-slate-400 mt-2">Select a chat history or create a new one to start.</p>
<button
onClick={handleNewChat}
className="mt-6 px-6 py-2 bg-blue-200 text-slate-800 rounded-xl shadow-sm hover:bg-blue-300 transition-colors"
>
Start New Chat
</button>
<div className="flex-1 flex flex-col items-center justify-center p-8 relative overflow-hidden">
{/* Decorative background */}
<div className="absolute top-1/3 left-1/2 -translate-x-1/2 -translate-y-1/2 w-64 h-64 bg-accent/5 rounded-full blur-3xl" />
<div className="relative z-10 flex flex-col items-center animate-fade-in-scale">
<div className="w-16 h-16 rounded-2xl bg-gradient-to-br from-accent to-glow-purple flex items-center justify-center text-white shadow-xl shadow-accent/20 mb-6">
<Activity size={32} />
</div>
<h2 className="text-2xl font-bold text-text-primary mb-2">{t('chat.assistantName')}</h2>
<p className="text-text-muted text-sm mb-8 text-center max-w-sm">
{t('chat.selectChat')}
</p>
{/* Quick Actions */}
<div className="grid grid-cols-2 gap-3 mb-8 w-full max-w-md">
{QUICK_ACTIONS.map((action) => (
<button
key={action.label}
onClick={() => handleQuickAction(action.prompt)}
className="flex items-center gap-2.5 px-4 py-3 bg-bg-card border border-border-primary rounded-xl text-left hover:border-accent hover:bg-bg-hover transition-all group"
>
<action.icon size={16} className="text-text-muted group-hover:text-accent transition-colors" />
<span className="text-sm text-text-secondary group-hover:text-text-primary transition-colors">{action.label}</span>
</button>
))}
</div>
<button
onClick={handleNewChat}
className="px-6 py-2.5 bg-accent text-white rounded-xl font-medium hover:bg-accent-hover transition-all shadow-lg shadow-accent/20 text-sm flex items-center gap-2"
>
<Plus size={16} />
{t('chat.newChat')}
</button>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col bg-white overflow-hidden relative">
<div className="h-14 border-b border-slate-100 bg-white flex items-center justify-between px-6 z-10 shrink-0">
<div className="flex items-center">
<MessageSquare size={18} className="text-blue-600 mr-3" />
<h1 className="font-semibold text-slate-800">{activeSession?.title || 'Chat'}</h1>
</div>
<div className="flex space-x-2 bg-slate-50 p-1 rounded-lg">
<button
onClick={() => setMode('chat')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
mode === 'chat' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Chat
</button>
<button
onClick={() => setMode('deploy')}
className={`px-3 py-1 text-sm font-medium rounded-md transition-colors ${
mode === 'deploy' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'
}`}
>
Deploy Task
</button>
<div className="flex-1 flex flex-col bg-bg-card overflow-hidden relative">
{/* Header */}
<div className="h-12 border-b border-border-primary/60 bg-bg-card/80 backdrop-blur flex items-center justify-between px-5 z-10 shrink-0">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
<MessageSquare size={14} className="text-accent" />
</div>
<h1 className="font-semibold text-sm text-text-primary">{activeSession?.title || 'Chat'}</h1>
</div>
</div>
<div className="flex-1 p-6 overflow-y-auto space-y-6 bg-white">
{/* Messages */}
<div className="flex-1 overflow-y-auto px-4 py-6 space-y-5">
{loadingMessages ? (
<div className="flex justify-center items-center h-full">
<div className="flex space-x-2">
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-75"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-150"></span>
<div className="flex items-center gap-1.5">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div>
) : (
messages.map((msg) => (
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
messages.map((msg, idx) => (
<div
key={msg.id}
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fade-in`}
style={{ animationDelay: `${idx * 30}ms` }}
>
{msg.role === 'assistant' && (
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600" />
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-accent to-glow-purple flex items-center justify-center mr-2.5 mt-0.5 shadow-sm flex-shrink-0">
<Activity size={13} className="text-white" />
</div>
)}
<div
className={`${
msg.role === 'user'
? 'bg-blue-100 text-slate-800 rounded-2xl rounded-tr-sm'
: 'bg-slate-50 border border-slate-100 text-slate-700 rounded-2xl rounded-tl-sm'
} p-4 max-w-[80%] shadow-sm`}
>
<p className="text-sm leading-relaxed mb-1 whitespace-pre-wrap">{msg.content}</p>
<div className={`max-w-[85%] ${msg.role === 'user' ? 'mr-1' : ''}`}>
<div
className={`px-4 py-2.5 text-sm leading-relaxed whitespace-pre-wrap ${
msg.role === 'user'
? 'bg-text-primary text-bg-primary rounded-2xl rounded-tr-sm'
: 'bg-bg-secondary border border-border-primary rounded-2xl rounded-tl-sm'
}`}
>
{msg.content}
</div>
</div>
</div>
))
)}
{loading && (
<div className="flex justify-start">
<div className="w-8 h-8 rounded-full bg-white border border-blue-100 flex items-center justify-center mr-3 mt-1 shadow-sm flex-shrink-0">
<Activity size={16} className="text-blue-600 animate-spin" />
{/* Typing indicator */}
{activeSession && activeSession.messages.length > 0 && activeSession.messages[activeSession.messages.length - 1].role === 'user' && (
<div className="flex justify-start animate-fade-in">
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-accent to-glow-purple flex items-center justify-center mr-2.5 mt-0.5 shadow-sm flex-shrink-0">
<Activity size={13} className="text-white animate-pulse" />
</div>
<div className="bg-slate-50 border border-slate-100 text-slate-700 p-4 rounded-2xl rounded-tl-sm max-w-[80%] shadow-sm">
<span className="flex space-x-1">
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-75"></span>
<span className="h-2 w-2 bg-slate-400 rounded-full animate-bounce delay-150"></span>
</span>
<div className="bg-bg-secondary border border-border-primary rounded-2xl rounded-tl-sm px-4 py-3">
<div className="flex items-center gap-1">
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
<span className="w-1.5 h-1.5 rounded-full bg-accent animate-typing-dot" />
</div>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
<div className="relative flex items-center">
{/* Input */}
<div className="p-4 bg-bg-card border-t border-border-primary/60 shrink-0">
<div className="relative flex items-end gap-2 max-w-3xl mx-auto">
<input type="file" ref={fileInputRef} className="hidden" />
<button
onClick={() => fileInputRef.current?.click()}
className="absolute left-2 p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors z-10 cursor-pointer"
className="p-2.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-xl transition-all flex-shrink-0 mb-0.5"
title="Add attachment"
>
<Plus size={20} />
<Plus size={18} />
</button>
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSendMessage()}
placeholder="Ask kilostar to do something..."
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-2xl pl-12 pr-12 py-3.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 transition-all"
/>
<div className="flex-1 relative">
<textarea
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
placeholder={t('chat.placeholder')}
rows={1}
className="w-full bg-bg-input border border-border-primary rounded-xl pl-4 pr-12 py-3 focus:outline-none focus:ring-2 focus:ring-accent/15 focus:border-accent/40 transition-all text-text-primary placeholder:text-text-muted/50 text-sm resize-none min-h-[44px] max-h-[120px]"
style={{ height: 'auto' }}
/>
</div>
<button
onClick={handleSendMessage}
disabled={loading || !input.trim()}
className="absolute right-2 p-1.5 bg-blue-200 text-slate-800 rounded-xl hover:bg-blue-300 transition-colors shadow-sm disabled:opacity-50 cursor-pointer"
disabled={!input.trim()}
className="p-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 disabled:opacity-30 disabled:shadow-none disabled:hover:bg-accent flex-shrink-0 mb-0.5"
>
<ChevronRight size={18} />
<ArrowUp size={18} />
</button>
</div>
<p className="text-center text-[10px] text-text-muted/50 mt-2">
KiloStar can make mistakes. Please verify important information.
</p>
</div>
</div>
);
+103 -150
View File
@@ -1,33 +1,29 @@
import { useState, useEffect } from 'react';
import { Plus, Trash2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon } from 'lucide-react';
import apiClient from '../../api/client';
import type { Workflow } from '../../types';
import type { ChatSession } from '../../App';
import { useChatStore } from '../../store/useChatStore';
interface LeftPanelProps {
activeTab: string;
selectedWorkflow: string | null;
setSelectedWorkflow: (id: string | null) => void;
chatSessions?: ChatSession[];
setChatSessions?: React.Dispatch<React.SetStateAction<ChatSession[]>>;
activeSessionId?: string | null;
setActiveSessionId?: React.Dispatch<React.SetStateAction<string | null>>;
onSessionsChanged?: () => void;
}
export function LeftPanel({
activeTab,
selectedWorkflow,
setSelectedWorkflow,
chatSessions,
setChatSessions,
activeSessionId,
setActiveSessionId,
onSessionsChanged,
}: LeftPanelProps) {
export function LeftPanel({ activeTab }: LeftPanelProps) {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
const {
sessions,
activeSessionId,
setActiveSessionId,
removeSession,
createChat,
selectedWorkflow,
setSelectedWorkflow,
} = useChatStore();
useEffect(() => {
let intervalId: ReturnType<typeof setInterval>;
@@ -62,148 +58,105 @@ export function LeftPanel({
}, [activeTab]);
const handleNewChat = async () => {
if (!setChatSessions || !setActiveSessionId || !onSessionsChanged) return;
try {
const response = await apiClient.post('/api/v1/chat', {
title: '新对话',
initial_message: '你好',
});
const chatId: string = response.data.chat_id;
const reply: string = response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = {
id: chatId,
title: '新对话',
messages: [
{ id: chatId + '_user', role: 'user', content: '你好', timestamp: Date.now() },
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() },
],
updatedAt: Date.now(),
};
setChatSessions((prev) => [newSession, ...prev]);
setActiveSessionId(chatId);
onSessionsChanged();
} catch (error) {
console.error('Failed to create chat session', error);
}
await createChat(t('chat.newChat'), '你好');
};
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
if (!setChatSessions || !setActiveSessionId || !chatSessions) return;
const updated = chatSessions.filter((s) => s.id !== id);
setChatSessions(updated);
if (activeSessionId === id) {
setActiveSessionId(updated.length > 0 ? updated[0].id : null);
}
removeSession(id);
};
const isChats = activeTab === 'chats';
return (
<div className="w-72 bg-white border-r border-slate-100 flex flex-col z-0 shrink-0">
<div className="flex-1 flex flex-col overflow-hidden">
<div className="w-64 bg-bg-sidebar border-r border-border-primary flex flex-col shrink-0">
<div className="flex items-center justify-between px-4 py-3 border-b border-border-primary">
<span className="text-[11px] font-bold text-text-muted uppercase tracking-widest">
{isChats ? t('chat.chatHistory') : t('nav.workflow')}
</span>
<button
onClick={() => {
if (isChats) handleNewChat();
else setSelectedWorkflow('new');
}}
className="p-1.5 rounded-lg bg-bg-hover text-text-muted hover:text-accent hover:bg-accent-light transition-all"
title={isChats ? t('chat.newChat') : t('workflow.createWorkflow')}
>
<Plus size={14} />
</button>
</div>
<div className="flex items-center justify-between p-3 border-b border-slate-100 bg-slate-50">
<span className="text-sm font-semibold text-slate-600 uppercase tracking-wider">
{activeTab === 'chats' ? 'Chat History' : 'Workflows'}
</span>
<button
onClick={() => {
if (activeTab === 'chats') {
handleNewChat();
} else {
setSelectedWorkflow('new');
}
}}
className="p-1.5 bg-blue-100 text-blue-600 rounded hover:bg-blue-200 transition-colors"
title={activeTab === 'chats' ? 'New Chat' : 'New Workflow'}
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 p-3 overflow-y-auto">
{activeTab === 'workflows' && (
<div className="space-y-2">
{loadingWorkflows ? (
<div className="text-center text-slate-400 text-sm py-4">Loading workflows...</div>
) : workflows.length === 0 ? (
<div className="text-center text-slate-400 text-sm py-4">暂无工作流<br/>点击右上角 + 创建</div>
) : (
workflows.map((wf) => (
<div
key={wf.trace_id}
onClick={() => setSelectedWorkflow(wf.trace_id)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${
selectedWorkflow === wf.trace_id
? 'border-blue-300 bg-blue-50 shadow-sm'
: 'border-slate-100 hover:border-blue-200 hover:bg-slate-50'
}`}
>
<div className="flex justify-between items-center mb-1">
<span
className={`font-medium text-sm ${
selectedWorkflow === wf.trace_id ? 'text-blue-700' : 'text-slate-700'
}`}
>
{wf.title || 'Unnamed Workflow'}
</span>
<span
className={`flex h-2 w-2 rounded-full ${
wf.status && (wf.status.includes('working'))
? 'bg-green-400 animate-pulse'
: wf.status === 'failed'
? 'bg-red-400'
: wf.status === 'completed'
? 'bg-green-500'
: 'bg-slate-300'
}`}
></span>
</div>
<p className="text-xs text-slate-500 font-mono line-clamp-1">ID: {wf.trace_id}</p>
</div>
))
)}
<div className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{isChats ? (
sessions.length === 0 ? (
<div className="px-3 py-8 text-center text-text-muted text-xs">
{t('chat.noHistory')}
</div>
)}
{activeTab === 'chats' && chatSessions && (
<div className="space-y-2">
{chatSessions.length === 0 ? (
<div className="text-center text-slate-400 text-sm py-8">
No chat history.<br/>Click + to start a new chat.
) : (
sessions.map((session) => (
<div
key={session.id}
onClick={() => setActiveSessionId(session.id)}
className={`group flex items-center gap-2.5 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${
activeSessionId === session.id
? 'bg-accent-light text-accent'
: 'hover:bg-bg-hover text-text-secondary'
}`}
>
<MessageSquare size={14} className={`flex-shrink-0 ${activeSessionId === session.id ? 'text-accent' : 'text-text-muted'}`} />
<div className="flex-1 min-w-0">
<h3 className={`text-xs font-medium truncate ${activeSessionId === session.id ? 'text-accent' : 'text-text-secondary'}`}>
{session.title}
</h3>
<p className="text-[10px] text-text-muted mt-0.5">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
) : (
chatSessions.map((session) => (
<div
key={session.id}
onClick={() => setActiveSessionId?.(session.id)}
className={`group flex items-center justify-between p-3 rounded-lg border cursor-pointer transition-all ${
activeSessionId === session.id
? 'border-blue-300 bg-blue-50 shadow-sm'
: 'border-slate-100 hover:border-blue-200 hover:bg-slate-50'
}`}
>
<div className="flex-1 min-w-0 mr-2">
<h3 className={`font-medium text-sm truncate ${
activeSessionId === session.id ? 'text-blue-700' : 'text-slate-700'
}`}>
{session.title}
</h3>
<p className="text-xs text-slate-400 mt-1">
{new Date(session.updatedAt).toLocaleDateString()}
</p>
</div>
<button
onClick={(e) => handleDeleteChat(e, session.id)}
className="text-slate-400 opacity-0 group-hover:opacity-100 hover:text-red-500 transition-all"
>
<Trash2 size={16} />
</button>
</div>
))
)}
<button
onClick={(e) => handleDeleteChat(e, session.id)}
className="opacity-0 group-hover:opacity-100 p-1 rounded text-text-muted hover:text-danger hover:bg-danger-bg transition-all"
>
<Trash2 size={12} />
</button>
</div>
))
)
) : (
loadingWorkflows ? (
<div className="px-3 py-8 text-center text-text-muted text-xs">{t('workflow.loading')}</div>
) : workflows.length === 0 ? (
<div className="px-3 py-8 text-center text-text-muted text-xs">
{t('workflow.noWorkflows')}
</div>
)}
</div>
) : (
workflows.map((wf) => (
<div
key={wf.trace_id}
onClick={() => setSelectedWorkflow(wf.trace_id)}
className={`group flex items-center gap-2.5 px-3 py-2.5 rounded-lg cursor-pointer transition-all ${
selectedWorkflow === wf.trace_id
? 'bg-accent-light'
: 'hover:bg-bg-hover'
}`}
>
<WorkflowIcon size={14} className={`flex-shrink-0 ${selectedWorkflow === wf.trace_id ? 'text-accent' : 'text-text-muted'}`} />
<div className="flex-1 min-w-0">
<h3 className={`text-xs font-medium truncate ${selectedWorkflow === wf.trace_id ? 'text-accent' : 'text-text-secondary'}`}>
{wf.title || 'Unnamed'}
</h3>
<div className="flex items-center gap-1.5 mt-0.5">
<span className={`w-1.5 h-1.5 rounded-full ${
wf.status?.includes('working') ? 'bg-accent animate-pulse' :
wf.status === 'failed' ? 'bg-danger' :
wf.status === 'completed' ? 'bg-success' : 'bg-text-muted'
}`} />
<span className="text-[10px] text-text-muted font-mono truncate">{wf.trace_id.slice(-8)}</span>
</div>
</div>
</div>
))
)
)}
</div>
</div>
);
@@ -1,4 +1,5 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Terminal, Sparkles, ArrowLeft } from 'lucide-react';
import apiClient from '../../api/client';
@@ -8,6 +9,7 @@ interface NewWorkflowDialogProps {
}
export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps) {
const { t } = useTranslation();
const [command, setCommand] = useState('');
const [title, setTitle] = useState('');
const [loading, setLoading] = useState(false);
@@ -15,26 +17,14 @@ export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!title.trim()) {
setError('请输入工作流标题');
return;
}
if (!command.trim()) {
setError('请输入具体需求描述');
return;
}
if (!title.trim()) { setError('请输入工作流标题'); return; }
if (!command.trim()) { setError('请输入具体需求描述'); return; }
setLoading(true);
setError('');
try {
const response = await apiClient.post('/api/v1/workflow', {
title: title.trim(),
command: command.trim()
});
if (response.data && response.data.trace_id) {
onSuccess(response.data.trace_id);
}
const response = await apiClient.post('/api/v1/workflow', { title: title.trim(), command: command.trim() });
if (response.data?.trace_id) onSuccess(response.data.trace_id);
} catch (err: any) {
setError(err.response?.data?.message || '创建工作流失败,请重试');
} finally {
@@ -42,103 +32,62 @@ export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps
}
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
};
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shrink-0">
<button
onClick={onClose}
className="flex items-center gap-1.5 text-sm text-slate-500 hover:text-blue-600 transition-colors"
>
<ArrowLeft size={16} />
返回工作流列表
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
<div className="h-12 border-b border-border-primary/60 bg-bg-card/80 backdrop-blur flex items-center px-5 shrink-0">
<button onClick={onClose} className="flex items-center gap-1.5 text-xs text-text-muted hover:text-accent transition-colors">
<ArrowLeft size={14} />
{t('workflow.backToList')}
</button>
<div className="flex-1 flex items-center justify-center gap-2">
<Terminal size={18} className="text-blue-600" />
<h2 className="font-semibold text-slate-800">新建工作流</h2>
<Terminal size={15} className="text-accent" />
<h2 className="font-semibold text-sm text-text-primary">{t('workflow.newWorkflow')}</h2>
</div>
<div className="w-28" />
<div className="w-20" />
</div>
<form onSubmit={handleSubmit} className="flex-1 p-6 overflow-y-auto">
<div className="max-w-3xl mx-auto h-full flex flex-col gap-6">
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6">
<div className="max-w-2xl mx-auto space-y-5">
{error && (
<div className="flex items-center gap-2 p-4 text-sm text-red-700 bg-red-50 border border-red-200 rounded-xl shrink-0">
<span className="text-base">⚠️</span>
{error}
<div className="flex items-center gap-2 p-3 text-xs text-danger bg-danger-bg border border-danger/20 rounded-xl">
<span>⚠️</span> {error}
</div>
)}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 shrink-0">
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-xs font-bold">1</span>
工作流标题
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm p-5">
<label className="flex items-center gap-2 text-xs font-bold text-text-secondary mb-2 uppercase tracking-wider">
<span className="flex items-center justify-center w-5 h-5 rounded-lg bg-accent-light text-accent text-[10px] font-bold">1</span>
{t('workflow.workflowTitle')}
</label>
<p className="text-xs text-slate-400 mb-3">为你的工作流起一个简洁、描述性的名称</p>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="例如:爬取最新技术新闻并生成摘要"
className="w-full px-4 py-3 text-lg font-medium text-slate-800 bg-slate-50 border border-slate-200 rounded-xl placeholder:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 focus:bg-white transition-all"
autoFocus
/>
<p className="text-[11px] text-text-muted mb-3">{t('workflow.titleHint')}</p>
<input type="text" value={title} onChange={(e) => setTitle(e.target.value)}
placeholder={t('workflow.titlePlaceholder')}
className="w-full px-4 py-2.5 text-sm font-medium text-text-primary bg-bg-input border border-border-primary rounded-xl placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15 focus:border-accent/40 transition-all"
autoFocus />
</div>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm p-6 flex flex-col flex-1 min-h-0">
<label className="flex items-center gap-2 text-sm font-semibold text-slate-700 mb-3 shrink-0">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 text-blue-600 text-xs font-bold">2</span>
需求描述
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm p-5">
<label className="flex items-center gap-2 text-xs font-bold text-text-secondary mb-2 uppercase tracking-wider">
<span className="flex items-center justify-center w-5 h-5 rounded-lg bg-accent-light text-accent text-[10px] font-bold">2</span>
{t('workflow.command')}
</label>
<p className="text-xs text-slate-400 mb-3 shrink-0">详细描述你希望 AI 完成的任务,越具体效果越好</p>
<textarea
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="例如:请帮我自动执行以下任务:
1. 访问指定的技术新闻网站
2. 抓取过去24小时内发布的所有文章
3. 对每篇文章进行智能摘要
4. 将结果整理为结构化表格
5. 通过邮件发送给我
提示:按 Ctrl+Enter 快速提交"
className="flex-1 w-full px-4 py-3 text-sm text-slate-700 bg-slate-50 border border-slate-200 rounded-xl resize-none placeholder:text-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-400 focus:bg-white transition-all min-h-[200px]"
/>
<div className="flex items-center justify-between mt-3 shrink-0">
<span className="text-xs text-slate-400">
{command.length > 0 ? `已输入 ${command.length} 个字符` : 'Ctrl + Enter 快速提交'}
</span>
<p className="text-[11px] text-text-muted mb-3">{t('workflow.commandHint')}</p>
<textarea value={command} onChange={(e) => setCommand(e.target.value)}
placeholder={t('workflow.commandPlaceholder')}
rows={8}
className="w-full px-4 py-3 text-sm text-text-primary bg-bg-input border border-border-primary rounded-xl resize-none placeholder:text-text-muted/50 focus:outline-none focus:ring-2 focus:ring-accent/15 focus:border-accent/40 transition-all" />
<div className="flex items-center justify-between mt-3">
<span className="text-[10px] text-text-muted">{command.length > 0 ? `${command.length} chars` : 'Ctrl + Enter to submit'}</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onClose}
className="px-5 py-2.5 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-xl transition-colors"
>
取消
<button type="button" onClick={onClose} className="px-4 py-2 text-xs font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
{t('workflow.cancel')}
</button>
<button
type="submit"
disabled={loading}
className="flex items-center gap-2 px-6 py-2.5 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-xl transition-all shadow-sm hover:shadow-md disabled:opacity-50 disabled:hover:bg-blue-600"
>
<button type="submit" disabled={loading}
className="flex items-center gap-2 px-5 py-2 text-xs font-semibold text-white bg-accent hover:bg-accent-hover rounded-xl transition-all shadow-lg shadow-accent/15 disabled:opacity-50">
{loading ? (
<>
<Sparkles size={16} className="animate-spin" />
正在创建...
</>
<><span className="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin" /> {t('workflow.creating')}</>
) : (
<>
<Sparkles size={16} />
创建并启动工作流
</>
<><Sparkles size={13} /> {t('workflow.createAndStart')}</>
)}
</button>
</div>
+62 -101
View File
@@ -1,5 +1,6 @@
import { useState, useEffect, useRef } from 'react';
import { Terminal, RefreshCw, SendHorizontal, LayoutList, GitFork } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Terminal, RefreshCw, SendHorizontal, LayoutList, GitFork, Radio } from 'lucide-react';
import apiClient from '../../api/client';
import type { WorkflowDetail } from '../../types';
import { WorkflowDiagram } from './WorkflowDiagram';
@@ -9,6 +10,7 @@ interface RightPanelProps {
}
export function RightPanel({ selectedWorkflow }: RightPanelProps) {
const { t } = useTranslation();
const [detail, setDetail] = useState<WorkflowDetail | null>(null);
const [loading, setLoading] = useState(false);
const [logs, setLogs] = useState<string[]>([]);
@@ -37,33 +39,18 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
setLogs([]);
return;
}
fetchDetail(selectedWorkflow);
setLogs([]);
const protocol = window.location.protocol;
const host = window.location.host;
const apiBase = import.meta.env.VITE_API_BASE_URL || `${protocol}//${host}`;
const apiBase = import.meta.env.VITE_API_BASE_URL || `${window.location.protocol}//${window.location.host}`;
const es = new EventSource(`${apiBase}/api/v1/workflow/sse/${selectedWorkflow}`);
eventSourceRef.current = es;
es.onopen = () => setSseConnected(true);
es.onmessage = (event) => {
setLogs(prev => [...prev, event.data]);
};
es.onmessage = (event) => setLogs((prev) => [...prev, event.data]);
es.onerror = () => setSseConnected(false);
const interval = setInterval(() => {
fetchDetail(selectedWorkflow);
}, 3000);
return () => {
es.close();
eventSourceRef.current = null;
clearInterval(interval);
};
const interval = setInterval(() => fetchDetail(selectedWorkflow), 3000);
return () => { es.close(); eventSourceRef.current = null; clearInterval(interval); };
}, [selectedWorkflow]);
useEffect(() => {
@@ -75,113 +62,87 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
const handleReplySubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!replyText.trim() || !selectedWorkflow) return;
const message = replyText.trim();
setReplyText('');
setLogs(prev => [...prev, `[You]: ${message}`]);
setLogs((prev) => [...prev, `[You]: ${message}`]);
try {
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
} catch (err) {
console.error("Failed to send reply", err);
setLogs(prev => [...prev, `[System Error]: Failed to send reply.`]);
} catch {
setLogs((prev) => [...prev, `[System Error]: Failed to send reply.`]);
}
};
if (!selectedWorkflow) return null;
return (
<div className="flex-1 bg-white border-l border-slate-200 flex flex-col z-0 relative">
<div className="h-14 border-b border-slate-100 flex items-center px-6 justify-between bg-white z-10 shrink-0">
<div className="flex items-center gap-4">
<h2 className="font-semibold text-slate-800 flex items-center gap-2">
<Terminal size={18} className="text-blue-500" />
<span className="truncate max-w-[200px]" title={detail?.title || 'Loading...'}>
{detail?.title || 'Workflow Details'}
</span>
</h2>
<span className={`px-2 py-0.5 text-xs rounded-full font-medium ${sseConnected ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-500'}`}>
{sseConnected ? 'Live' : 'Disconnected'}
<div className="flex-1 bg-bg-card border-l border-border-primary flex flex-col relative">
<div className="h-12 border-b border-border-primary/60 bg-bg-card/80 backdrop-blur flex items-center px-5 justify-between shrink-0">
<div className="flex items-center gap-3">
<div className="w-7 h-7 rounded-lg bg-accent-light flex items-center justify-center">
<Terminal size={14} className="text-accent" />
</div>
<h2 className="font-semibold text-sm text-text-primary truncate max-w-[180px]">{detail?.title || t('workflow.workflowDetails')}</h2>
<span className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-medium ${sseConnected ? 'bg-success-bg text-success' : 'bg-bg-secondary text-text-muted'}`}>
<Radio size={10} className={sseConnected ? 'animate-pulse' : ''} />
{sseConnected ? t('workflow.live') : t('workflow.disconnected')}
</span>
</div>
<div className="flex items-center bg-slate-100 rounded-lg p-1">
<button
onClick={() => setActiveTab('chat')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'chat' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<LayoutList size={16} />
交流日志
</button>
<button
onClick={() => setActiveTab('diagram')}
className={`flex items-center gap-2 px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${activeTab === 'diagram' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'}`}
>
<GitFork size={16} />
流程图
</button>
<div className="flex items-center gap-2">
<div className="flex items-center bg-bg-secondary rounded-lg p-0.5">
<button onClick={() => setActiveTab('chat')} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'chat' ? 'bg-bg-card text-accent shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>
<LayoutList size={12} /> {t('workflow.chatLog')}
</button>
<button onClick={() => setActiveTab('diagram')} className={`flex items-center gap-1.5 px-2.5 py-1.5 rounded-md text-xs font-medium transition-all ${activeTab === 'diagram' ? 'bg-bg-card text-accent shadow-sm' : 'text-text-muted hover:text-text-secondary'}`}>
<GitFork size={12} /> {t('workflow.diagram')}
</button>
</div>
<button onClick={() => fetchDetail(selectedWorkflow)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all">
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</button>
</div>
<button
onClick={() => fetchDetail(selectedWorkflow)}
className="p-1.5 text-slate-400 hover:text-blue-600 hover:bg-blue-50 rounded-lg transition-colors"
title="Refresh Data"
>
<RefreshCw size={16} className={loading ? "animate-spin" : ""} />
</button>
</div>
<div className="flex-1 flex overflow-hidden bg-slate-50 relative">
<div className="flex-1 flex overflow-hidden bg-bg-secondary relative">
{activeTab === 'diagram' ? (
<div className="absolute inset-0">
{detail?.steps && detail.steps.length > 0 ? (
<WorkflowDiagram steps={detail.steps} currentStep={0} status={detail.status} />
) : (
<div className="h-full flex items-center justify-center text-slate-400">
Workflow steps are not yet generated.
</div>
<div className="h-full flex items-center justify-center text-text-muted text-sm">Workflow steps are not yet generated.</div>
)}
</div>
) : (
<div className="flex-1 flex flex-col p-6 overflow-hidden">
{detail?.command && (
<div className="bg-white border border-slate-200 rounded-xl p-4 mb-4 shadow-sm shrink-0">
<h3 className="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">Original Command</h3>
<p className="text-slate-700 text-sm">{detail.command}</p>
<div className="flex-1 flex flex-col p-5 overflow-hidden">
{detail?.command && (
<div className="bg-bg-card border border-border-primary rounded-xl p-4 mb-4 shadow-sm shrink-0">
<h3 className="text-[10px] font-bold text-text-muted uppercase tracking-widest mb-1.5">{t('workflow.originalCommand')}</h3>
<p className="text-text-secondary text-sm">{detail.command}</p>
</div>
)}
<div className="flex-1 bg-bg-card border border-border-primary rounded-xl shadow-sm overflow-y-auto p-4 mb-4 space-y-2 font-mono text-xs">
{logs.length === 0 ? (
<div className="h-full flex items-center justify-center text-text-muted">
{t('workflow.waitingEvents')}
</div>
)}
<div className="flex-1 bg-white border border-slate-200 rounded-xl shadow-sm overflow-y-auto p-4 mb-4 space-y-3 font-mono text-sm">
{logs.length === 0 ? (
<div className="h-full flex items-center justify-center text-slate-400">
Waiting for events...
) : (
logs.map((log, index) => (
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
{log}
</div>
) : (
logs.map((log, index) => (
<div key={index} className={`p-3 rounded-lg ${log.startsWith('[You]') ? 'bg-blue-50 border border-blue-100 text-blue-800 self-end ml-12' : 'bg-slate-50 border border-slate-100 text-slate-700 mr-12'}`}>
{log}
</div>
))
)}
<div ref={logsEndRef} />
</div>
<form onSubmit={handleReplySubmit} className="relative shrink-0">
<input
type="text"
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="Reply to the workflow..."
className="w-full bg-white border border-slate-200 rounded-xl pl-4 pr-12 py-3 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent shadow-sm"
/>
<button
type="submit"
disabled={!replyText.trim()}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:hover:bg-blue-600 transition-colors"
>
<SendHorizontal size={16} />
</button>
</form>
))
)}
<div ref={logsEndRef} />
</div>
<form onSubmit={handleReplySubmit} className="relative shrink-0">
<input type="text" value={replyText} onChange={(e) => setReplyText(e.target.value)}
placeholder={t('workflow.replyPlaceholder')}
className="w-full bg-bg-card border border-border-primary rounded-xl pl-4 pr-11 py-2.5 text-sm focus:outline-none focus:ring-2 focus:ring-accent/15 focus:border-accent/40 text-text-primary placeholder:text-text-muted/50" />
<button type="submit" disabled={!replyText.trim()}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1.5 bg-accent text-white rounded-lg hover:bg-accent-hover disabled:opacity-30 transition-colors">
<SendHorizontal size={14} />
</button>
</form>
</div>
)}
</div>
@@ -7,7 +7,7 @@ import {
useNodesState,
useEdgesState,
MarkerType,
BackgroundVariant
BackgroundVariant,
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import type { WorkflowStep } from '../../types';
@@ -27,43 +27,43 @@ export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramP
const isCompleted = step.status === 'completed';
const isFailed = step.status === 'failed';
let bgColor = '#ffffff';
let borderColor = '#e2e8f0'; // slate-200
let textColor = '#334155'; // slate-700
let bgColor = 'var(--bg-card)';
let borderColor = 'var(--border-primary)';
let textColor = 'var(--text-secondary)';
if (isCurrent) {
bgColor = '#eff6ff'; // blue-50
borderColor = '#3b82f6'; // blue-500
textColor = '#1e40af'; // blue-800
bgColor = 'var(--bg-active)';
borderColor = 'var(--accent)';
textColor = 'var(--accent)';
} else if (isFailed) {
bgColor = '#fef2f2'; // red-50
borderColor = '#ef4444'; // red-500
textColor = '#991b1b'; // red-800
bgColor = 'var(--danger-bg)';
borderColor = 'var(--danger)';
textColor = 'var(--danger)';
} else if (isCompleted) {
bgColor = '#f0fdf4'; // green-50
borderColor = '#22c55e'; // green-500
textColor = '#166534'; // green-800
bgColor = 'var(--success-bg)';
borderColor = 'var(--success)';
textColor = 'var(--success)';
}
return {
id: step.step.toString(),
position: { x: 250, y: index * 120 + 50 },
position: { x: 250, y: index * 100 + 40 },
data: {
label: (
<div className="flex flex-col items-center p-2 min-w-[150px]">
<div className="text-xs font-semibold mb-1 opacity-70 uppercase tracking-wider">{step.node}</div>
<div className="text-sm font-medium">{step.name}</div>
<div className="flex flex-col items-center p-1.5 min-w-[140px]">
<div className="text-[10px] font-bold mb-0.5 opacity-60 uppercase tracking-wider">{step.node}</div>
<div className="text-xs font-semibold">{step.name}</div>
</div>
)
),
},
style: {
background: bgColor,
border: `2px solid ${borderColor}`,
borderRadius: '8px',
borderRadius: '10px',
color: textColor,
boxShadow: isCurrent ? '0 4px 6px -1px rgba(59, 130, 246, 0.2)' : '0 1px 2px 0 rgba(0, 0, 0, 0.05)',
padding: '4px',
}
boxShadow: isCurrent ? '0 0 20px -4px var(--accent-glow)' : 'none',
fontSize: '12px',
},
};
});
}, [steps, currentStep, isWorkflowActive]);
@@ -76,11 +76,8 @@ export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramP
source: steps[i].step.toString(),
target: steps[i + 1].step.toString(),
animated: steps[i].step === currentStep && isWorkflowActive,
style: { stroke: '#cbd5e1', strokeWidth: 2 },
markerEnd: {
type: MarkerType.ArrowClosed,
color: '#cbd5e1',
},
style: { stroke: 'var(--border-primary)', strokeWidth: 2 },
markerEnd: { type: MarkerType.ArrowClosed, color: 'var(--border-primary)' },
});
}
return edges;
@@ -89,29 +86,19 @@ export function WorkflowDiagram({ steps, currentStep, status }: WorkflowDiagramP
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
// Update nodes and edges if props change
useEffect(() => {
setNodes(initialNodes);
setEdges(initialEdges);
}, [initialNodes, initialEdges, setNodes, setEdges]);
return (
<ReactFlow
nodes={nodes}
edges={edges}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
fitView
attributionPosition="bottom-right"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} color="#cbd5e1" />
<Controls className="bg-white border-slate-200 fill-slate-500 shadow-sm rounded-md" />
<ReactFlow nodes={nodes} edges={edges} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} fitView attributionPosition="bottom-right">
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="var(--border-primary)" />
<Controls className="bg-bg-card border-border-primary fill-text-muted shadow-sm rounded-lg" />
<MiniMap
nodeColor={(n) => {
if (n.style?.background) return n.style.background as string;
return '#e2e8f0';
}}
maskColor="rgba(248, 250, 252, 0.7)"
nodeColor={(n) => (n.style?.background as string) || 'var(--border-primary)'}
maskColor="rgba(0, 0, 0, 0.6)"
className="bg-bg-card border border-border-primary rounded-lg"
/>
</ReactFlow>
);
@@ -1,13 +1,15 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
import type { Workflow } from '../../types';
import { PlayCircle, CheckCircle, XCircle, Clock } from 'lucide-react';
import { PlayCircle, CheckCircle, XCircle, Clock, ArrowRight, Zap } from 'lucide-react';
interface WorkflowListViewProps {
onSelectWorkflow: (id: string) => void;
}
export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
const { t } = useTranslation();
const [workflows, setWorkflows] = useState<Workflow[]>([]);
const [loading, setLoading] = useState(true);
@@ -35,102 +37,99 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
return () => clearInterval(intervalId);
}, []);
const getStatusIcon = (status?: string) => {
if (status === 'completed') return <CheckCircle size={20} className="text-green-500" />;
if (status === 'failed') return <XCircle size={20} className="text-red-500" />;
if (status && (status.includes('working')))
return <PlayCircle size={20} className="text-blue-500 animate-pulse" />;
return <Clock size={20} className="text-slate-400" />;
};
const getStatusBadge = (status?: string) => {
let colorClass = "bg-slate-100 text-slate-600";
let label = "Waiting";
if (status === 'completed') { colorClass = "bg-green-100 text-green-700"; label = "Completed"; }
else if (status === 'failed') { colorClass = "bg-red-100 text-red-700"; label = "Failed"; }
else if (status && status.includes('working')) { colorClass = "bg-blue-100 text-blue-700 animate-pulse"; label = "Running"; }
return (
<span className={`px-2.5 py-1 text-xs font-medium rounded-full ${colorClass}`}>
{label}
</span>
);
const getStatusMeta = (status?: string) => {
if (status === 'completed') return { icon: CheckCircle, color: 'text-success', bg: 'bg-success-bg', border: 'border-success/20', glow: 'group-hover:shadow-success/20' };
if (status === 'failed') return { icon: XCircle, color: 'text-danger', bg: 'bg-danger-bg', border: 'border-danger/20', glow: 'group-hover:shadow-danger/20' };
if (status && status.includes('working')) return { icon: PlayCircle, color: 'text-accent', bg: 'bg-accent-light', border: 'border-accent/20', glow: 'group-hover:shadow-accent/20' };
return { icon: Clock, color: 'text-text-muted', bg: 'bg-bg-secondary', border: 'border-border-primary', glow: 'group-hover:shadow-border-primary' };
};
if (loading) {
return (
<div className="flex-1 flex items-center justify-center p-6 bg-slate-50">
<div className="text-slate-400">Loading Workflows...</div>
<div className="flex-1 flex items-center justify-center">
<div className="flex items-center gap-2 text-text-muted">
<span className="w-4 h-4 border-2 border-border-primary border-t-accent rounded-full animate-spin" />
<span className="text-sm">{t('workflow.loading')}</span>
</div>
</div>
);
}
return (
<div className="flex-1 flex flex-col p-8 bg-slate-50 overflow-auto">
<div className="mb-8 flex justify-between items-center">
<div>
<h1 className="text-2xl font-bold text-slate-800">Workflows</h1>
<p className="text-slate-500 mt-1">Manage and monitor your automated processes.</p>
</div>
<button
onClick={() => onSelectWorkflow('new')}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white font-medium rounded-lg hover:bg-blue-700 transition-colors shadow-sm"
>
<span className="text-xl leading-none">+</span> 创建工作流
</button>
</div>
{workflows.length === 0 ? (
<div className="flex-1 flex flex-col items-center justify-center border-2 border-dashed border-slate-200 rounded-2xl bg-white p-12 text-center">
<div className="w-16 h-16 bg-slate-50 rounded-full flex items-center justify-center mb-4">
<PlayCircle size={32} className="text-slate-400" />
</div>
<h3 className="text-lg font-medium text-slate-800 mb-2">No Workflows Found</h3>
<p className="text-slate-500 max-w-sm">
Workflows created from your chats will appear here automatically.
</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
{workflows.map((wf) => (
<div
key={wf.trace_id}
onClick={() => onSelectWorkflow(wf.trace_id)}
className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm hover:shadow-md hover:border-blue-300 transition-all cursor-pointer group flex flex-col h-full"
>
<div className="flex justify-between items-start mb-4">
<div className="p-2.5 bg-blue-50 text-blue-600 rounded-xl group-hover:bg-blue-600 group-hover:text-white transition-colors">
{getStatusIcon(wf.status)}
</div>
{getStatusBadge(wf.status)}
</div>
<h3 className="text-lg font-semibold text-slate-800 mb-2 line-clamp-1" title={wf.title || 'Unnamed Workflow'}>
{wf.title || 'Unnamed Workflow'}
</h3>
<div className="mt-auto">
{wf.command && (
<div className="text-sm text-slate-500 line-clamp-2 mt-4 bg-slate-50 p-3 rounded-lg border border-slate-100">
<span className="font-medium text-slate-600 block mb-1">Command:</span>
"{wf.command}"
</div>
)}
<div className="flex justify-between items-center mt-5 text-xs text-slate-400">
<span className="font-mono bg-slate-100 px-2 py-1 rounded truncate max-w-[140px]" title={wf.trace_id}>
{wf.trace_id}
</span>
{wf.created_at && (
<span>{new Date(wf.created_at).toLocaleDateString()}</span>
)}
</div>
</div>
<div className="flex-1 flex flex-col p-8 overflow-auto">
<div className="max-w-6xl mx-auto w-full">
<div className="flex justify-between items-end mb-8">
<div>
<div className="flex items-center gap-2 mb-1">
<Zap size={18} className="text-accent" />
<h1 className="text-2xl font-bold text-text-primary tracking-tight">{t('workflow.workflows')}</h1>
</div>
))}
<p className="text-sm text-text-muted">{t('workflow.manageWorkflows')}</p>
</div>
<button
onClick={() => onSelectWorkflow('new')}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-white font-medium rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm"
>
<span className="text-base leading-none">+</span>
{t('workflow.createWorkflow')}
</button>
</div>
)}
{workflows.length === 0 ? (
<div className="flex flex-col items-center justify-center border border-dashed border-border-primary rounded-2xl bg-bg-card/50 p-16 text-center">
<div className="w-14 h-14 bg-bg-secondary rounded-2xl flex items-center justify-center mb-4">
<PlayCircle size={28} className="text-text-muted" />
</div>
<h3 className="text-base font-semibold text-text-primary mb-1">{t('workflow.noWorkflows')}</h3>
<p className="text-sm text-text-muted max-w-xs">{t('workflow.workflowsAppearHere')}</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{workflows.map((wf) => {
const meta = getStatusMeta(wf.status);
const Icon = meta.icon;
return (
<div
key={wf.trace_id}
onClick={() => onSelectWorkflow(wf.trace_id)}
className={`group relative bg-bg-card rounded-2xl p-5 border border-border-primary card-hover cursor-pointer overflow-hidden ${meta.glow}`}
>
{/* Status glow on hover */}
<div className={`absolute top-0 right-0 w-24 h-24 ${meta.bg} rounded-full blur-2xl opacity-0 group-hover:opacity-40 transition-opacity -translate-y-1/2 translate-x-1/2`} />
<div className="relative">
<div className="flex justify-between items-start mb-4">
<div className={`p-2 rounded-xl ${meta.bg} ${meta.border} border`}>
<Icon size={18} className={meta.color} />
</div>
<ArrowRight size={16} className="text-text-muted opacity-0 group-hover:opacity-100 transition-all group-hover:translate-x-0.5" />
</div>
<h3 className="text-base font-semibold text-text-primary mb-1 line-clamp-1" title={wf.title || 'Unnamed'}>
{wf.title || 'Unnamed Workflow'}
</h3>
{wf.command && (
<p className="text-xs text-text-muted line-clamp-2 mb-4">
{wf.command}
</p>
)}
<div className="flex items-center justify-between pt-3 border-t border-border-secondary">
<span className="text-[10px] font-mono text-text-muted bg-bg-secondary px-2 py-0.5 rounded">
{wf.trace_id.slice(0, 8)}...
</span>
{wf.created_at && (
<span className="text-[10px] text-text-muted">{new Date(wf.created_at).toLocaleDateString()}</span>
)}
</div>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
);
}
@@ -1,99 +1,69 @@
import { ChevronLeft, ChevronRight, MessageSquare, Workflow, Box, Bot } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { MessageSquare, Workflow, Box, Bot, ChevronLeft, ChevronRight } from 'lucide-react';
import { useAppStore } from '../../store/useAppStore';
interface CollapsibleSidebarProps {
mode: 'work' | 'agent';
isOpen: boolean;
setIsOpen: (isOpen: boolean) => void;
workTab: 'chat' | 'workflow';
setWorkTab: (tab: 'chat' | 'workflow') => void;
agentTab: 'plugin' | 'agents';
setAgentTab: (tab: 'plugin' | 'agents') => void;
}
export function CollapsibleSidebar() {
const { t } = useTranslation();
const {
mode,
isSidebarOpen,
setIsSidebarOpen,
workTab,
setWorkTab,
agentTab,
setAgentTab,
} = useAppStore();
export function CollapsibleSidebar({
mode,
isOpen,
setIsOpen,
workTab,
setWorkTab,
agentTab,
setAgentTab,
}: CollapsibleSidebarProps) {
const navItems = mode === 'work'
? [
{ key: 'chat', label: t('nav.chat'), icon: MessageSquare },
{ key: 'workflow', label: t('nav.workflow'), icon: Workflow },
]
: [
{ key: 'plugin', label: t('nav.plugin'), icon: Box },
{ key: 'agents', label: t('nav.agents'), icon: Bot },
];
const getWorkNav = () => (
<>
<button
onClick={() => setWorkTab('chat')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium transition-all ${
workTab === 'chat'
? 'bg-blue-100 text-blue-800 border-r-4 border-blue-600'
: 'text-slate-600 hover:bg-blue-50 hover:text-blue-800 border-r-4 border-transparent'
}`}
>
<MessageSquare size={18} className="mr-3" />
{isOpen && <span>Chat</span>}
</button>
<button
onClick={() => setWorkTab('workflow')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium transition-all ${
workTab === 'workflow'
? 'bg-blue-100 text-blue-800 border-r-4 border-blue-600'
: 'text-slate-600 hover:bg-blue-50 hover:text-blue-800 border-r-4 border-transparent'
}`}
>
<Workflow size={18} className="mr-3" />
{isOpen && <span>Workflow</span>}
</button>
</>
);
const activeTab = mode === 'work' ? workTab : agentTab;
const setTab = mode === 'work' ? setWorkTab as (v: string) => void : setAgentTab as (v: string) => void;
const getAgentNav = () => (
<>
<button
onClick={() => setAgentTab('plugin')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium transition-all ${
agentTab === 'plugin'
? 'bg-blue-100 text-blue-800 border-r-4 border-blue-600'
: 'text-slate-600 hover:bg-blue-50 hover:text-blue-800 border-r-4 border-transparent'
}`}
>
<Box size={18} className="mr-3" />
{isOpen && <span>Plugin</span>}
</button>
<button
onClick={() => setAgentTab('agents')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium transition-all ${
agentTab === 'agents'
? 'bg-blue-100 text-blue-800 border-r-4 border-blue-600'
: 'text-slate-600 hover:bg-blue-50 hover:text-blue-800 border-r-4 border-transparent'
}`}
>
<Bot size={18} className="mr-3" />
{isOpen && <span>Agents</span>}
</button>
</>
);
// Background is slightly darker than the page background but lighter than the topbar
// Let's make it the "main nav light blue" level. We will use bg-blue-50 for it,
// and make the page background essentially white or extremely light slate,
// but since the instruction says "page background is the next lightest",
// let's use bg-blue-100 for Sidebar and bg-blue-50 for page background.
return (
<div
className={`bg-blue-50 border-r border-blue-100 flex flex-col transition-all duration-300 relative z-10 ${
isOpen ? 'w-64' : 'w-16'
className={`bg-bg-sidebar border-r border-border-primary flex flex-col transition-all duration-300 relative z-10 ${
isSidebarOpen ? 'w-56' : 'w-14'
}`}
>
<div className="flex-1 py-4 space-y-2 overflow-y-auto">
{mode === 'work' ? getWorkNav() : getAgentNav()}
<div className="flex-1 py-3 space-y-1">
{navItems.map((item) => {
const isActive = activeTab === item.key;
return (
<button
key={item.key}
onClick={() => setTab(item.key as any)}
className={`w-full flex items-center mx-1.5 rounded-lg transition-all duration-200 group ${
isActive
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
} ${isSidebarOpen ? 'px-3 py-2.5 gap-3' : 'px-0 py-2.5 justify-center'}`}
style={{ width: isSidebarOpen ? 'calc(100% - 12px)' : 'calc(100% - 12px)' }}
>
<item.icon size={18} className={`flex-shrink-0 transition-transform group-hover:scale-110 ${isActive ? 'text-accent' : ''}`} />
{isSidebarOpen && (
<span className="text-xs font-medium">{item.label}</span>
)}
{isActive && isSidebarOpen && (
<div className="ml-auto w-1 h-1 rounded-full bg-accent" />
)}
</button>
);
})}
</div>
<button
onClick={() => setIsOpen(!isOpen)}
className="h-10 w-10 absolute -right-5 top-1/2 -translate-y-1/2 bg-white border border-blue-200 rounded-full flex items-center justify-center text-slate-400 hover:text-blue-600 hover:border-blue-400 shadow-sm transition-all z-20"
onClick={() => setIsSidebarOpen(!isSidebarOpen)}
className="absolute -right-3 top-6 w-6 h-6 bg-bg-card border border-border-primary rounded-full flex items-center justify-center text-text-muted hover:text-accent hover:border-accent shadow-sm transition-all z-20"
>
{isOpen ? <ChevronLeft size={16} /> : <ChevronRight size={16} />}
{isSidebarOpen ? <ChevronLeft size={12} /> : <ChevronRight size={12} />}
</button>
</div>
);
+104 -43
View File
@@ -1,57 +1,118 @@
import { Settings, BrainCircuit } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import {
Settings,
Sun,
Moon,
Globe,
LogOut,
Zap,
} from 'lucide-react';
import { useAppStore } from '../../store/useAppStore';
interface TopBarProps {
mode: 'work' | 'agent';
setMode: (mode: 'work' | 'agent') => void;
showSettings: boolean;
setShowSettings: (show: boolean) => void;
}
export function TopBar() {
const { t, i18n } = useTranslation();
const {
mode,
setMode,
showSettings,
setShowSettings,
setTheme,
resolvedTheme,
setIsAuthenticated,
} = useAppStore();
const toggleTheme = () => {
setTheme(resolvedTheme === 'dark' ? 'light' : 'dark');
};
const toggleLanguage = () => {
i18n.changeLanguage(i18n.language.startsWith('zh') ? 'en' : 'zh');
};
const handleLogout = () => {
localStorage.removeItem('token');
setIsAuthenticated(false);
window.location.reload();
};
export function TopBar({ mode, setMode, showSettings, setShowSettings }: TopBarProps) {
return (
<div className="h-14 bg-blue-100 text-blue-900 flex items-center justify-between px-4 shrink-0 shadow-sm z-20 relative">
{/* Left: Logo */}
<div className="flex items-center space-x-2 font-bold text-xl tracking-tight text-blue-900">
<BrainCircuit className="text-blue-600" size={24} />
<span>kilostar</span>
<div className="h-14 glass text-text-primary flex items-center justify-between px-5 shrink-0 z-50 relative">
{/* Left: Logo with status */}
<div className="flex items-center gap-3">
<div className="relative">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-accent to-glow-purple flex items-center justify-center text-white shadow-lg">
<Zap size={18} fill="currentColor" />
</div>
<span className="status-dot absolute -bottom-0.5 -right-0.5 w-2.5 h-2.5 rounded-full bg-success border-2 border-bg-card" />
</div>
<div className="flex flex-col leading-none">
<span className="font-bold text-sm tracking-tight text-text-primary">{t('app.name')}</span>
<span className="text-[10px] text-text-muted font-medium tracking-wide">{t('app.tagline')}</span>
</div>
</div>
{/* Right Container: Mode Toggle Switch + Settings */}
<div className="flex items-center space-x-4">
{/* Center: Mode Switch */}
<div className="hidden md:flex items-center bg-bg-secondary/80 rounded-full p-0.5 border border-border-primary">
<button
onClick={() => { setMode('work'); setShowSettings(false); }}
className={`px-4 py-1.5 rounded-full text-xs font-semibold transition-all duration-200 ${
mode === 'work' && !showSettings
? 'bg-bg-card text-accent shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{t('nav.work')}
</button>
<button
onClick={() => { setMode('agent'); setShowSettings(false); }}
className={`px-4 py-1.5 rounded-full text-xs font-semibold transition-all duration-200 ${
mode === 'agent' && !showSettings
? 'bg-bg-card text-accent shadow-sm'
: 'text-text-muted hover:text-text-secondary'
}`}
>
{t('nav.agent')}
</button>
</div>
{/* Mode Toggle Switch */}
<div className="flex items-center space-x-1 bg-blue-200/50 p-1 rounded-full border border-blue-200">
<button
onClick={() => { setMode('work'); setShowSettings(false); }}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
mode === 'work' && !showSettings
? 'bg-white text-blue-700 shadow-sm'
: 'text-blue-700 hover:text-blue-900 hover:bg-white/50'
}`}
>
Work
</button>
<button
onClick={() => { setMode('agent'); setShowSettings(false); }}
className={`px-4 py-1.5 rounded-full text-sm font-medium transition-all ${
mode === 'agent' && !showSettings
? 'bg-white text-blue-700 shadow-sm'
: 'text-blue-700 hover:text-blue-900 hover:bg-white/50'
}`}
>
Agent
</button>
</div>
{/* Right: Actions */}
<div className="flex items-center gap-1">
<button
onClick={toggleLanguage}
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-hover transition-all"
title={i18n.language.startsWith('zh') ? 'Switch to English' : '切换到中文'}
>
<Globe size={16} />
</button>
<button
onClick={toggleTheme}
className="p-2 rounded-lg text-text-muted hover:text-text-primary hover:bg-bg-hover transition-all"
title={resolvedTheme === 'dark' ? 'Light mode' : 'Dark mode'}
>
{resolvedTheme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
</button>
<div className="w-px h-4 bg-border-primary mx-1" />
{/* Settings */}
<button
onClick={() => setShowSettings(!showSettings)}
className={`p-2 rounded-full transition-colors ${
showSettings ? 'bg-white text-blue-700 shadow-sm' : 'text-blue-700 hover:bg-white/50 hover:text-blue-900'
className={`p-2 rounded-lg transition-all ${
showSettings
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-primary hover:bg-bg-hover'
}`}
title="Settings"
title={t('nav.settings')}
>
<Settings size={20} />
<Settings size={16} />
</button>
<button
onClick={handleLogout}
className="p-2 rounded-lg text-text-muted hover:text-danger hover:bg-danger-bg transition-all ml-0.5"
title="Logout"
>
<LogOut size={16} />
</button>
</div>
</div>
+25 -26
View File
@@ -1,35 +1,34 @@
import { useTranslation } from 'react-i18next';
import { useAppStore } from '../../store/useAppStore';
import { SkillSettings } from './SkillSettings';
import { ToolSettings } from './ToolSettings';
interface PluginLayoutProps {
resourceTab: string;
setResourceTab: (tab: string) => void;
}
export function PluginLayout() {
const { t } = useTranslation();
const { resourceTab, setResourceTab } = useAppStore();
const tabs = [
{ key: 'skill', label: t('agent.skills') },
{ key: 'tool', label: t('agent.tools') },
];
export function PluginLayout({ resourceTab, setResourceTab }: PluginLayoutProps) {
return (
<div className="flex-1 flex flex-col bg-slate-50 overflow-hidden">
{/* Top Tabs for Plugin Module */}
<div className="h-14 border-b border-slate-200 bg-white flex items-center px-6 shadow-sm z-10 shrink-0 space-x-6">
<button
onClick={() => setResourceTab('skill')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
resourceTab === 'skill' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
Skills
</button>
<button
onClick={() => setResourceTab('tool')}
className={`py-4 text-sm font-medium border-b-2 transition-colors ${
resourceTab === 'tool' ? 'border-blue-600 text-blue-600' : 'border-transparent text-slate-500 hover:text-slate-800'
}`}
>
Tools
</button>
<div className="flex-1 flex flex-col bg-bg-secondary overflow-hidden">
<div className="h-12 border-b border-border-primary bg-bg-card/80 backdrop-blur flex items-center px-6 shrink-0 gap-1">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setResourceTab(tab.key)}
className={`px-4 py-2 text-xs font-semibold rounded-lg transition-all ${
resourceTab === tab.key
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Main Content */}
<div className="flex-1 overflow-y-auto p-8">
{resourceTab === 'skill' && <SkillSettings />}
{resourceTab === 'tool' && <ToolSettings />}
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Download, Trash2, Plus, Box, Sparkles } from 'lucide-react';
import apiClient from '../../api/client';
import { Download, Trash2, Plus, Box } from 'lucide-react';
export function SkillSettings() {
const [skills, setSkills] = useState<string[]>([]);
@@ -15,14 +15,8 @@ export function SkillSettings() {
setLoading(true);
try {
const response = await apiClient.get('/api/v1/resource/skill');
const skillsData = response.data.skills || {};
// skillsData might be an object mapping skill names to their details, or it might be an array in some versions.
// We ensure it is an array of strings (skill names)
if (Array.isArray(skillsData)) {
setSkills(skillsData);
} else {
setSkills(Object.keys(skillsData));
}
const data = response.data.skills || {};
setSkills(Array.isArray(data) ? data : Object.keys(data));
} catch (err) {
console.error('Failed to fetch skills:', err);
} finally {
@@ -30,29 +24,21 @@ export function SkillSettings() {
}
};
useEffect(() => {
fetchSkills();
}, []);
useEffect(() => { fetchSkills(); }, []);
const handleInstall = async (e: React.FormEvent) => {
e.preventDefault();
if (!repoUrl) return;
setInstalling(true);
setMessage('');
setError('');
try {
await apiClient.post('/api/v1/resource/skill', {
repo_url: repoUrl,
path: path || null
});
await apiClient.post('/api/v1/resource/skill', { repo_url: repoUrl, path: path || null });
setMessage('Skill installed successfully');
setRepoUrl('');
setPath('');
fetchSkills();
} catch (err: any) {
console.error(err);
setError(err.response?.data?.message || 'Failed to install skill');
} finally {
setInstalling(false);
@@ -60,69 +46,55 @@ export function SkillSettings() {
};
const handleDelete = async (skillName: string) => {
if (!confirm(`Are you sure you want to delete ${skillName}?`)) return;
if (!confirm(`Delete ${skillName}?`)) return;
try {
await apiClient.delete(`/api/v1/resource/skill/${skillName}`);
fetchSkills();
} catch (err: any) {
console.error('Failed to delete skill:', err);
} catch {
alert('Failed to delete skill');
}
};
return (
<div className="max-w-4xl space-y-6">
<div className="mb-8">
<h1 className="text-2xl font-bold text-slate-800">Skill Management</h1>
<p className="text-slate-500 mt-1">Manage agent skills and functions.</p>
<div>
<div className="flex items-center gap-2 mb-1">
<Sparkles size={16} className="text-accent" />
<h1 className="text-lg font-bold text-text-primary">Skill Management</h1>
</div>
<p className="text-sm text-text-muted">Manage agent skills and functions</p>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-100 flex items-center space-x-3">
<div className="w-10 h-10 bg-blue-50 text-blue-600 rounded-lg flex items-center justify-center">
<Download size={20} />
</div>
<div>
<h2 className="text-lg font-semibold text-slate-800">Install Skill</h2>
<p className="text-sm text-slate-500">Install a new skill from a repository.</p>
</div>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex items-center gap-3">
<div className="w-9 h-9 rounded-xl bg-accent-light flex items-center justify-center">
<Download size={16} className="text-accent" />
</div>
<div>
<h2 className="text-sm font-bold text-text-primary">Install Skill</h2>
<p className="text-[11px] text-text-muted">Install from a repository</p>
</div>
</div>
<div className="p-6">
<form onSubmit={handleInstall} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Repository URL</label>
<input
type="text"
required
value={repoUrl}
onChange={(e) => setRepoUrl(e.target.value)}
placeholder="https://github.com/user/repo"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Repository URL</label>
<input type="text" required value={repoUrl} onChange={(e) => setRepoUrl(e.target.value)} placeholder="https://github.com/user/repo"
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Path (Optional)</label>
<input
type="text"
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="e.g. subfolder/path"
className="w-full px-4 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">Path (Optional)</label>
<input type="text" value={path} onChange={(e) => setPath(e.target.value)} placeholder="subfolder/path"
className="w-full px-3.5 py-2.5 bg-bg-input border border-border-primary rounded-xl text-sm text-text-primary focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent placeholder:text-text-muted/50" />
</div>
</div>
{message && <div className="text-green-600 text-sm">{message}</div>}
{error && <div className="text-red-600 text-sm">{error}</div>}
{message && <div className="text-xs text-success">{message}</div>}
{error && <div className="text-xs text-danger">{error}</div>}
<div className="flex justify-end">
<button
type="submit"
disabled={installing}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50"
>
<Plus size={16} className="mr-2" />
<button type="submit" disabled={installing}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium disabled:opacity-50">
<Plus size={14} />
{installing ? 'Installing...' : 'Install'}
</button>
</div>
@@ -130,31 +102,27 @@ export function SkillSettings() {
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-800">Installed Skills</h2>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary">
<h2 className="text-sm font-bold text-text-primary">Installed Skills ({skills.length})</h2>
</div>
<div className="p-6">
{loading ? (
<div className="text-slate-500 text-sm">Loading skills...</div>
<div className="text-text-muted text-sm">Loading skills...</div>
) : skills.length === 0 ? (
<div className="text-slate-500 text-sm">No skills installed yet.</div>
<div className="text-text-muted text-sm">No skills installed yet.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{skills.map((skill) => (
<div key={skill} className="p-4 border border-slate-200 rounded-xl flex items-center justify-between hover:shadow-sm transition-shadow">
<div className="flex items-center space-x-3">
<div className="w-8 h-8 rounded-lg bg-slate-100 flex items-center justify-center text-slate-500">
<Box size={16} />
<div key={skill} className="flex items-center justify-between p-3.5 bg-bg-secondary border border-border-secondary rounded-xl hover:border-accent/30 transition-all group">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-bg-card border border-border-primary flex items-center justify-center">
<Box size={14} className="text-text-muted" />
</div>
<span className="font-medium text-slate-800">{skill}</span>
<span className="text-sm font-medium text-text-primary">{skill}</span>
</div>
<button
onClick={() => handleDelete(skill)}
className="p-2 text-slate-400 hover:text-red-500 hover:bg-red-50 rounded-lg transition-colors"
title="Delete Skill"
>
<Trash2 size={16} />
<button onClick={() => handleDelete(skill)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all opacity-0 group-hover:opacity-100">
<Trash2 size={14} />
</button>
</div>
))}
+19 -22
View File
@@ -1,21 +1,18 @@
import { useState, useEffect } from 'react';
import { Package } from 'lucide-react';
import { Package, Wrench } from 'lucide-react';
import apiClient from '../../api/client';
export function ToolSettings() {
const [tools, setTools] = useState<string[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchTools();
}, []);
useEffect(() => { fetchTools(); }, []);
const fetchTools = async () => {
try {
setLoading(true);
const response = await apiClient.get('/api/v1/resource/tool');
const toolsData = response.data.tools || [];
setTools(toolsData);
setTools(response.data.tools || []);
} catch (err) {
console.error('Failed to fetch tools:', err);
} finally {
@@ -26,33 +23,33 @@ export function ToolSettings() {
return (
<div className="max-w-4xl space-y-6">
<div>
<h3 className="text-xl font-semibold text-slate-800">Installed Tools</h3>
<p className="text-slate-500 mt-1">Manage agent tools and functions.</p>
<div className="flex items-center gap-2 mb-1">
<Wrench size={16} className="text-accent" />
<h3 className="text-lg font-bold text-text-primary">Installed Tools</h3>
</div>
<p className="text-sm text-text-muted">Manage agent tools and functions</p>
</div>
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-6 border-b border-slate-100 flex justify-between items-center bg-slate-50/50">
<div className="bg-bg-card border border-border-primary rounded-2xl shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex justify-between items-center bg-bg-secondary">
<div>
<h4 className="font-medium text-slate-800">Available Tools</h4>
<p className="text-sm text-slate-500">List of installed tools available for agents.</p>
<h4 className="text-sm font-bold text-text-primary">Available Tools</h4>
<p className="text-[11px] text-text-muted">Installed tools for agents</p>
</div>
</div>
<div className="p-6">
{loading ? (
<div className="text-slate-500 text-sm">Loading tools...</div>
<div className="text-text-muted text-sm">Loading tools...</div>
) : tools.length === 0 ? (
<div className="text-slate-500 text-sm">No tools installed yet.</div>
<div className="text-text-muted text-sm">No tools installed yet.</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{tools.map((tool) => (
<div key={tool} className="p-4 border border-slate-200 rounded-xl flex items-center justify-between hover:shadow-sm transition-shadow">
<div className="flex items-center">
<div className="w-10 h-10 bg-purple-50 rounded-lg flex items-center justify-center mr-3">
<Package size={20} className="text-purple-600" />
</div>
<span className="font-medium text-slate-800">{tool}</span>
<div key={tool} className="flex items-center gap-3 p-3.5 bg-bg-secondary border border-border-secondary rounded-xl hover:border-accent/30 transition-all">
<div className="w-9 h-9 rounded-lg bg-accent-light flex items-center justify-center">
<Package size={16} className="text-accent" />
</div>
<span className="text-sm font-medium text-text-primary">{tool}</span>
</div>
))}
</div>
@@ -1,44 +1,48 @@
import { useTranslation } from 'react-i18next';
import { Users, Sliders } from 'lucide-react';
import { useAppStore } from '../../store/useAppStore';
import { UsersSettings } from './UsersSettings';
import { SystemSettings } from './SystemSettings';
interface SettingsLayoutProps {
settingsTab: string;
setSettingsTab: (tab: string) => void;
}
export function SettingsLayout() {
const { t } = useTranslation();
const { settingsTab, setSettingsTab } = useAppStore();
const tabs = [
{ key: 'users', label: t('settings.userManagement'), icon: Users },
{ key: 'system', label: t('settings.systemSettings'), icon: Sliders },
];
export function SettingsLayout({ settingsTab, setSettingsTab }: SettingsLayoutProps) {
return (
<div className="flex-1 flex bg-slate-50 overflow-hidden">
{/* Settings Inner Sidebar */}
<div className="w-64 bg-white border-r border-slate-200 flex flex-col z-0">
<div className="p-6 border-b border-slate-100">
<h2 className="text-lg font-semibold text-slate-800">Settings</h2>
</div>
<div className="flex-1 p-4 space-y-2 overflow-y-auto">
<button
onClick={() => setSettingsTab('users')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'users' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Users size={18} className="mr-3" />
User Management
</button>
<button
onClick={() => setSettingsTab('system')}
className={`w-full flex items-center px-4 py-3 text-sm font-medium rounded-xl transition-all ${settingsTab === 'system' ? 'bg-blue-50 text-blue-600' : 'text-slate-600 hover:bg-slate-50 hover:text-slate-900'}`}
>
<Sliders size={18} className="mr-3" />
System Settings
</button>
</div>
</div>
<div className="flex-1 flex bg-bg-secondary overflow-hidden">
<div className="w-56 bg-bg-sidebar border-r border-border-primary flex flex-col">
<div className="px-5 py-4 border-b border-border-primary">
<h2 className="text-sm font-bold text-text-primary">{t('settings.settings')}</h2>
</div>
<div className="p-2 space-y-0.5">
{tabs.map((tab) => (
<button
key={tab.key}
onClick={() => setSettingsTab(tab.key)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-xs font-medium transition-all ${
settingsTab === tab.key
? 'bg-accent-light text-accent'
: 'text-text-muted hover:text-text-secondary hover:bg-bg-hover'
}`}
>
<tab.icon size={15} />
{tab.label}
</button>
))}
</div>
</div>
{/* Settings Main Content */}
<div className="flex-1 overflow-y-auto p-8">
<div className="flex-1 overflow-y-auto p-8">
<div className="max-w-3xl mx-auto">
{settingsTab === 'users' && <UsersSettings />}
{settingsTab === 'system' && <SystemSettings />}
</div>
</div>
</div>
</div>
);
}
@@ -1,106 +1,114 @@
import { useState, useEffect } from 'react';
import { Globe, Server, Save } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Globe, Server, Save, Check } from 'lucide-react';
import { useAppStore } from '../../store/useAppStore';
export function SystemSettings() {
const [language, setLanguage] = useState(localStorage.getItem('language') || 'English');
const [theme, setTheme] = useState(localStorage.getItem('theme') || 'Light');
const { t, i18n } = useTranslation();
const { theme, setTheme } = useAppStore();
const [localLang, setLocalLang] = useState(i18n.language.startsWith('zh') ? 'zh' : 'en');
const [localTheme, setLocalTheme] = useState(theme);
const [debugMode, setDebugMode] = useState(true);
const [saved, setSaved] = useState(false);
const handleSave = () => {
localStorage.setItem('language', language);
localStorage.setItem('theme', theme);
// Apply theme
if (theme === 'Dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
// In a real app, you would dispatch a language change event or context update here
alert(`Settings saved!\nLanguage: ${language}\nTheme: ${theme}`);
i18n.changeLanguage(localLang);
setTheme(localTheme);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
};
// Initialize theme on mount if needed
useEffect(() => {
if (theme === 'Dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
}, [theme]);
return (
<div className="max-w-4xl mx-auto">
<div className="mb-6">
<h3 className="text-xl font-semibold text-slate-800">System Settings</h3>
<p className="text-sm text-slate-500 mt-1">Global platform configurations.</p>
<div className="space-y-6">
<div>
<h3 className="text-lg font-bold text-text-primary">{t('settings.systemSettings')}</h3>
<p className="text-sm text-text-muted mt-0.5">Global platform configurations</p>
</div>
<div className="space-y-6">
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
<Globe size={16} className="mr-2 text-slate-500" />
General
</h4>
<div className="space-y-4 max-w-md">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">System Language</label>
<select
value={language}
onChange={(e) => setLanguage(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
>
<option value="English">English</option>
<option value="简体中文">简体中文</option>
</select>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex items-center gap-2">
<Globe size={15} className="text-text-muted" />
<h4 className="text-sm font-semibold text-text-primary">{t('settings.general')}</h4>
</div>
<div className="p-6 space-y-5 max-w-md">
<div>
<label className="block text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">{t('settings.language')}</label>
<div className="flex gap-2">
{[
{ value: 'en', label: 'English' },
{ value: 'zh', label: '简体中文' },
].map((opt) => (
<button
key={opt.value}
onClick={() => setLocalLang(opt.value)}
className={`flex-1 px-4 py-2.5 rounded-xl text-sm font-medium border transition-all ${
localLang === opt.value
? 'bg-accent-light border-accent/30 text-accent'
: 'bg-bg-input border-border-primary text-text-secondary hover:border-text-muted'
}`}
>
{opt.label}
</button>
))}
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Theme</label>
<select
value={theme}
onChange={(e) => setTheme(e.target.value)}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all"
>
<option value="Light">Light</option>
<option value="Dark">Dark</option>
<option value="System Default">System Default</option>
</select>
</div>
<div>
<label className="block text-xs font-semibold text-text-secondary mb-2 uppercase tracking-wider">{t('settings.theme')}</label>
<div className="grid grid-cols-3 gap-2">
{[
{ value: 'light', label: t('settings.light') },
{ value: 'dark', label: t('settings.dark') },
{ value: 'system', label: t('settings.systemDefault') },
].map((opt) => (
<button
key={opt.value}
onClick={() => setLocalTheme(opt.value as any)}
className={`px-3 py-2.5 rounded-xl text-xs font-medium border transition-all ${
localTheme === opt.value
? 'bg-accent-light border-accent/30 text-accent'
: 'bg-bg-input border-border-primary text-text-secondary hover:border-text-muted'
}`}
>
{opt.label}
</button>
))}
</div>
</div>
</div>
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm p-6">
<h4 className="text-sm font-semibold text-slate-800 mb-4 flex items-center">
<Server size={16} className="mr-2 text-slate-500" />
Cluster & Runtime
</h4>
<div className="space-y-4 max-w-md">
<div className="flex items-center mt-4">
<input
type="checkbox"
id="debug_mode"
checked={debugMode}
onChange={(e) => setDebugMode(e.target.checked)}
className="w-4 h-4 text-blue-600 border-slate-300 rounded focus:ring-blue-500"
/>
<label htmlFor="debug_mode" className="ml-2 block text-sm text-slate-700">
Enable debug logging
</label>
</div>
</div>
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<div className="px-6 py-4 border-b border-border-primary flex items-center gap-2">
<Server size={15} className="text-text-muted" />
<h4 className="text-sm font-semibold text-text-primary">{t('settings.clusterRuntime')}</h4>
</div>
<div className="p-6">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={debugMode}
onChange={(e) => setDebugMode(e.target.checked)}
className="w-4 h-4 rounded border-border-primary text-accent focus:ring-accent/20"
/>
<span className="text-sm text-text-secondary">{t('settings.debugLogging')}</span>
</label>
</div>
</div>
<div className="flex justify-end">
<button
onClick={handleSave}
className="flex items-center px-6 py-2.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
>
<Save size={16} className="mr-2" />
Save Changes
</button>
</div>
<div className="flex justify-end items-center gap-3">
{saved && (
<span className="flex items-center gap-1.5 text-xs text-success animate-fade-in">
<Check size={14} />
{t('settings.saved')}
</span>
)}
<button
onClick={handleSave}
className="flex items-center gap-2 px-5 py-2.5 bg-accent text-white rounded-xl font-medium hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm"
>
<Save size={14} />
{t('settings.saveChanges')}
</button>
</div>
</div>
);
+108 -176
View File
@@ -1,15 +1,19 @@
import { useEffect } from 'react';
import { useState } from 'react';
import { Plus, Edit2, Trash2, X } from 'lucide-react';
import type { User } from '../../types';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Plus, Edit2, Trash2, X, User, Shield } from 'lucide-react';
import type { User as UserType } from '../../types';
import apiClient from '../../api/client';
export function UsersSettings() {
const [users, setUsers] = useState<User[]>([]);
const { t } = useTranslation();
const [users, setUsers] = useState<UserType[]>([]);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null);
const [editingUser, setEditingUser] = useState<UserType | null>(null);
const [editRole, setEditRole] = useState('User');
const [formData, setFormData] = useState({ username: '', password: '' });
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
const fetchUsers = async () => {
try {
@@ -20,54 +24,25 @@ export function UsersSettings() {
}
};
useEffect(() => {
fetchUsers();
}, []);
const [formData, setFormData] = useState({
username: '',
password: ''
});
const [submitLoading, setSubmitLoading] = useState(false);
const [error, setError] = useState('');
const handleOpenModal = () => {
setFormData({ username: '', password: '' });
setError('');
setIsModalOpen(true);
};
const handleCloseModal = () => {
setIsModalOpen(false);
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
useEffect(() => { fetchUsers(); }, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.username || !formData.password) {
setError('Please fill in both username and password.');
setError(t('settings.fillBoth'));
return;
}
setSubmitLoading(true);
setError('');
try {
await apiClient.post('/api/v1/auth/register', {
user_name: formData.username,
password: formData.password
password: formData.password,
});
await fetchUsers();
handleCloseModal();
setIsModalOpen(false);
setFormData({ username: '', password: '' });
} catch (err) {
console.error("Failed to register user", err);
setError('Registration failed. Please try again.');
} finally {
setSubmitLoading(false);
@@ -75,18 +50,16 @@ export function UsersSettings() {
};
const handleDeleteUser = async (userId: string | undefined) => {
if (!userId) return;
if (!confirm('Are you sure you want to delete this user?')) return;
if (!userId || !confirm(t('settings.deleteConfirm'))) return;
try {
await apiClient.delete(`/api/v1/auth/${userId}`);
await fetchUsers();
} catch (err) {
console.error("Failed to delete user", err);
alert("Failed to delete user");
} catch {
alert(t('settings.deleteFailed'));
}
};
const handleEditClick = (user: User) => {
const handleEditClick = (user: UserType) => {
setEditingUser(user);
setEditRole(user.role || 'User');
setIsEditModalOpen(true);
@@ -99,132 +72,113 @@ export function UsersSettings() {
try {
await apiClient.put('/api/v1/auth/authority', {
user_id: editingUser.user_id,
new_authority: editRole
new_authority: editRole,
});
await fetchUsers();
setIsEditModalOpen(false);
} catch (err) {
console.error("Failed to update user role", err);
alert("Failed to update user role");
} catch {
alert(t('settings.updateFailed'));
} finally {
setSubmitLoading(false);
}
};
return (
<div className="max-w-4xl mx-auto">
<div className="flex justify-between items-center mb-6">
<div className="space-y-6">
<div className="flex justify-between items-end">
<div>
<h3 className="text-xl font-semibold text-slate-800">User Management</h3>
<p className="text-sm text-slate-500 mt-1">Manage system users and their roles.</p>
<h3 className="text-lg font-bold text-text-primary">{t('settings.userManagement')}</h3>
<p className="text-sm text-text-muted mt-0.5">Manage system users and their roles</p>
</div>
<button
onClick={handleOpenModal}
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm font-medium transition-colors shadow-sm cursor-pointer"
onClick={() => { setFormData({ username: '', password: '' }); setError(''); setIsModalOpen(true); }}
className="flex items-center gap-2 px-4 py-2.5 bg-accent text-white rounded-xl hover:bg-accent-hover transition-all shadow-lg shadow-accent/15 text-sm font-medium"
>
<Plus size={16} className="mr-2" />
Add User
<Plus size={14} />
{t('settings.addUser')}
</button>
</div>
<div className="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div className="bg-bg-card rounded-2xl border border-border-primary shadow-sm overflow-hidden">
<table className="w-full text-left text-sm">
<thead className="bg-slate-50 border-b border-slate-200 text-slate-500">
<tr>
<th className="px-6 py-4 font-medium">Username</th>
<th className="px-6 py-4 font-medium">Role</th>
<th className="px-6 py-4 font-medium">Status</th>
<th className="px-6 py-4 font-medium text-right">Actions</th>
<thead>
<tr className="bg-bg-secondary border-b border-border-primary text-text-muted text-xs uppercase tracking-wider">
<th className="px-5 py-3 font-semibold">{t('settings.username')}</th>
<th className="px-5 py-3 font-semibold">{t('settings.role')}</th>
<th className="px-5 py-3 font-semibold">{t('settings.status')}</th>
<th className="px-5 py-3 font-semibold text-right">{t('settings.actions')}</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{users.map((user, i) => (
<tr key={i} className="hover:bg-slate-50 transition-colors">
<td className="px-6 py-4 font-medium text-slate-800">{user.user_name}</td>
<td className="px-6 py-4 text-slate-600">{user.role || 'User'}</td>
<td className="px-6 py-4">
<span className={`px-2.5 py-1 rounded-full text-xs font-medium ${user.status === 'Active' ? 'bg-green-100 text-green-700 border border-green-200' : 'bg-slate-100 text-slate-600 border border-slate-200'}`}>
{user.status || 'Active'}
<tbody className="divide-y divide-border-secondary">
{users.map((user) => (
<tr key={user.user_id || user.user_name} className="hover:bg-bg-hover transition-colors">
<td className="px-5 py-3.5">
<div className="flex items-center gap-2.5">
<div className="w-7 h-7 rounded-full bg-accent-light flex items-center justify-center">
<User size={13} className="text-accent" />
</div>
<span className="font-medium text-text-primary">{user.user_name}</span>
</div>
</td>
<td className="px-5 py-3.5">
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium bg-bg-secondary text-text-secondary border border-border-primary">
<Shield size={10} />
{user.role || 'User'}
</span>
</td>
<td className="px-6 py-4 text-right">
<button onClick={() => handleEditClick(user)} className="text-slate-400 hover:text-blue-600 mr-3 transition-colors cursor-pointer" title="Edit"><Edit2 size={16} /></button>
<button onClick={() => handleDeleteUser(user.user_id)} className="text-slate-400 hover:text-red-600 transition-colors cursor-pointer" title="Delete"><Trash2 size={16} /></button>
<td className="px-5 py-3.5">
<span className={`inline-block w-1.5 h-1.5 rounded-full mr-1.5 ${user.status === 'Active' ? 'bg-success' : 'bg-text-muted'}`} />
<span className="text-xs text-text-secondary">{user.status || t('settings.active')}</span>
</td>
<td className="px-5 py-3.5 text-right">
<button onClick={() => handleEditClick(user)} className="p-1.5 text-text-muted hover:text-accent hover:bg-accent-light rounded-lg transition-all mr-1">
<Edit2 size={14} />
</button>
<button onClick={() => handleDeleteUser(user.user_id)} className="p-1.5 text-text-muted hover:text-danger hover:bg-danger-bg rounded-lg transition-all">
<Trash2 size={14} />
</button>
</td>
</tr>
))}
{users.length === 0 && (
<tr>
<td colSpan={4} className="px-5 py-8 text-center text-text-muted text-sm">
No users found
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Add User Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Add New User</h3>
<button
onClick={handleCloseModal}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-md overflow-hidden border border-border-primary animate-fade-in-scale">
<div className="flex justify-between items-center p-5 border-b border-border-primary">
<h3 className="text-base font-bold text-text-primary">{t('settings.addNewUser')}</h3>
<button onClick={() => setIsModalOpen(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors">
<X size={18} />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
{error && (
<div className="p-3 bg-red-50 text-red-600 text-sm rounded-lg border border-red-100">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="p-5 space-y-4">
{error && <div className="p-3 bg-danger-bg text-danger text-sm rounded-xl border border-danger/20">{error}</div>}
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
<input
type="text"
name="username"
placeholder="e.g. jsmith"
value={formData.username}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('settings.username')}</label>
<input type="text" value={formData.username} onChange={(e) => setFormData({...formData, username: e.target.value})}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50" placeholder={t('settings.enterUsername')} />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Password</label>
<input
type="password"
name="password"
placeholder="Enter secure password"
value={formData.password}
onChange={handleChange}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all placeholder:text-slate-400"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('settings.password')}</label>
<input type="password" value={formData.password} onChange={(e) => setFormData({...formData, password: e.target.value})}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary placeholder:text-text-muted/50" placeholder={t('settings.enterPassword')} />
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={handleCloseModal}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
<div className="pt-2 flex justify-end gap-2">
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
{t('settings.cancel')}
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? (
<span className="flex items-center">
<svg className="animate-spin -ml-1 mr-2 h-4 w-4 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
Creating...
</span>
) : (
'Add User'
)}
<button type="submit" disabled={submitLoading} className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
{submitLoading ? 'Creating...' : t('settings.create')}
</button>
</div>
</form>
@@ -232,58 +186,36 @@ export function UsersSettings() {
</div>
)}
{/* Edit User Role Modal */}
{/* Edit Modal */}
{isEditModalOpen && editingUser && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm transition-opacity">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-md overflow-hidden animate-in fade-in zoom-in-95 duration-200">
<div className="flex justify-between items-center p-5 border-b border-slate-100">
<h3 className="text-lg font-semibold text-slate-800">Edit User Role</h3>
<button
onClick={() => setIsEditModalOpen(false)}
className="text-slate-400 hover:text-slate-600 p-1 rounded-md transition-colors cursor-pointer"
>
<X size={20} />
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 backdrop-blur-sm">
<div className="bg-bg-card rounded-2xl shadow-2xl w-full max-w-md overflow-hidden border border-border-primary animate-fade-in-scale">
<div className="flex justify-between items-center p-5 border-b border-border-primary">
<h3 className="text-base font-bold text-text-primary">{t('settings.editUserRole')}</h3>
<button onClick={() => setIsEditModalOpen(false)} className="p-1 text-text-muted hover:text-text-primary rounded-lg transition-colors">
<X size={18} />
</button>
</div>
<form onSubmit={handleEditSubmit} className="p-6 space-y-4">
<form onSubmit={handleEditSubmit} className="p-5 space-y-4">
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Username</label>
<input
type="text"
disabled
value={editingUser.user_name}
className="w-full bg-slate-50 border border-slate-200 text-sm rounded-lg px-3 py-2.5 text-slate-500"
/>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('settings.username')}</label>
<input type="text" disabled value={editingUser.user_name} className="w-full bg-bg-secondary border border-border-primary text-sm rounded-xl px-3.5 py-2.5 text-text-muted" />
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1.5">Role</label>
<select
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
className="w-full bg-white border border-slate-200 text-sm rounded-lg px-3 py-2.5 focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all cursor-pointer"
>
<label className="block text-xs font-semibold text-text-secondary mb-1.5 uppercase tracking-wider">{t('settings.role')}</label>
<select value={editRole} onChange={(e) => setEditRole(e.target.value)}
className="w-full bg-bg-input border border-border-primary text-sm rounded-xl px-3.5 py-2.5 focus:outline-none focus:ring-2 focus:ring-accent/20 focus:border-accent text-text-primary">
<option value="User">User</option>
<option value="Administrator">Administrator</option>
<option value="SuperAdministrator">SuperAdministrator</option>
</select>
</div>
<div className="pt-4 flex justify-end space-x-3">
<button
type="button"
onClick={() => setIsEditModalOpen(false)}
className="px-4 py-2 text-sm font-medium text-slate-600 bg-white border border-slate-200 rounded-lg hover:bg-slate-50 transition-colors cursor-pointer"
>
Cancel
<div className="pt-2 flex justify-end gap-2">
<button type="button" onClick={() => setIsEditModalOpen(false)} className="px-4 py-2 text-sm font-medium text-text-secondary hover:bg-bg-hover rounded-xl transition-colors">
{t('settings.cancel')}
</button>
<button
type="submit"
disabled={submitLoading}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 rounded-lg hover:bg-blue-700 transition-colors cursor-pointer disabled:opacity-70 flex items-center"
>
{submitLoading ? 'Saving...' : 'Save Changes'}
<button type="submit" disabled={submitLoading} className="px-4 py-2 text-sm font-medium text-white bg-accent rounded-xl hover:bg-accent-hover transition-colors disabled:opacity-50">
{submitLoading ? 'Saving...' : t('settings.save')}
</button>
</div>
</form>
+27
View File
@@ -0,0 +1,27 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import LanguageDetector from 'i18next-browser-languagedetector';
import en from './locales/en.json';
import zh from './locales/zh.json';
const resources = {
en: { translation: en },
zh: { translation: zh },
};
i18n
.use(LanguageDetector)
.use(initReactI18next)
.init({
resources,
fallbackLng: 'en',
interpolation: {
escapeValue: false,
},
detection: {
order: ['localStorage', 'navigator'],
caches: ['localStorage'],
},
});
export default i18n;
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "KiloStar",
"tagline": "Distributed Multi-Agent System"
},
"nav": {
"work": "Work",
"agent": "Agent",
"chat": "Chat",
"workflow": "Workflow",
"plugin": "Plugin",
"agents": "Agents",
"settings": "Settings"
},
"auth": {
"welcomeBack": "Welcome Back",
"createAccount": "Create Account",
"enterCredentials": "Enter your credentials to access your account",
"signUpToStart": "Sign up to start using the platform",
"username": "Username",
"password": "Password",
"signIn": "Sign In",
"signUp": "Sign Up",
"noAccount": "Don't have an account?",
"hasAccount": "Already have an account?",
"processing": "Processing...",
"registerSuccess": "Registration successful. Please log in."
},
"chat": {
"newChat": "New Chat",
"chatHistory": "Chat History",
"noHistory": "No chat history.",
"startChat": "Click + to start a new chat.",
"placeholder": "Ask kilostar to do something...",
"send": "Send",
"selectChat": "Select a chat history or create a new one to start.",
"assistantName": "kilostar Assistant",
"errorCommunication": "Sorry, an error occurred while communicating with the server."
},
"workflow": {
"workflows": "Workflows",
"manageWorkflows": "Manage and monitor your automated processes.",
"createWorkflow": "Create Workflow",
"noWorkflows": "No Workflows Found",
"workflowsAppearHere": "Workflows created from your chats will appear here automatically.",
"newWorkflow": "New Workflow",
"backToList": "Back to workflow list",
"workflowTitle": "Workflow Title",
"titlePlaceholder": "e.g. Scrape tech news and generate summaries",
"titleHint": "Give your workflow a concise, descriptive name",
"command": "Command",
"commandHint": "Describe the task you want the AI to complete in detail",
"commandPlaceholder": "Describe the task you want the AI to complete...",
"step1": "Step 1",
"step2": "Step 2",
"cancel": "Cancel",
"createAndStart": "Create & Start Workflow",
"creating": "Creating...",
"live": "Live",
"disconnected": "Disconnected",
"chatLog": "Chat Log",
"diagram": "Diagram",
"originalCommand": "Original Command",
"waitingEvents": "Waiting for events...",
"replyPlaceholder": "Reply to the workflow...",
"refresh": "Refresh Data",
"workflowDetails": "Workflow Details",
"loading": "Loading Workflows...",
"status": {
"waiting": "Waiting",
"running": "Running",
"completed": "Completed",
"failed": "Failed"
}
},
"settings": {
"settings": "Settings",
"userManagement": "User Management",
"systemSettings": "System Settings",
"general": "General",
"language": "Language",
"theme": "Theme",
"light": "Light",
"dark": "Dark",
"systemDefault": "System Default",
"clusterRuntime": "Cluster & Runtime",
"debugLogging": "Enable debug logging",
"saveChanges": "Save Changes",
"saved": "Settings saved!",
"addUser": "Add User",
"username": "Username",
"role": "Role",
"status": "Status",
"actions": "Actions",
"active": "Active",
"inactive": "Inactive",
"cancel": "Cancel",
"create": "Create",
"save": "Save",
"deleteConfirm": "Are you sure you want to delete this user?",
"deleteFailed": "Failed to delete user",
"updateFailed": "Failed to update user role",
"addNewUser": "Add New User",
"editUserRole": "Edit User Role",
"enterUsername": "e.g. jsmith",
"enterPassword": "Enter secure password",
"fillBoth": "Please fill in both username and password."
},
"agent": {
"individual": "Individual",
"providerManagement": "Provider Management",
"skills": "Skills",
"tools": "Tools"
},
"common": {
"close": "Close",
"submit": "Submit",
"loading": "Loading...",
"error": "Error",
"success": "Success",
"back": "Back"
}
}
+123
View File
@@ -0,0 +1,123 @@
{
"app": {
"name": "千星",
"tagline": "分布式多智能体系统"
},
"nav": {
"work": "工作",
"agent": "智能体",
"chat": "对话",
"workflow": "工作流",
"plugin": "插件",
"agents": "智能体",
"settings": "设置"
},
"auth": {
"welcomeBack": "欢迎回来",
"createAccount": "创建账号",
"enterCredentials": "输入您的凭据以访问账户",
"signUpToStart": "注册以开始使用平台",
"username": "用户名",
"password": "密码",
"signIn": "登录",
"signUp": "注册",
"noAccount": "还没有账号?",
"hasAccount": "已有账号?",
"processing": "处理中...",
"registerSuccess": "注册成功,请登录。"
},
"chat": {
"newChat": "新对话",
"chatHistory": "对话历史",
"noHistory": "暂无对话记录",
"startChat": "点击 + 开始新对话",
"placeholder": "让 kilostar 做点什么...",
"send": "发送",
"selectChat": "选择对话记录或创建新对话以开始",
"assistantName": "kilostar 助手",
"errorCommunication": "抱歉,与服务器通信时出错。"
},
"workflow": {
"workflows": "工作流",
"manageWorkflows": "管理和监控您的自动化流程",
"createWorkflow": "创建工作流",
"noWorkflows": "暂无工作流",
"workflowsAppearHere": "从对话创建的工作流将自动显示在这里",
"newWorkflow": "新建工作流",
"backToList": "返回工作流列表",
"workflowTitle": "工作流标题",
"titlePlaceholder": "例如:爬取最新技术新闻并生成摘要",
"titleHint": "为你的工作流起一个简洁、描述性的名称",
"command": "需求描述",
"commandHint": "详细描述你希望 AI 完成的任务,越具体效果越好",
"commandPlaceholder": "详细描述你希望 AI 完成的任务...",
"step1": "第一步",
"step2": "第二步",
"cancel": "取消",
"createAndStart": "创建并启动工作流",
"creating": "正在创建...",
"live": "实时",
"disconnected": "已断开",
"chatLog": "交流日志",
"diagram": "流程图",
"originalCommand": "原始指令",
"waitingEvents": "等待事件...",
"replyPlaceholder": "回复工作流...",
"refresh": "刷新数据",
"workflowDetails": "工作流详情",
"loading": "正在加载工作流...",
"status": {
"waiting": "等待中",
"running": "运行中",
"completed": "已完成",
"failed": "失败"
}
},
"settings": {
"settings": "设置",
"userManagement": "用户管理",
"systemSettings": "系统设置",
"general": "通用",
"language": "语言",
"theme": "主题",
"light": "浅色",
"dark": "深色",
"systemDefault": "跟随系统",
"clusterRuntime": "集群与运行时",
"debugLogging": "启用调试日志",
"saveChanges": "保存更改",
"saved": "设置已保存!",
"addUser": "添加用户",
"username": "用户名",
"role": "角色",
"status": "状态",
"actions": "操作",
"active": "活跃",
"inactive": "未激活",
"cancel": "取消",
"create": "创建",
"save": "保存",
"deleteConfirm": "确定要删除此用户吗?",
"deleteFailed": "删除用户失败",
"updateFailed": "更新用户角色失败",
"addNewUser": "添加新用户",
"editUserRole": "编辑用户角色",
"enterUsername": "例如:zhangsan",
"enterPassword": "输入安全密码",
"fillBoth": "请填写用户名和密码。"
},
"agent": {
"individual": "个体",
"providerManagement": "供应商管理",
"skills": "技能",
"tools": "工具"
},
"common": {
"close": "关闭",
"submit": "提交",
"loading": "加载中...",
"error": "错误",
"success": "成功",
"back": "返回"
}
}
+248
View File
@@ -1 +1,249 @@
@import "tailwindcss";
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-bg-primary: var(--bg-primary);
--color-bg-secondary: var(--bg-secondary);
--color-bg-tertiary: var(--bg-tertiary);
--color-bg-card: var(--bg-card);
--color-bg-sidebar: var(--bg-sidebar);
--color-bg-topbar: var(--bg-topbar);
--color-bg-input: var(--bg-input);
--color-bg-hover: var(--bg-hover);
--color-bg-active: var(--bg-active);
--color-bg-glass: var(--bg-glass);
--color-border-primary: var(--border-primary);
--color-border-secondary: var(--border-secondary);
--color-text-primary: var(--text-primary);
--color-text-secondary: var(--text-secondary);
--color-text-tertiary: var(--text-tertiary);
--color-text-muted: var(--text-muted);
--color-accent: var(--accent);
--color-accent-hover: var(--accent-hover);
--color-accent-light: var(--accent-light);
--color-accent-text: var(--accent-text);
--color-accent-glow: var(--accent-glow);
--color-danger: var(--danger);
--color-danger-bg: var(--danger-bg);
--color-success: var(--success);
--color-success-bg: var(--success-bg);
--color-warning: var(--warning);
--color-warning-bg: var(--warning-bg);
--color-glow-purple: var(--glow-purple);
--color-glow-cyan: var(--glow-cyan);
--font-sans: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, monospace;
}
:root {
--bg-primary: #fafafa;
--bg-secondary: #f4f4f5;
--bg-tertiary: #e4e4e7;
--bg-card: #ffffff;
--bg-sidebar: #f4f4f5;
--bg-topbar: rgba(255, 255, 255, 0.72);
--bg-input: #f4f4f5;
--bg-hover: #f4f4f5;
--bg-active: #eff6ff;
--bg-glass: rgba(255, 255, 255, 0.6);
--border-primary: #e4e4e7;
--border-secondary: #f4f4f5;
--text-primary: #18181b;
--text-secondary: #3f3f46;
--text-tertiary: #52525b;
--text-muted: #a1a1aa;
--accent: #4f46e5;
--accent-hover: #4338ca;
--accent-light: #e0e7ff;
--accent-text: #3730a3;
--accent-glow: rgba(79, 70, 229, 0.15);
--danger: #dc2626;
--danger-bg: #fef2f2;
--success: #16a34a;
--success-bg: #f0fdf4;
--warning: #d97706;
--warning-bg: #fffbeb;
--glow-purple: #a855f7;
--glow-cyan: #06b6d4;
}
.dark {
--bg-primary: #09090b;
--bg-secondary: #0f0f11;
--bg-tertiary: #18181b;
--bg-card: #131316;
--bg-sidebar: #0c0c0e;
--bg-topbar: rgba(9, 9, 11, 0.72);
--bg-input: #18181b;
--bg-hover: #1c1c1f;
--bg-active: rgba(79, 70, 229, 0.12);
--bg-glass: rgba(19, 19, 22, 0.6);
--border-primary: rgba(255, 255, 255, 0.08);
--border-secondary: rgba(255, 255, 255, 0.04);
--text-primary: #fafafa;
--text-secondary: #e4e4e7;
--text-tertiary: #a1a1aa;
--text-muted: #71717a;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-light: rgba(99, 102, 241, 0.15);
--accent-text: #a5b4fc;
--accent-glow: rgba(99, 102, 241, 0.25);
--danger: #f87171;
--danger-bg: rgba(248, 113, 113, 0.1);
--success: #4ade80;
--success-bg: rgba(74, 222, 128, 0.1);
--warning: #fbbf24;
--warning-bg: rgba(251, 191, 36, 0.1);
--glow-purple: #c084fc;
--glow-cyan: #67e8f9;
}
html {
scroll-behavior: smooth;
}
body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Dot pattern background for dark mode */
.dark body::before {
content: "";
position: fixed;
inset: 0;
background-image: radial-gradient(circle at 1px 1px, rgba(255,255,255,0.04) 1px, transparent 0);
background-size: 24px 24px;
pointer-events: none;
z-index: 0;
}
/* Custom scrollbar */
::-webkit-scrollbar {
width: 5px;
height: 5px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--border-primary);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* Selection */
::selection {
background-color: var(--accent-light);
color: var(--accent-text);
}
/* Animations */
@keyframes fadeIn {
from { opacity: 0; transform: translateY(6px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeInScale {
from { opacity: 0; transform: scale(0.96); }
to { opacity: 1; transform: scale(1); }
}
@keyframes slideUp {
from { opacity: 0; transform: translateY(16px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes pulse-glow {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
@keyframes typing {
0%, 60%, 100% { transform: translateY(0); }
30% { transform: translateY(-4px); }
}
@keyframes gradient-shift {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
.animate-fade-in {
animation: fadeIn 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-fade-in-scale {
animation: fadeInScale 0.4s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-slide-up {
animation: slideUp 0.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
.animate-shimmer {
background: linear-gradient(90deg, transparent 0%, var(--accent-glow) 50%, transparent 100%);
background-size: 200% 100%;
animation: shimmer 2s infinite;
}
.animate-typing-dot {
animation: typing 1.4s ease-in-out infinite;
}
.animate-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.animate-typing-dot:nth-child(3) { animation-delay: 0.4s; }
.animate-gradient {
background-size: 200% 200%;
animation: gradient-shift 6s ease infinite;
}
/* Glass effect */
.glass {
background: var(--bg-glass);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border-bottom: 1px solid var(--border-primary);
}
/* Modern card hover */
.card-hover {
transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.2s ease, border-color 0.2s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 8px 30px -8px rgba(0, 0, 0, 0.12);
}
.dark .card-hover:hover {
box-shadow: 0 8px 30px -8px rgba(0, 0, 0, 0.4);
}
/* Glow effects */
.glow-accent {
box-shadow: 0 0 20px -4px var(--accent-glow);
}
.glow-purple {
box-shadow: 0 0 30px -6px rgba(168, 85, 247, 0.3);
}
/* Status indicator pulse */
.status-dot {
position: relative;
}
.status-dot::after {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
background: inherit;
opacity: 0.4;
animation: pulse-glow 2s ease-in-out infinite;
}
+1
View File
@@ -1,5 +1,6 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './i18n'
import './index.css'
import App from './App.tsx'
+108
View File
@@ -0,0 +1,108 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type AppMode = 'work' | 'agent';
type WorkTab = 'chat' | 'workflow';
type AgentTab = 'plugin' | 'agents';
type ThemeMode = 'light' | 'dark' | 'system';
interface AppState {
// Auth
isAuthenticated: boolean;
setIsAuthenticated: (v: boolean) => void;
// Layout
mode: AppMode;
setMode: (mode: AppMode) => void;
showSettings: boolean;
setShowSettings: (v: boolean) => void;
isSidebarOpen: boolean;
setIsSidebarOpen: (v: boolean) => void;
// Work tabs
workTab: WorkTab;
setWorkTab: (tab: WorkTab) => void;
// Agent tabs
agentTab: AgentTab;
setAgentTab: (tab: AgentTab) => void;
// Settings tabs
settingsTab: string;
setSettingsTab: (tab: string) => void;
innerAgentTab: string;
setInnerAgentTab: (tab: string) => void;
resourceTab: string;
setResourceTab: (tab: string) => void;
// Theme
theme: ThemeMode;
setTheme: (theme: ThemeMode) => void;
resolvedTheme: 'light' | 'dark';
applyTheme: () => void;
}
function resolveTheme(theme: ThemeMode): 'light' | 'dark' {
if (theme === 'system') {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
return theme;
}
export const useAppStore = create<AppState>()(
persist(
(set, get) => ({
isAuthenticated: false,
setIsAuthenticated: (v) => set({ isAuthenticated: v }),
mode: 'work',
setMode: (mode) => set({ mode }),
showSettings: false,
setShowSettings: (v) => set({ showSettings: v }),
isSidebarOpen: true,
setIsSidebarOpen: (v) => set({ isSidebarOpen: v }),
workTab: 'chat',
setWorkTab: (workTab) => set({ workTab }),
agentTab: 'plugin',
setAgentTab: (agentTab) => set({ agentTab }),
settingsTab: 'users',
setSettingsTab: (settingsTab) => set({ settingsTab }),
innerAgentTab: 'worker',
setInnerAgentTab: (innerAgentTab) => set({ innerAgentTab }),
resourceTab: 'skill',
setResourceTab: (resourceTab) => set({ resourceTab }),
theme: 'system',
setTheme: (theme) => {
set({ theme });
const resolved = resolveTheme(theme);
set({ resolvedTheme: resolved });
if (resolved === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
resolvedTheme: 'light',
applyTheme: () => {
const resolved = resolveTheme(get().theme);
set({ resolvedTheme: resolved });
if (resolved === 'dark') {
document.documentElement.classList.add('dark');
} else {
document.documentElement.classList.remove('dark');
}
},
}),
{
name: 'kilostar-app-storage',
partialize: (state) => ({
theme: state.theme,
isSidebarOpen: state.isSidebarOpen,
}),
}
)
);
+198
View File
@@ -0,0 +1,198 @@
import { create } from 'zustand';
import apiClient from '../api/client';
import type { ChatSessionDB } from '../types';
export interface Message {
id: string;
role: 'user' | 'assistant' | 'system';
content: string;
timestamp: number;
}
export interface ChatSession {
id: string;
title: string;
messages: Message[];
updatedAt: number;
}
interface ChatState {
sessions: ChatSession[];
activeSessionId: string | null;
selectedWorkflow: string | null;
loadingSessions: boolean;
loadingMessages: boolean;
setSessions: (sessions: ChatSession[]) => void;
setActiveSessionId: (id: string | null) => void;
setSelectedWorkflow: (id: string | null) => void;
addSession: (session: ChatSession) => void;
updateSessionMessages: (sessionId: string, messages: Message[]) => void;
updateSessionTitle: (sessionId: string, title: string) => void;
removeSession: (sessionId: string) => void;
loadSessions: () => Promise<void>;
loadMessages: (chatId: string) => Promise<void>;
createChat: (title?: string, initialMessage?: string) => Promise<string | null>;
sendMessage: (chatId: string, text: string) => Promise<void>;
}
function mapSessionFromDB(s: ChatSessionDB): ChatSession {
return {
id: s.chat_id,
title: s.title,
messages: [],
updatedAt: new Date(s.updated_at).getTime(),
};
}
export const useChatStore = create<ChatState>((set, get) => ({
sessions: [],
activeSessionId: null,
selectedWorkflow: null,
loadingSessions: false,
loadingMessages: false,
setSessions: (sessions) => set({ sessions }),
setActiveSessionId: (id) => set({ activeSessionId: id }),
setSelectedWorkflow: (id) => set({ selectedWorkflow: id }),
addSession: (session) =>
set((state) => ({ sessions: [session, ...state.sessions], activeSessionId: session.id })),
updateSessionMessages: (sessionId, messages) =>
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === sessionId ? { ...s, messages, updatedAt: Date.now() } : s
),
})),
updateSessionTitle: (sessionId, title) =>
set((state) => ({
sessions: state.sessions.map((s) => (s.id === sessionId ? { ...s, title } : s)),
})),
removeSession: (sessionId) =>
set((state) => {
const filtered = state.sessions.filter((s) => s.id !== sessionId);
return {
sessions: filtered,
activeSessionId:
state.activeSessionId === sessionId
? filtered.length > 0
? filtered[0].id
: null
: state.activeSessionId,
};
}),
loadSessions: async () => {
set({ loadingSessions: true });
try {
const response = await apiClient.get('/api/v1/chat');
const data: ChatSessionDB[] = response.data?.sessions || [];
set({ sessions: data.map(mapSessionFromDB) });
} catch (error) {
console.error('Failed to load chat sessions', error);
} finally {
set({ loadingSessions: false });
}
},
loadMessages: async (chatId) => {
set({ loadingMessages: true });
try {
const response = await apiClient.get(`/api/v1/chat/${chatId}`);
const dbMessages = response.data?.messages || [];
const mapped: Message[] = dbMessages.map((m: any) => ({
id: m.message_id,
role: m.message_owner === 'user' ? 'user' : 'assistant',
content: m.message,
timestamp: new Date(m.created_at).getTime(),
}));
set((state) => ({
sessions: state.sessions.map((s) =>
s.id === chatId ? { ...s, messages: mapped } : s
),
}));
} catch (error) {
console.error('Failed to load messages', error);
} finally {
set({ loadingMessages: false });
}
},
createChat: async (title = '新对话', initialMessage = '你好') => {
try {
const response = await apiClient.post('/api/v1/chat', {
title,
initial_message: initialMessage,
});
const chatId: string = response.data.chat_id;
const reply: string =
response.data.reply || '你好!我是 kilostar 助手,有什么可以帮你的吗?';
const newSession: ChatSession = {
id: chatId,
title,
messages: [
{ id: chatId + '_user', role: 'user', content: initialMessage, timestamp: Date.now() },
{ id: chatId + '_ai', role: 'assistant', content: reply, timestamp: Date.now() },
],
updatedAt: Date.now(),
};
get().addSession(newSession);
return chatId;
} catch (error) {
console.error('Failed to create chat session', error);
return null;
}
},
sendMessage: async (chatId, text) => {
const state = get();
const session = state.sessions.find((s) => s.id === chatId);
if (!session) return;
const userMessage: Message = {
id: Date.now().toString(),
role: 'user',
content: text,
timestamp: Date.now(),
};
const currentMessages = [...session.messages, userMessage];
get().updateSessionMessages(chatId, currentMessages);
try {
const response = await apiClient.post(`/api/v1/chat/${chatId}/reply`, {
message: text,
});
const replyContent: string = response.data?.reply || '收到你的消息。';
// Auto-update title on first user message
if (session.messages.length <= 1 && text.length > 0) {
const newTitle = text.slice(0, 20) + (text.length > 20 ? '...' : '');
get().updateSessionTitle(chatId, newTitle);
}
const aiMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: replyContent,
timestamp: Date.now(),
};
get().updateSessionMessages(chatId, [...currentMessages, aiMessage]);
} catch (error) {
console.error('Error sending message', error);
const errorMessage: Message = {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: '抱歉,与服务器通信时出错。',
timestamp: Date.now(),
};
get().updateSessionMessages(chatId, [...currentMessages, errorMessage]);
}
},
}));