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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "安装技能",
|
||||
|
||||
Reference in New Issue
Block a user