From 0f23dffc260004d499d10b6b64512312a7700aab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C2=A8NW=C2=A8?= <¨neroworld@mail.ru¨> Date: Fri, 10 Apr 2026 15:43:33 +0100 Subject: [PATCH] feat(agents): restore agent-worker container architecture + fix chat scroll and parallel chats - Restore agent-worker from commit 153399f: autonomous HTTP server per agent (main.go 597 lines, main_test.go 438 lines, Dockerfile.agent-worker) - Add container fields to agents table (serviceName, servicePort, containerImage, containerStatus) - Update executor.go: real delegateToAgent() with HTTP POST to agent containers - Update db.go: GetAgentByID, UpdateContainerStatus, GetAgentHistory, SaveHistory - Update orchestrator.go: inject DB into executor for container address resolution - Add tRPC endpoints: agents.deployContainer, agents.stopContainer, agents.containerStatus - Add Docker Swarm deploy/stop logic in server/agents.ts - Add Start/Stop container buttons to Agents.tsx with status badges - Fix chat auto-scroll: replace ScrollArea with overflow-y-auto for direct scrollTop control - Fix parallel chats: make isThinking per-conversation (thinkingConvId) instead of global so switching between chats works while one is processing --- client/src/lib/chatStore.ts | 23 +- client/src/pages/Agents.tsx | 586 +++++++++----- client/src/pages/Chat.tsx | 74 +- docker/Dockerfile.agent-worker | 45 ++ drizzle/0006_agent_container_fields.sql | 12 + drizzle/schema.ts | 393 ++++++---- gateway/cmd/agent-worker/main.go | 727 ++++++++++++++++++ gateway/cmd/agent-worker/main_test.go | 438 +++++++++++ gateway/go.mod | 7 +- gateway/internal/db/db.go | 113 ++- gateway/internal/orchestrator/orchestrator.go | 2 + gateway/internal/tools/executor.go | 97 ++- server/agents.ts | 320 +++++++- server/routers.ts | 219 ++++-- 14 files changed, 2583 insertions(+), 473 deletions(-) create mode 100644 docker/Dockerfile.agent-worker create mode 100644 drizzle/0006_agent_container_fields.sql create mode 100644 gateway/cmd/agent-worker/main.go create mode 100644 gateway/cmd/agent-worker/main_test.go diff --git a/client/src/lib/chatStore.ts b/client/src/lib/chatStore.ts index c85199a..3190247 100644 --- a/client/src/lib/chatStore.ts +++ b/client/src/lib/chatStore.ts @@ -93,7 +93,7 @@ function getTs(): string { class ChatStore { private conversations: Conversation[] = loadConversations(); private activeId: string = ""; - private isThinking = false; + private thinkingConvId: string | null = null; // per-conversation thinking lock private activeAgents: AgentActivity[] = []; private listeners = new Set(); @@ -129,7 +129,14 @@ class ChatStore { return this.conversations.find(c => c.id === this.activeId) ?? null; } getIsThinking(): boolean { - return this.isThinking; + return this.thinkingConvId !== null; + } + getIsConversationThinking(convId: string): boolean { + return this.thinkingConvId === convId; + } + /** Which conversation is currently thinking, or null */ + getThinkingConvId(): string | null { + return this.thinkingConvId; } // ─── Mutations ────────────────────────────────────────────────────────────── @@ -175,8 +182,12 @@ class ChatStore { this.emit(); } - setThinking(v: boolean) { - this.isThinking = v; + setThinking(v: boolean, convId?: string) { + if (v) { + this.thinkingConvId = convId ?? this.activeId; + } else { + this.thinkingConvId = null; + } this.emit(); } @@ -281,7 +292,7 @@ class ChatStore { : c ); persistConversations(this.conversations); - this.isThinking = false; + this.thinkingConvId = null; this.emit(); } @@ -299,7 +310,7 @@ class ChatStore { c.id === convId ? { ...c, messages: [...c.messages, msg] } : c ); persistConversations(this.conversations); - this.isThinking = false; + this.thinkingConvId = null; this.emit(); } diff --git a/client/src/pages/Agents.tsx b/client/src/pages/Agents.tsx index 5074177..614388e 100644 --- a/client/src/pages/Agents.tsx +++ b/client/src/pages/Agents.tsx @@ -41,6 +41,8 @@ import { Shield, Wrench, Code2, + Container, + Square, } from "lucide-react"; import { motion } from "framer-motion"; import { useState } from "react"; @@ -74,6 +76,21 @@ function getStatusConfig(status: string) { } } +function getContainerStatusConfig(status: string | undefined | null) { + switch (status) { + case "running": + return { label: "RUNNING", color: "text-neon-green", bg: "bg-neon-green/15", border: "border-neon-green/30", icon: Container }; + case "deploying": + return { label: "DEPLOYING", color: "text-neon-amber", bg: "bg-neon-amber/15", border: "border-neon-amber/30", icon: Loader2 }; + case "error": + return { label: "ERROR", color: "text-neon-red", bg: "bg-neon-red/15", border: "border-neon-red/30", icon: AlertCircle }; + case "stopped": + default: + return { label: "STOPPED", color: "text-muted-foreground", bg: "bg-muted/50", border: "border-border", icon: Square }; + } +} +} + export default function Agents() { const [selectedAgent, setSelectedAgent] = useState(null); const [detailModalOpen, setDetailModalOpen] = useState(false); @@ -97,6 +114,37 @@ export default function Agents() { }, }); + const deployMutation = trpc.agents.deployContainer.useMutation({ + onSuccess: (data) => { + if (data.success) { + toast.success(`Agent container deployed: ${data.serviceName}`); + } else { + toast.error(`Deploy failed: ${data.error}`); + } + refetch(); + }, + onError: (error) => { + toast.error(`Deploy error: ${error.message}`); + }, + }); + + const stopMutation = trpc.agents.stopContainer.useMutation({ + onSuccess: (data) => { + if (data.success) { + toast.success("Agent container stopped"); + } else { + toast.error(`Stop failed: ${data.error}`); + } + refetch(); + }, + onError: (error) => { + toast.error(`Stop error: ${error.message}`); + }, + }); + + const [deployingAgentId, setDeployingAgentId] = useState(null); + const [stoppingAgentId, setStoppingAgentId] = useState(null); + const handleDeleteClick = (agentId: number) => { setAgentToDelete(agentId); setDeleteConfirmOpen(true); @@ -139,7 +187,8 @@ export default function Agents() {

Agent Fleet

- {agents.filter((a: any) => !a.isOrchestrator).length} agents · {agents.filter((a: any) => a.isOrchestrator).length} orchestrator + {agents.filter((a: any) => !a.isOrchestrator).length} agents ·{" "} + {agents.filter((a: any) => a.isOrchestrator).length} orchestrator

+ + - )} - -
- - - -
- - - - ); - })} + + + + ); + })} {/* Specialized Agents Section */} {agents.filter((a: any) => !a.isOrchestrator).length > 0 && ( @@ -273,126 +370,226 @@ export default function Agents() { SPECIALIZED AGENTS
- {agents.filter((a: any) => !a.isOrchestrator).map((agent: any, i: number) => { - const sc = getStatusConfig(agent.status || "idle"); - const Icon = ROLE_ICONS[agent.role] || Bot; - const temperature = typeof agent.temperature === "string" ? parseFloat(agent.temperature) : (agent.temperature ?? 0.7); + {agents + .filter((a: any) => !a.isOrchestrator) + .map((agent: any, i: number) => { + const sc = getStatusConfig(agent.status || "idle"); + const csc = getContainerStatusConfig(agent.containerStatus); + const ContainerIcon = csc.icon; + const Icon = ROLE_ICONS[agent.role] || Bot; + const temperature = + typeof agent.temperature === "string" + ? parseFloat(agent.temperature) + : (agent.temperature ?? 0.7); + const isDeploying = deployingAgentId === agent.id; + const isStopping = stoppingAgentId === agent.id; - return ( - - handleEditAgent(agent)}> - - {/* Top row */} -
-
-
- -
-
-
-

{agent.name}

- {agent.isSystem && ( - - SYS - - )} + return ( + + handleEditAgent(agent)} + > + + {/* Top row */} +
+
+
+
-

{agent.description || "No description"}

+
+
+

+ {agent.name} +

+ {agent.isSystem && ( + + SYS + + )} +
+

+ {agent.description || "No description"} +

+
+
+
+ + + {agent.isActive ? "ACTIVE" : "INACTIVE"} + + + + {csc.label} +
- - - {agent.isActive ? "ACTIVE" : "INACTIVE"} - -
- {/* Model & Config */} -
-
-
- - MODEL -
-
{agent.model}
-
{agent.provider}
-
-
-
- - CONFIG -
-
T: {temperature.toFixed(2)}
-
Tokens: {agent.maxTokens}
-
-
- - {/* Role & Date */} -
-
- - Role: - {agent.role} -
-
- - Created: - {new Date(agent.createdAt).toLocaleDateString()} -
-
- - {/* Tools */} - {agent.allowedTools && agent.allowedTools.length > 0 && ( -
- TOOLS -
- {agent.allowedTools.map((tool: string) => ( - - {tool} + {/* Model & Config */} +
+
+
+ + + MODEL - ))} +
+
+ {agent.model} +
+
+ {agent.provider} +
+
+
+
+ + + CONFIG + +
+
+ T: {temperature.toFixed(2)} +
+
+ Tokens: {agent.maxTokens} +
- )} - {/* Actions */} -
- - - {!agent.isSystem && ( + {/* Role & Date */} +
+
+ + + Role: + + + {agent.role} + +
+
+ + + Created: + + + {new Date( + agent.createdAt + ).toLocaleDateString()} + +
+
+ + {/* Tools */} + {agent.allowedTools && + agent.allowedTools.length > 0 && ( +
+ + TOOLS + +
+ {agent.allowedTools.map((tool: string) => ( + + {tool} + + ))} +
+
+ )} + + {/* Actions */} +
+ {agent.containerStatus === "running" ? ( + + ) : ( + + )} - )} -
- - - - ); - })} + + {!agent.isSystem && ( + + )} +
+ + + + ); + })}
)} @@ -419,7 +616,8 @@ export default function Agents() { Delete Agent - Are you sure you want to delete this agent? This action cannot be undone. + Are you sure you want to delete this agent? This action cannot be + undone.
diff --git a/client/src/pages/Chat.tsx b/client/src/pages/Chat.tsx index 0c9527e..46dba74 100644 --- a/client/src/pages/Chat.tsx +++ b/client/src/pages/Chat.tsx @@ -60,6 +60,9 @@ function useChatStore() { activeId: chatStore.getActiveId(), active: chatStore.getActive(), isThinking: chatStore.getIsThinking(), + isThinkingConvId: chatStore.getThinkingConvId(), + isConversationThinking: (convId: string) => + chatStore.getIsConversationThinking(convId), activeAgents: chatStore.getActiveAgents(), }; } @@ -509,11 +512,13 @@ function ConversationItem({ isActive, onClick, onDelete, + isThinking, }: { conv: Conversation; isActive: boolean; onClick: () => void; onDelete: () => void; + isThinking?: boolean; }) { const lastMsg = conv.messages[conv.messages.length - 1]; const lastContent = @@ -530,12 +535,19 @@ function ConversationItem({ className={`group flex items-start gap-1.5 px-2 py-1.5 rounded cursor-pointer transition-colors ${ isActive ? "bg-[#00D4FF]/10 border border-[#00D4FF]/20" - : "hover:bg-secondary/30 border border-transparent" + : isThinking + ? "bg-[#FFB800]/5 border border-[#FFB800]/20" + : "hover:bg-secondary/30 border border-transparent" }`} > - +
+ + {isThinking && ( + + )} +

("console"); const scrollRef = useRef(null); @@ -598,11 +617,21 @@ export default function Chat() { } }, [orchestratorConfigQuery.data, conversations.length]); - // Auto-scroll + // Auto-scroll to bottom when messages change useEffect(() => { - if (scrollRef.current) - scrollRef.current.scrollTop = scrollRef.current.scrollHeight; - }, [active?.messages]); + const el = scrollRef.current; + if (!el) return; + const scrollToBottom = () => { + el.scrollTop = el.scrollHeight; + }; + requestAnimationFrame(scrollToBottom); + const t1 = setTimeout(scrollToBottom, 50); + const t2 = setTimeout(scrollToBottom, 200); + return () => { + clearTimeout(t1); + clearTimeout(t2); + }; + }, [active?.messages, isCurrentConvThinking, activeAgents]); // Auto-resize textarea const adjustTextareaHeight = useCallback(() => { @@ -718,15 +747,19 @@ export default function Chat() { activeId, ]); + const isCurrentConvThinking = activeId + ? isConversationThinking(activeId) + : false; + const sendMessage = async () => { - if (!input.trim() || isThinking) return; + if (!input.trim() || isCurrentConvThinking) return; const { convId: cid, newHistory } = chatStore.addUserMessage( input.trim(), convId ); setInput(""); - chatStore.setThinking(true); + chatStore.setThinking(true, cid); setActiveTab("console"); const thinkingId = chatStore.addThinkingMessage(cid); @@ -877,6 +910,7 @@ export default function Chat() { key={c.id} conv={c} isActive={c.id === activeId} + isThinking={isConversationThinking(c.id)} onClick={() => chatStore.setActiveId(c.id)} onDelete={() => chatStore.deleteConversation(c.id)} /> @@ -888,14 +922,14 @@ export default function Chat() { {/* ─── Center — Chat Area ─── */} - -

+
+
{messages.map(msg => ( ))} - {isThinking && ( + {isCurrentConvThinking && ( )}
- +
{/* Input area */}
@@ -945,21 +979,21 @@ export default function Chat() { onChange={e => setInput(e.target.value)} onKeyDown={handleKeyDown} placeholder={ - isThinking + isCurrentConvThinking ? "Ожидание ответа..." : "Введите команду... (Shift+Enter для новой строки)" } - disabled={isThinking} + disabled={isCurrentConvThinking} rows={1} className="flex-1 bg-transparent text-foreground font-mono text-sm placeholder:text-muted-foreground/50 focus:outline-none resize-none min-h-[32px] max-h-[150px] py-1.5" />