feat: workflow和chat分离
1,增加了创建workflow的页面 2.删除了event
This commit is contained in:
+45
-18
@@ -1,16 +1,18 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { TopBar } from './components/Layout/TopBar';
|
||||
import { CollapsibleSidebar } from './components/Layout/CollapsibleSidebar';
|
||||
import { SettingsLayout } from './components/Settings/SettingsLayout';
|
||||
import { AgentLayout } from './components/Agent/AgentLayout';
|
||||
import { PluginLayout } from './components/Plugin/PluginLayout'; // Will rename to PluginLayout soon
|
||||
import { PluginLayout } from './components/Plugin/PluginLayout';
|
||||
import { LeftPanel } from './components/Chat/LeftPanel';
|
||||
import { ChatPanel } from './components/Chat/ChatPanel';
|
||||
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';
|
||||
|
||||
// For Chat Module State Persistence
|
||||
export interface Message {
|
||||
id: string;
|
||||
role: 'user' | 'assistant' | 'system';
|
||||
@@ -25,33 +27,43 @@ export interface ChatSession {
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
function mapSessionFromDB(s: ChatSessionDB): ChatSession {
|
||||
return {
|
||||
id: s.chat_id,
|
||||
title: s.title,
|
||||
messages: [],
|
||||
updatedAt: new Date(s.updated_at).getTime(),
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [isAuthenticated, setIsAuthenticated] = useState(false);
|
||||
|
||||
// Layout State
|
||||
const [mode, setMode] = useState<'work' | 'agent'>('work');
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [isSidebarOpen, setIsSidebarOpen] = useState(true);
|
||||
|
||||
// Module Sub-navigation States
|
||||
// Work Mode
|
||||
const [workTab, setWorkTab] = useState<'chat' | 'workflow'>('chat');
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<string | null>(null);
|
||||
|
||||
// Agent Mode
|
||||
const [agentTab, setAgentTab] = useState<'plugin' | 'agents'>('plugin');
|
||||
|
||||
// Settings Sub-tab
|
||||
const [settingsTab, setSettingsTab] = useState('users');
|
||||
|
||||
// Inner Agent Tab (temporary until full Agent layout rewrite)
|
||||
const [innerAgentTab, setInnerAgentTab] = useState('worker');
|
||||
const [resourceTab, setResourceTab] = useState('skill');
|
||||
|
||||
// Chat State Hoisted for Persistence
|
||||
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);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
@@ -59,13 +71,18 @@ function App() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadChatSessions();
|
||||
}
|
||||
}, [isAuthenticated, loadChatSessions]);
|
||||
|
||||
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">
|
||||
{/* 1. Top Bar */}
|
||||
<TopBar
|
||||
mode={mode}
|
||||
setMode={setMode}
|
||||
@@ -73,13 +90,11 @@ function App() {
|
||||
setShowSettings={setShowSettings}
|
||||
/>
|
||||
|
||||
{/* 2. Main Content Area */}
|
||||
<div className="flex flex-1 overflow-hidden relative">
|
||||
{showSettings ? (
|
||||
<SettingsLayout settingsTab={settingsTab} setSettingsTab={setSettingsTab} />
|
||||
) : (
|
||||
<>
|
||||
{/* Collapsible Main Sidebar */}
|
||||
<CollapsibleSidebar
|
||||
mode={mode}
|
||||
isOpen={isSidebarOpen}
|
||||
@@ -90,7 +105,6 @@ function App() {
|
||||
setAgentTab={setAgentTab}
|
||||
/>
|
||||
|
||||
{/* Dynamic View based on Mode and Tab */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{mode === 'work' && workTab === 'chat' && (
|
||||
<div className="flex-1 p-6 flex overflow-hidden">
|
||||
@@ -99,17 +113,18 @@ function App() {
|
||||
activeTab="chats"
|
||||
selectedWorkflow={null}
|
||||
setSelectedWorkflow={() => {}}
|
||||
// Pass hoisted state down
|
||||
chatSessions={chatSessions}
|
||||
setChatSessions={setChatSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
setActiveSessionId={setActiveSessionId}
|
||||
onSessionsChanged={loadChatSessions}
|
||||
/>
|
||||
<ChatPanel
|
||||
chatSessions={chatSessions}
|
||||
setChatSessions={setChatSessions}
|
||||
activeSessionId={activeSessionId}
|
||||
setActiveSessionId={setActiveSessionId}
|
||||
onSessionsChanged={loadChatSessions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,7 +132,19 @@ function App() {
|
||||
|
||||
{mode === 'work' && workTab === 'workflow' && (
|
||||
<>
|
||||
{selectedWorkflow ? (
|
||||
{selectedWorkflow === 'new' ? (
|
||||
<>
|
||||
<LeftPanel
|
||||
activeTab="workflows"
|
||||
selectedWorkflow={selectedWorkflow}
|
||||
setSelectedWorkflow={setSelectedWorkflow}
|
||||
/>
|
||||
<NewWorkflowDialog
|
||||
onClose={() => setSelectedWorkflow(null)}
|
||||
onSuccess={(traceId: string) => setSelectedWorkflow(traceId)}
|
||||
/>
|
||||
</>
|
||||
) : selectedWorkflow ? (
|
||||
<>
|
||||
<LeftPanel
|
||||
activeTab="workflows"
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { MessageSquare, Activity, Terminal, ChevronRight, Plus } from 'lucide-react';
|
||||
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';
|
||||
|
||||
interface ChatPanelProps {
|
||||
chatSessions: ChatSession[];
|
||||
setChatSessions: React.Dispatch<React.SetStateAction<ChatSession[]>>;
|
||||
activeSessionId: string | null;
|
||||
setActiveSessionId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
onSessionsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setActiveSessionId }: ChatPanelProps) {
|
||||
export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setActiveSessionId, onSessionsChanged }: ChatPanelProps) {
|
||||
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);
|
||||
|
||||
@@ -28,6 +31,36 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
scrollToBottom();
|
||||
}, [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);
|
||||
if (session && session.messages.length === 0) {
|
||||
loadMessages(activeSessionId);
|
||||
}
|
||||
}
|
||||
}, [activeSessionId]);
|
||||
|
||||
const updateSessionMessages = (newMessages: Message[]) => {
|
||||
if (!activeSessionId) return;
|
||||
setChatSessions((prev) =>
|
||||
@@ -39,39 +72,30 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
);
|
||||
};
|
||||
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !activeSessionId) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
setLoading(true);
|
||||
const handleNewChat = async () => {
|
||||
if (!onSessionsChanged) return;
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/adapter/client/upload', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
const response = await apiClient.post('/api/v1/chat', {
|
||||
title: '新对话',
|
||||
initial_message: '你好',
|
||||
});
|
||||
const aiMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: `已上传文件: ${response.data.filename}`,
|
||||
timestamp: Date.now(),
|
||||
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(),
|
||||
};
|
||||
updateSessionMessages([...messages, aiMessage]);
|
||||
setChatSessions((prev) => [newSession, ...prev]);
|
||||
setActiveSessionId(chatId);
|
||||
onSessionsChanged();
|
||||
} catch (error) {
|
||||
console.error('Error uploading file', error);
|
||||
const errorMessage: Message = {
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: '文件上传失败。',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
updateSessionMessages([...messages, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
console.error('Failed to create chat session', error);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -86,21 +110,19 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
updateSessionMessages([...messages, userMessage]);
|
||||
const currentMessages = activeSession?.messages || [];
|
||||
updateSessionMessages([...currentMessages, userMessage]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const promptModifier = mode === 'deploy' ? '[DEPLOY TASK] ' : '';
|
||||
const response = await apiClient.post('/api/v1/adapter/client', {
|
||||
message: promptModifier + userMessage.content,
|
||||
const response = await apiClient.post(`/api/v1/chat/${activeSessionId}/reply`, {
|
||||
message: userText,
|
||||
});
|
||||
|
||||
const responseData = response.data.message;
|
||||
let aiContent = responseData || 'I received your message.';
|
||||
const replyContent: string = response.data?.reply || '收到你的消息。';
|
||||
|
||||
// Auto-update title if it's the first user message
|
||||
if (messages.length <= 1 && userText.length > 0) {
|
||||
if (currentMessages.length <= 1 && userText.length > 0) {
|
||||
setChatSessions((prev) =>
|
||||
prev.map((s) =>
|
||||
s.id === activeSessionId
|
||||
@@ -113,20 +135,21 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
const aiMessage: Message = {
|
||||
id: (Date.now() + 1).toString(),
|
||||
role: 'assistant',
|
||||
content: aiContent,
|
||||
content: replyContent,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
updateSessionMessages([...messages, userMessage, aiMessage]);
|
||||
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: 'Sorry, I encountered an error communicating with the server.',
|
||||
content: '抱歉,与服务器通信时出错。',
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
updateSessionMessages([...messages, userMessage, errorMessage]);
|
||||
updateSessionMessages([...currentMessages, userMessage, errorMessage]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -139,23 +162,7 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
<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={() => {
|
||||
const newSession: ChatSession = {
|
||||
id: Date.now().toString(),
|
||||
title: 'New Chat',
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am kilostar Assistant. How can I help you today?',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setChatSessions([newSession, ...chatSessions]);
|
||||
setActiveSessionId(newSession.id);
|
||||
}}
|
||||
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
|
||||
@@ -164,7 +171,6 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
);
|
||||
}
|
||||
|
||||
// Notice we removed the outer padded div, since App.tsx is handling that layout now
|
||||
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">
|
||||
@@ -192,32 +198,35 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat History */}
|
||||
<div className="flex-1 p-6 overflow-y-auto space-y-6 bg-white">
|
||||
{messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
{typeof msg.content === 'string' && msg.content.includes('-') && msg.role === 'assistant' && (msg.content.length === 36 || msg.content.includes('任务已创建')) && (
|
||||
<div className="mt-2 bg-white border border-slate-100 rounded-lg p-3 flex items-center text-sm shadow-sm">
|
||||
<Terminal size={16} className="text-slate-400 mr-2" />
|
||||
<span className="font-mono text-slate-600 text-xs">Task ID: {msg.content.substring(0, 36)}</span>
|
||||
</div>
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
))}
|
||||
) : (
|
||||
messages.map((msg) => (
|
||||
<div key={msg.id} className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}>
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
</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">
|
||||
@@ -235,10 +244,9 @@ export function ChatPanel({ chatSessions, setChatSessions, activeSessionId, setA
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Chat Input */}
|
||||
<div className="p-4 bg-white border-t border-slate-100 shrink-0">
|
||||
<div className="relative flex items-center">
|
||||
<input type="file" ref={fileInputRef} onChange={handleFileUpload} className="hidden" />
|
||||
<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"
|
||||
|
||||
@@ -8,11 +8,11 @@ interface LeftPanelProps {
|
||||
activeTab: string;
|
||||
selectedWorkflow: string | null;
|
||||
setSelectedWorkflow: (id: string | null) => void;
|
||||
// Hoisted state props (optional, since this panel is used for workflows too)
|
||||
chatSessions?: ChatSession[];
|
||||
setChatSessions?: React.Dispatch<React.SetStateAction<ChatSession[]>>;
|
||||
activeSessionId?: string | null;
|
||||
setActiveSessionId?: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
onSessionsChanged?: () => void;
|
||||
}
|
||||
|
||||
export function LeftPanel({
|
||||
@@ -23,6 +23,7 @@ export function LeftPanel({
|
||||
setChatSessions,
|
||||
activeSessionId,
|
||||
setActiveSessionId,
|
||||
onSessionsChanged,
|
||||
}: LeftPanelProps) {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [loadingWorkflows, setLoadingWorkflows] = useState(false);
|
||||
@@ -60,23 +61,31 @@ export function LeftPanel({
|
||||
};
|
||||
}, [activeTab]);
|
||||
|
||||
const handleNewChat = () => {
|
||||
if (!setChatSessions || !setActiveSessionId) return;
|
||||
const newSession: ChatSession = {
|
||||
id: Date.now().toString(),
|
||||
title: 'New Chat',
|
||||
messages: [
|
||||
{
|
||||
id: Date.now().toString(),
|
||||
role: 'assistant',
|
||||
content: 'Hello! I am kilostar Assistant. How can I help you today?',
|
||||
timestamp: Date.now(),
|
||||
},
|
||||
],
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
setChatSessions((prev) => [newSession, ...prev]);
|
||||
setActiveSessionId(newSession.id);
|
||||
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);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteChat = (e: React.MouseEvent, id: string) => {
|
||||
@@ -91,7 +100,6 @@ export function LeftPanel({
|
||||
|
||||
return (
|
||||
<div className="w-72 bg-white border-r border-slate-100 flex flex-col z-0 shrink-0">
|
||||
{/* Bottom: Tab Selection */}
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
|
||||
<div className="flex items-center justify-between p-3 border-b border-slate-100 bg-slate-50">
|
||||
@@ -103,7 +111,7 @@ export function LeftPanel({
|
||||
if (activeTab === 'chats') {
|
||||
handleNewChat();
|
||||
} else {
|
||||
setSelectedWorkflow('new'); // 设置为一个特殊值,表示进入新建工作流向导
|
||||
setSelectedWorkflow('new');
|
||||
}
|
||||
}}
|
||||
className="p-1.5 bg-blue-100 text-blue-600 rounded hover:bg-blue-200 transition-colors"
|
||||
@@ -123,10 +131,10 @@ export function LeftPanel({
|
||||
) : (
|
||||
workflows.map((wf) => (
|
||||
<div
|
||||
key={wf.event_id}
|
||||
onClick={() => setSelectedWorkflow(wf.event_id)}
|
||||
key={wf.trace_id}
|
||||
onClick={() => setSelectedWorkflow(wf.trace_id)}
|
||||
className={`p-3 rounded-lg border cursor-pointer transition-all ${
|
||||
selectedWorkflow === wf.event_id
|
||||
selectedWorkflow === wf.trace_id
|
||||
? 'border-blue-300 bg-blue-50 shadow-sm'
|
||||
: 'border-slate-100 hover:border-blue-200 hover:bg-slate-50'
|
||||
}`}
|
||||
@@ -134,22 +142,24 @@ export function LeftPanel({
|
||||
<div className="flex justify-between items-center mb-1">
|
||||
<span
|
||||
className={`font-medium text-sm ${
|
||||
selectedWorkflow === wf.event_id ? 'text-blue-700' : 'text-slate-700'
|
||||
selectedWorkflow === wf.trace_id ? 'text-blue-700' : 'text-slate-700'
|
||||
}`}
|
||||
>
|
||||
{wf.workflow_title || 'Unnamed Workflow'}
|
||||
{wf.title || 'Unnamed Workflow'}
|
||||
</span>
|
||||
<span
|
||||
className={`flex h-2 w-2 rounded-full ${
|
||||
wf.status === 'llm_working' || wf.status === 'tool_working'
|
||||
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.event_id}</p>
|
||||
<p className="text-xs text-slate-500 font-mono line-clamp-1">ID: {wf.trace_id}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Terminal, X, ArrowRight } from 'lucide-react';
|
||||
import { Terminal, Sparkles, ArrowLeft } from 'lucide-react';
|
||||
import apiClient from '../../api/client';
|
||||
|
||||
interface NewWorkflowDialogProps {
|
||||
@@ -13,10 +13,14 @@ export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!command.trim() || !title.trim()) {
|
||||
setError('标题和需求描述不能为空');
|
||||
const handleSubmit = async (e?: React.FormEvent) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
setError('请输入工作流标题');
|
||||
return;
|
||||
}
|
||||
if (!command.trim()) {
|
||||
setError('请输入具体需求描述');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -25,8 +29,8 @@ export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps
|
||||
|
||||
try {
|
||||
const response = await apiClient.post('/api/v1/workflow', {
|
||||
title: title,
|
||||
command: command
|
||||
title: title.trim(),
|
||||
command: command.trim()
|
||||
});
|
||||
if (response.data && response.data.trace_id) {
|
||||
onSuccess(response.data.trace_id);
|
||||
@@ -38,67 +42,110 @@ export function NewWorkflowDialog({ onClose, onSuccess }: NewWorkflowDialogProps
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 bg-white/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
|
||||
<div className="bg-white rounded-2xl shadow-xl border border-slate-200 w-full max-w-md overflow-hidden">
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-slate-100 bg-slate-50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Terminal size={20} className="text-blue-600" />
|
||||
<h2 className="font-semibold text-slate-800">新建工作流任务</h2>
|
||||
</div>
|
||||
<button onClick={onClose} className="p-1 text-slate-400 hover:bg-slate-200 rounded-lg transition-colors">
|
||||
<X size={18} />
|
||||
</button>
|
||||
</div>
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
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} />
|
||||
返回工作流列表
|
||||
</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>
|
||||
</div>
|
||||
<div className="w-28" />
|
||||
</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">
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-100 rounded-lg">
|
||||
<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>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">工作流标题</label>
|
||||
<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>
|
||||
工作流标题
|
||||
</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-3 py-2 border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
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
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-slate-700 mb-1">具体需求指令</label>
|
||||
<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>
|
||||
需求描述
|
||||
</label>
|
||||
<p className="text-xs text-slate-400 mb-3 shrink-0">详细描述你希望 AI 完成的任务,越具体效果越好</p>
|
||||
<textarea
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
placeholder="详细描述您希望工作流完成的任务..."
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg h-32 resize-none focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="例如:请帮我自动执行以下任务:
|
||||
|
||||
<div className="pt-2 flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-sm font-medium text-slate-600 bg-slate-100 hover:bg-slate-200 rounded-lg transition-colors"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
|
||||
>
|
||||
{loading ? '创建中...' : '开始创建'}
|
||||
{!loading && <ArrowRight size={16} />}
|
||||
</button>
|
||||
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>
|
||||
<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>
|
||||
<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"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Sparkles size={16} className="animate-spin" />
|
||||
正在创建...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles size={16} />
|
||||
创建并启动工作流
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
try {
|
||||
const response = await apiClient.get(`/api/v1/workflow/${traceId}`);
|
||||
setDetail(response.data);
|
||||
} catch {
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch workflow detail', err);
|
||||
setDetail(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -38,7 +39,7 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
}
|
||||
|
||||
fetchDetail(selectedWorkflow);
|
||||
setLogs([]); // Reset logs when changing workflow
|
||||
setLogs([]);
|
||||
|
||||
const protocol = window.location.protocol;
|
||||
const host = window.location.host;
|
||||
@@ -95,8 +96,8 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
<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?.workflow_title || 'Loading...'}>
|
||||
{detail?.workflow_title || 'Workflow Details'}
|
||||
<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'}`}>
|
||||
@@ -104,7 +105,6 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Navigation Tabs */}
|
||||
<div className="flex items-center bg-slate-100 rounded-lg p-1">
|
||||
<button
|
||||
onClick={() => setActiveTab('chat')}
|
||||
@@ -135,7 +135,7 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
{activeTab === 'diagram' ? (
|
||||
<div className="absolute inset-0">
|
||||
{detail?.steps && detail.steps.length > 0 ? (
|
||||
<WorkflowDiagram steps={detail.steps} currentStep={detail.current_step} status={detail.status} />
|
||||
<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.
|
||||
@@ -144,7 +144,6 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col p-6 overflow-hidden">
|
||||
{/* Command Header */}
|
||||
{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>
|
||||
@@ -152,7 +151,6 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live Chat / Logs Area */}
|
||||
<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">
|
||||
@@ -168,7 +166,6 @@ export function RightPanel({ selectedWorkflow }: RightPanelProps) {
|
||||
<div ref={logsEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Area */}
|
||||
<form onSubmit={handleReplySubmit} className="relative shrink-0">
|
||||
<input
|
||||
type="text"
|
||||
|
||||
@@ -38,7 +38,7 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
|
||||
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 === 'llm_working' || status === 'tool_working' || status?.includes('working'))
|
||||
if (status && (status.includes('working')))
|
||||
return <PlayCircle size={20} className="text-blue-500 animate-pulse" />;
|
||||
return <Clock size={20} className="text-slate-400" />;
|
||||
};
|
||||
@@ -49,7 +49,7 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
|
||||
|
||||
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?.includes('working')) { colorClass = "bg-blue-100 text-blue-700 animate-pulse"; label = "Running"; }
|
||||
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}`}>
|
||||
@@ -95,8 +95,8 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
{workflows.map((wf) => (
|
||||
<div
|
||||
key={wf.event_id}
|
||||
onClick={() => onSelectWorkflow(wf.event_id)}
|
||||
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">
|
||||
@@ -106,33 +106,24 @@ export function WorkflowListView({ onSelectWorkflow }: WorkflowListViewProps) {
|
||||
{getStatusBadge(wf.status)}
|
||||
</div>
|
||||
|
||||
<h3 className="text-lg font-semibold text-slate-800 mb-2 line-clamp-1" title={wf.workflow_title || 'Unnamed Workflow'}>
|
||||
{wf.workflow_title || 'Unnamed Workflow'}
|
||||
<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 as any).command && (
|
||||
{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 as any).command}"
|
||||
</div>
|
||||
)}
|
||||
{wf.message && !(wf as any).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.message}"
|
||||
"{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.event_id || (wf as any).trace_id}>
|
||||
{wf.event_id || (wf as any).trace_id}
|
||||
<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.create_time && (
|
||||
<span>{new Date(wf.create_time).toLocaleDateString()}</span>
|
||||
)}
|
||||
{(wf as any).created_at && !wf.create_time && (
|
||||
<span>{new Date((wf as any).created_at).toLocaleDateString()}</span>
|
||||
{wf.created_at && (
|
||||
<span>{new Date(wf.created_at).toLocaleDateString()}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+24
-19
@@ -19,7 +19,6 @@ export interface Provider {
|
||||
provider_url?: string;
|
||||
provider_owner?: string;
|
||||
provider_models?: string[];
|
||||
// Based on your UI needs we might infer some local status fields
|
||||
status?: string;
|
||||
model?: string;
|
||||
}
|
||||
@@ -51,22 +50,31 @@ export interface ClusterNode {
|
||||
remaining: ClusterResources;
|
||||
}
|
||||
|
||||
// Chat types
|
||||
export interface ChatMessageRequest {
|
||||
// Chat types (from DB)
|
||||
export interface ChatSessionDB {
|
||||
chat_id: string;
|
||||
user_id: string;
|
||||
title: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageDB {
|
||||
message_id: string;
|
||||
chat_id: string;
|
||||
message: string;
|
||||
message_owner: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface ChatMessageResponse {
|
||||
message: string; // Either event_id or text
|
||||
}
|
||||
|
||||
// Workflow types
|
||||
// Workflow types (matches backend API response)
|
||||
export interface Workflow {
|
||||
event_id: string;
|
||||
workflow_title: string;
|
||||
trace_id: string;
|
||||
title: string;
|
||||
command: string;
|
||||
status?: string;
|
||||
message?: string;
|
||||
create_time?: string;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
@@ -80,22 +88,19 @@ export interface WorkflowStep {
|
||||
}
|
||||
|
||||
export interface WorkflowDetail {
|
||||
event_id: string;
|
||||
workflow_title: string | null;
|
||||
trace_id: string;
|
||||
title: string;
|
||||
status: string;
|
||||
command?: string;
|
||||
current_step: number;
|
||||
user_name: string;
|
||||
message: string;
|
||||
create_time: string;
|
||||
steps: WorkflowStep[];
|
||||
context_blackboard?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Workflow Template Validation
|
||||
export interface WorkStep {
|
||||
name: string;
|
||||
desc?: string;
|
||||
actor: string; // the name of the worker individual
|
||||
actor: string;
|
||||
inputs?: string[];
|
||||
outputs?: string[];
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user