This commit is contained in:
2026-07-01 09:22:26 +00:00
parent 4aa1dab283
commit aa47a19e98
53 changed files with 4721 additions and 77 deletions
+19 -4
View File
@@ -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;
+77 -32
View File
@@ -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>
);
}
+4
View File
@@ -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": {
+4
View File
@@ -9,6 +9,7 @@
"chat": "对话",
"workflow": "工作流",
"plugin": "插件",
"heavyPlugin": "重型插件",
"agents": "智能体",
"toolsets": "工具集",
"config": "配置",
@@ -50,6 +51,9 @@
"writeCode": "写代码",
"summarize": "总结文档",
"search": "查找资料"
},
"plugins": {
"title": "重型插件"
}
},
"workflow": {
+109
View File
@@ -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 同一个 modulecustomElements 也不允许重复 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 并按需注入 JSCSS 在 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>
);
}
+30
View File
@@ -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',