feat: 工具系统迁移 + 重型插件骨架 + 前端交互增强

- 工具系统从 kilostar/plugin/tool_plugin/ 迁移到 data/toolset/(manifest.json 声明式)
- 新增 plugin_runtime 模块:BaseOrganization / GlobalPluginManager / loader / tool_bridge
- 新增 org_task + org_task_event 表及 DAO(alembic 0009)
- 新增 /api/v1/plugin 路由(submit/status/stream/install/reload)
- 新增 data/plugin/example_dept 示例重型插件
- regulatory_node 支持聊天历史上下文注入
- send_file 改为 artifact 存盘 + SSE 推送下载链接
- 前端 WorkflowFileCard 组件 + ToolSettings README 渲染
- utils 整理:合并 access/role_check、standalone_proxy→ray_compat、删除废弃模块
- 项目结构文档移至 docs/STRUCTURE.md 并详细展开

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-06-17 05:20:00 +00:00
parent 9b73ae4db4
commit 6d658b4f4d
74 changed files with 2591 additions and 1308 deletions
+46 -12
View File
@@ -7,6 +7,35 @@ import type { SSEConnection } from '../../api/sse';
import type { WorkflowDetail } from '../../types';
import { ErrorBoundary } from '../ErrorBoundary';
import { WorkflowDiagram } from './WorkflowDiagram';
import { WorkflowFileCard, type WorkflowFilePayload } from './WorkflowFileCard';
type LogEntry =
| { kind: 'text'; content: string }
| { kind: 'file'; payload: WorkflowFilePayload };
const FILE_PREFIX = '__FILE__';
function parseLogEvent(data: string): LogEntry {
if (data.startsWith(FILE_PREFIX)) {
try {
const parsed = JSON.parse(data.slice(FILE_PREFIX.length));
if (parsed && typeof parsed.filename === 'string' && typeof parsed.url === 'string') {
return {
kind: 'file',
payload: {
filename: parsed.filename,
url: parsed.url,
artifact_id: parsed.artifact_id,
size: typeof parsed.size === 'number' ? parsed.size : undefined,
},
};
}
} catch {
/* fall through to text */
}
}
return { kind: 'text', content: data };
}
interface RightPanelProps {
selectedWorkflow: string | null;
@@ -16,7 +45,7 @@ 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[]>([]);
const [logs, setLogs] = useState<LogEntry[]>([]);
const [sseConnected, setSseConnected] = useState(false);
const [replyText, setReplyText] = useState('');
const [resuming, setResuming] = useState(false);
@@ -54,13 +83,13 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
token,
{
onOpen: () => setSseConnected(true),
onMessage: (data) => setLogs((prev) => [...prev, data]),
onMessage: (data) => setLogs((prev) => [...prev, parseLogEvent(data)]),
onError: () => setSseConnected(false),
onReconnect: (delayMs) => {
setSseConnected(false);
setLogs((prev) => [
...prev,
`[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}`,
{ kind: 'text', content: `[System]: ${t('workflow.sseReconnecting', { seconds: Math.round(delayMs / 1000) })}` },
]);
},
},
@@ -82,11 +111,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
if (!replyText.trim() || !selectedWorkflow) return;
const message = replyText.trim();
setReplyText('');
setLogs((prev) => [...prev, `[You]: ${message}`]);
setLogs((prev) => [...prev, { kind: 'text', content: `[You]: ${message}` }]);
try {
await apiClient.post(`/api/v1/workflow/reply/${selectedWorkflow}`, { message });
} catch {
setLogs((prev) => [...prev, `[System Error]: Failed to send reply.`]);
setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: Failed to send reply.` }]);
}
};
@@ -95,11 +124,11 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
setResuming(true);
try {
await apiClient.post(`/api/v1/workflow/${selectedWorkflow}/resume`);
setLogs((prev) => [...prev, `[System]: ${t('workflow.resumeTriggered')}`]);
setLogs((prev) => [...prev, { kind: 'text', content: `[System]: ${t('workflow.resumeTriggered')}` }]);
fetchDetail(selectedWorkflow);
} catch (err: any) {
const detailMsg = err?.response?.data?.detail || t('workflow.resumeFailed');
setLogs((prev) => [...prev, `[System Error]: ${detailMsg}`]);
setLogs((prev) => [...prev, { kind: 'text', content: `[System Error]: ${detailMsg}` }]);
} finally {
setResuming(false);
}
@@ -220,11 +249,16 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
{t('workflow.waitingEvents')}
</div>
) : (
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) => {
if (log.kind === 'file') {
return <WorkflowFileCard key={index} payload={log.payload} />;
}
return (
<div key={index} className={`p-2.5 rounded-lg text-xs ${log.content.startsWith('[You]') ? 'bg-accent-light/50 text-accent-text ml-8' : 'bg-bg-secondary text-text-secondary mr-8'}`}>
{log.content}
</div>
);
})
)}
<div ref={logsEndRef} />
</div>
@@ -0,0 +1,83 @@
import { FileText, Download, Loader2 } from 'lucide-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import apiClient from '../../api/client';
export interface WorkflowFilePayload {
filename: string;
url: string;
artifact_id?: string;
size?: number;
}
function formatSize(bytes?: number): string {
if (!bytes && bytes !== 0) return '';
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
}
export function WorkflowFileCard({ payload }: { payload: WorkflowFilePayload }) {
const { t } = useTranslation();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleDownload = async () => {
if (loading) return;
setLoading(true);
setError(null);
try {
const resp = await apiClient.get(payload.url, { responseType: 'blob' });
const blob = new Blob([resp.data]);
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = objectUrl;
a.download = payload.filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(objectUrl);
} catch (err: any) {
setError(err?.response?.status === 403 ? t('workflow.fileForbidden') : t('workflow.fileDownloadFailed'));
} finally {
setLoading(false);
}
};
return (
<div className="bg-bg-card border border-border-primary rounded-xl px-3 py-2.5 mr-8 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-9 h-9 rounded-lg bg-accent-light flex items-center justify-center shrink-0">
<FileText size={18} className="text-accent" />
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-text-primary truncate">
{payload.filename}
</div>
{payload.size !== undefined && (
<div className="text-[11px] text-text-muted mt-0.5">
{formatSize(payload.size)}
</div>
)}
</div>
<button
onClick={handleDownload}
disabled={loading}
className="p-1.5 rounded-lg hover:bg-accent-light text-text-muted hover:text-accent transition-colors disabled:opacity-40"
title={t('workflow.fileDownload')}
>
{loading ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
</button>
</div>
{error && (
<div className="mt-2 text-[11px] text-error">
{error}
</div>
)}
</div>
);
}
+70 -13
View File
@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Package, Wrench, Loader2, Box, Shield, X } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import apiClient from '../../api/client';
interface Toolset {
@@ -145,13 +147,36 @@ function ToolsetModal({
onClose: () => void;
}) {
const { t } = useTranslation();
const [readme, setReadme] = useState<string | null>(null);
const [readmeState, setReadmeState] = useState<'idle' | 'loading' | 'missing' | 'loaded'>('idle');
useEffect(() => {
if (!toolset.is_system) return;
let cancelled = false;
setReadmeState('loading');
apiClient
.get(`/api/v1/resource/toolset-package/${encodeURIComponent(toolset.category)}/readme`)
.then((res) => {
if (cancelled) return;
setReadme(res.data.content || '');
setReadmeState('loaded');
})
.catch(() => {
if (cancelled) return;
setReadmeState('missing');
});
return () => {
cancelled = true;
};
}, [toolset.category, toolset.is_system]);
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
onClick={onClose}
>
<div
className="bg-bg-card border border-border-primary rounded-2xl shadow-lg w-full max-w-md mx-4"
className="bg-bg-card border border-border-primary rounded-2xl shadow-lg w-full max-w-2xl mx-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-border-primary">
@@ -172,19 +197,51 @@ function ToolsetModal({
<X size={16} className="text-text-muted" />
</button>
</div>
<div className="px-5 py-4 space-y-2 max-h-80 overflow-y-auto">
<p className="text-xs font-medium text-text-secondary mb-2">
{t('plugin.toolsetTools')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
<div className="px-5 py-4 max-h-[70vh] overflow-y-auto">
{toolset.is_system ? (
readmeState === 'loading' ? (
<div className="flex items-center justify-center py-8 text-text-muted">
<Loader2 size={18} className="animate-spin mr-2" />
<span className="text-sm">{t('plugin.toolsetReadmeLoading')}</span>
</div>
) : readmeState === 'loaded' && readme ? (
<div className="prose-chat text-[13.5px] text-text-primary leading-[1.75]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>
{readme}
</ReactMarkdown>
</div>
) : (
<div className="space-y-2">
<p className="text-xs text-text-muted mb-3">
{t('plugin.toolsetReadmeMissing')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
</div>
))}
</div>
)
) : (
<div className="space-y-2">
<p className="text-xs font-medium text-text-secondary mb-2">
{t('plugin.toolsetTools')}
</p>
{toolset.tools.map((tool) => (
<div
key={tool}
className="flex items-center gap-2.5 p-2.5 bg-bg-secondary rounded-lg"
>
<Package size={14} className="text-accent shrink-0" />
<span className="text-sm text-text-primary">{tool}</span>
</div>
))}
</div>
))}
)}
</div>
</div>
</div>
+5
View File
@@ -83,6 +83,9 @@
"resumeTriggered": "Resume request sent, the workflow is recovering...",
"resumeFailed": "Failed to resume workflow",
"sseReconnecting": "Connection lost, retrying in {{seconds}}s...",
"fileDownload": "Download",
"fileDownloadFailed": "Download failed",
"fileForbidden": "Not allowed to download this file",
"workflowDetails": "Workflow Details",
"loading": "Loading Workflows...",
"titleRequired": "Please enter a workflow title",
@@ -262,6 +265,8 @@
"toolsetEmpty": "No toolsets available",
"toolsetSystem": "System",
"toolsetCount": "{{count}} tools",
"toolsetReadmeMissing": "No README provided for this package",
"toolsetReadmeLoading": "Loading description…",
"skillManagement": "Skill Management",
"skillDesc": "Manage agent skills and functions",
"installSkill": "Install Skill",
+5
View File
@@ -83,6 +83,9 @@
"resumeTriggered": "恢复请求已发送,工作流正在恢复中...",
"resumeFailed": "恢复工作流失败",
"sseReconnecting": "连接断开,{{seconds}}秒后重试...",
"fileDownload": "下载附件",
"fileDownloadFailed": "下载失败",
"fileForbidden": "无权下载该文件",
"workflowDetails": "工作流详情",
"loading": "正在加载工作流...",
"titleRequired": "请输入工作流标题",
@@ -262,6 +265,8 @@
"toolsetEmpty": "暂无工具集",
"toolsetSystem": "系统",
"toolsetCount": "{{count}} 个工具",
"toolsetReadmeMissing": "该工具包没有提供 README",
"toolsetReadmeLoading": "正在加载说明…",
"skillManagement": "技能管理",
"skillDesc": "管理代理技能和函数",
"installSkill": "安装技能",