存档
This commit is contained in:
+19
-4
@@ -15,6 +15,7 @@ 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 { HeavyPluginShell } from './plugins/HeavyPluginShell';
|
||||
import { useAppStore } from './store/useAppStore';
|
||||
import { useChatStore } from './store/useChatStore';
|
||||
|
||||
@@ -29,10 +30,12 @@ function App() {
|
||||
agentTab,
|
||||
applyTheme,
|
||||
locale,
|
||||
loadInstalledPlugins,
|
||||
} = useAppStore();
|
||||
|
||||
const { loadSessions } = useChatStore();
|
||||
const [showSetupGuide, setShowSetupGuide] = useState(false);
|
||||
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme();
|
||||
@@ -58,9 +61,10 @@ function App() {
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadSessions();
|
||||
loadInstalledPlugins();
|
||||
setShowSetupGuide(true);
|
||||
}
|
||||
}, [isAuthenticated, loadSessions]);
|
||||
}, [isAuthenticated, loadSessions, loadInstalledPlugins]);
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <AuthPage onLoginSuccess={() => setIsAuthenticated(true)} />;
|
||||
@@ -90,11 +94,17 @@ function App() {
|
||||
{mode === 'work' && workTab === 'chat' && (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="chats" />
|
||||
<ChatPanel />
|
||||
{activeHeavyPlugin ? (
|
||||
<HeavyPluginShell name={activeHeavyPlugin} />
|
||||
) : (
|
||||
<ChatPanel />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mode === 'work' && workTab === 'workflow' && <WorkflowShell />}
|
||||
{mode === 'work' && workTab === 'workflow' && (
|
||||
<WorkflowShell />
|
||||
)}
|
||||
|
||||
{mode === 'agent' && agentTab === 'agents' && <AgentLayout />}
|
||||
|
||||
@@ -149,7 +159,12 @@ function WorkflowShell() {
|
||||
);
|
||||
}
|
||||
|
||||
return <WorkflowListView onSelectWorkflow={setSelectedWorkflow} />;
|
||||
return (
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<LeftPanel activeTab="workflows" />
|
||||
<WorkflowListView onSelectWorkflow={setSelectedWorkflow} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check } from 'lucide-react';
|
||||
import { Plus, Trash2, MessageSquare, Workflow as WorkflowIcon, Pencil, Check, Box } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
import type { Workflow } from '../../types';
|
||||
import { useChatStore } from '../../store/useChatStore';
|
||||
import { useAppStore } from '../../store/useAppStore';
|
||||
|
||||
interface LeftPanelProps {
|
||||
activeTab: string;
|
||||
@@ -26,6 +27,12 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
updateSessionTitle,
|
||||
} = useChatStore();
|
||||
|
||||
const installedHeavyPlugins = useAppStore((s) => s.installedHeavyPlugins);
|
||||
const activeHeavyPlugin = useAppStore((s) => s.activeHeavyPlugin);
|
||||
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
|
||||
const heavyPluginsWithUi = installedHeavyPlugins.filter((p) => p.has_ui);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
let intervalId: ReturnType<typeof setInterval>;
|
||||
|
||||
@@ -61,6 +68,7 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
|
||||
const handleNewChat = () => {
|
||||
setActiveSessionId(null);
|
||||
setActiveHeavyPlugin(null);
|
||||
};
|
||||
|
||||
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
|
||||
@@ -115,7 +123,10 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
return (
|
||||
<div
|
||||
key={session.id}
|
||||
onClick={() => setActiveSessionId(session.id)}
|
||||
onClick={() => {
|
||||
setActiveSessionId(session.id);
|
||||
setActiveHeavyPlugin(null);
|
||||
}}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
@@ -160,39 +171,73 @@ export function LeftPanel({ activeTab }: LeftPanelProps) {
|
||||
})
|
||||
)
|
||||
) : (
|
||||
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>
|
||||
) : (
|
||||
workflows.map((wf) => {
|
||||
const isActive = selectedWorkflow === wf.trace_id;
|
||||
return (
|
||||
<div
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedWorkflow(wf.trace_id)}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<WorkflowIcon size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
<>
|
||||
{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>
|
||||
) : (
|
||||
workflows.map((wf) => {
|
||||
const isActive = selectedWorkflow === wf.trace_id;
|
||||
return (
|
||||
<div
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedWorkflow(wf.trace_id)}
|
||||
className={`group flex items-center gap-2.5 px-2.5 py-2 rounded-lg cursor-pointer transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<WorkflowIcon size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{wf.title || t('common.unnamed')}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{wf.title || t('common.unnamed')}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)
|
||||
);
|
||||
})
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isChats && heavyPluginsWithUi.length > 0 && (
|
||||
<div className="border-t border-border-primary px-2 py-3 shrink-0">
|
||||
<div className="px-2.5 text-[10px] font-semibold text-text-muted uppercase tracking-[1.2px] mb-1.5">
|
||||
{t('chat.plugins.title')}
|
||||
</div>
|
||||
{heavyPluginsWithUi.map((p) => {
|
||||
const isActive = activeHeavyPlugin === p.name;
|
||||
return (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => {
|
||||
setActiveHeavyPlugin(p.name);
|
||||
setActiveSessionId(null);
|
||||
}}
|
||||
className={`w-full flex items-center gap-2.5 px-2.5 py-2 rounded-lg transition-all mb-px ${
|
||||
isActive
|
||||
? 'bg-bg-card shadow-[0_1px_3px_rgba(0,0,0,0.04)]'
|
||||
: 'hover:bg-white/60 dark:hover:bg-white/[0.04]'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-7 h-7 rounded-[7px] flex items-center justify-center flex-shrink-0 ${isActive ? 'bg-accent-light' : 'bg-bg-primary'}`}>
|
||||
<Box size={12} className={isActive ? 'text-accent' : 'text-text-muted'} />
|
||||
</div>
|
||||
<span className={`text-xs truncate ${isActive ? 'text-text-primary font-medium' : 'text-text-secondary'}`}>
|
||||
{p.display_name}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"chat": "Chat",
|
||||
"workflow": "Workflow",
|
||||
"plugin": "Plugin",
|
||||
"heavyPlugin": "Plugins",
|
||||
"agents": "Agents",
|
||||
"toolsets": "Toolsets",
|
||||
"config": "Config",
|
||||
@@ -50,6 +51,9 @@
|
||||
"writeCode": "Write code",
|
||||
"summarize": "Summarize doc",
|
||||
"search": "Search info"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "Heavy Plugins"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"chat": "对话",
|
||||
"workflow": "工作流",
|
||||
"plugin": "插件",
|
||||
"heavyPlugin": "重型插件",
|
||||
"agents": "智能体",
|
||||
"toolsets": "工具集",
|
||||
"config": "配置",
|
||||
@@ -50,6 +51,9 @@
|
||||
"writeCode": "写代码",
|
||||
"summarize": "总结文档",
|
||||
"search": "查找资料"
|
||||
},
|
||||
"plugins": {
|
||||
"title": "重型插件"
|
||||
}
|
||||
},
|
||||
"workflow": {
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { ChevronLeft, Loader2 } from 'lucide-react';
|
||||
import { useAppStore } from '../store/useAppStore';
|
||||
import apiClient from '../api/client';
|
||||
|
||||
interface UiManifest {
|
||||
name: string;
|
||||
tag: string;
|
||||
js: string;
|
||||
css?: string[];
|
||||
display_name?: string;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 已加载过的插件 JS:避免切换插件时重复 import 同一个 module(customElements 也不允许重复 define)
|
||||
const loadedJs = new Set<string>();
|
||||
|
||||
export function HeavyPluginShell({ name }: Props) {
|
||||
const setActiveHeavyPlugin = useAppStore((s) => s.setActiveHeavyPlugin);
|
||||
const meta = useAppStore((s) => s.installedHeavyPlugins.find((p) => p.name === name));
|
||||
const resolvedTheme = useAppStore((s) => s.resolvedTheme);
|
||||
const [manifest, setManifest] = useState<UiManifest | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// 取 token:和 apiClient 拦截器同源(localStorage 'token',详见 frontend/src/api/client.ts)
|
||||
const token =
|
||||
typeof window !== 'undefined' ? localStorage.getItem('token') ?? '' : '';
|
||||
|
||||
// 1. 拉取 ui-manifest 并按需注入 JS(CSS 在 Web Component 内部 inline 注入,这里不管)
|
||||
useEffect(() => {
|
||||
let canceled = false;
|
||||
setManifest(null);
|
||||
setError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await apiClient.get<UiManifest>(`/api/v1/plugin/${name}/ui-manifest`);
|
||||
const m = resp.data;
|
||||
if (!loadedJs.has(m.js)) {
|
||||
// 动态 import 插件 ESM bundle —— @vite-ignore 避免 vite 把这个 URL 当成构建期资源
|
||||
await import(/* @vite-ignore */ m.js);
|
||||
loadedJs.add(m.js);
|
||||
}
|
||||
if (!canceled) setManifest(m);
|
||||
} catch (e: unknown) {
|
||||
if (canceled) return;
|
||||
const msg =
|
||||
(e as { response?: { data?: { detail?: string } } }).response?.data?.detail ||
|
||||
(e as Error).message ||
|
||||
'failed to load plugin UI';
|
||||
setError(msg);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
canceled = true;
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
// 2. 把 token / api-base / data-theme 三个属性写到自定义元素上
|
||||
// 创建/复用一个元素 placeholder:每次 manifest 切换时清空 host 重新挂载
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
if (!host || !manifest) return;
|
||||
host.innerHTML = '';
|
||||
const el = document.createElement(manifest.tag);
|
||||
el.setAttribute('token', token);
|
||||
el.setAttribute('api-base', '');
|
||||
el.setAttribute('data-theme', resolvedTheme);
|
||||
(el as HTMLElement).style.cssText = 'display:block;width:100%;height:100%';
|
||||
host.appendChild(el);
|
||||
return () => {
|
||||
host.contains(el) && host.removeChild(el);
|
||||
};
|
||||
}, [manifest, token, resolvedTheme]);
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-4 py-2 border-b border-border-primary bg-bg-card">
|
||||
<button
|
||||
onClick={() => setActiveHeavyPlugin(null)}
|
||||
className="flex items-center gap-1 text-xs text-text-muted hover:text-accent transition-all"
|
||||
>
|
||||
<ChevronLeft size={14} />
|
||||
返回会话
|
||||
</button>
|
||||
<span className="ml-2 text-sm font-medium text-text-primary">
|
||||
{meta?.display_name || manifest?.display_name || name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1 overflow-hidden relative">
|
||||
{error ? (
|
||||
<div className="p-8 text-danger text-sm">
|
||||
插件 <code className="text-accent">{name}</code> 加载失败:{error}
|
||||
</div>
|
||||
) : !manifest ? (
|
||||
<div className="absolute inset-0 flex items-center justify-center text-text-muted">
|
||||
<Loader2 size={20} className="animate-spin" />
|
||||
</div>
|
||||
) : (
|
||||
<div ref={hostRef} className="w-full h-full" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,15 @@ type WorkTab = 'chat' | 'workflow';
|
||||
type AgentTab = 'plugin' | 'agents' | 'toolsets' | 'config' | 'logs';
|
||||
type ThemeMode = 'light' | 'dark' | 'system';
|
||||
|
||||
export interface InstalledPlugin {
|
||||
name: string;
|
||||
display_name: string;
|
||||
description: string;
|
||||
status: string;
|
||||
has_ui?: boolean;
|
||||
icon?: string | null;
|
||||
}
|
||||
|
||||
interface AppState {
|
||||
// Auth
|
||||
isAuthenticated: boolean;
|
||||
@@ -44,6 +53,12 @@ interface AppState {
|
||||
// Locale
|
||||
locale: string;
|
||||
setLocale: (locale: string) => void;
|
||||
|
||||
// Heavy plugin (workspace dashboards)
|
||||
activeHeavyPlugin: string | null;
|
||||
setActiveHeavyPlugin: (name: string | null) => void;
|
||||
installedHeavyPlugins: InstalledPlugin[];
|
||||
loadInstalledPlugins: () => Promise<void>;
|
||||
}
|
||||
|
||||
function resolveTheme(theme: ThemeMode): 'light' | 'dark' {
|
||||
@@ -107,6 +122,21 @@ export const useAppStore = create<AppState>()(
|
||||
// 同步到 i18next,确保刷新后语言一致
|
||||
import('i18next').then((i18n) => i18n.default.changeLanguage(locale));
|
||||
},
|
||||
|
||||
activeHeavyPlugin: null,
|
||||
setActiveHeavyPlugin: (activeHeavyPlugin) => set({ activeHeavyPlugin }),
|
||||
installedHeavyPlugins: [],
|
||||
loadInstalledPlugins: async () => {
|
||||
try {
|
||||
const apiClient = (await import('../api/client')).default;
|
||||
const resp = await apiClient.get('/api/v1/plugin/list');
|
||||
const list: InstalledPlugin[] = resp.data?.plugins || [];
|
||||
set({ installedHeavyPlugins: list.filter((p) => p.has_ui) });
|
||||
} catch (e) {
|
||||
console.warn('Failed to fetch installed plugins', e);
|
||||
set({ installedHeavyPlugins: [] });
|
||||
}
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: 'kilostar-app-storage',
|
||||
|
||||
@@ -19,7 +19,13 @@
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
"baseUrl": ".",
|
||||
"ignoreDeprecations": "6.0",
|
||||
"paths": {
|
||||
"@app/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'node:path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@app': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user