From d0e7e614e2936aeb517fbce5a3c8e0402a27592f Mon Sep 17 00:00:00 2001 From: thomas Date: Fri, 3 Jul 2026 17:35:56 +0800 Subject: [PATCH 1/6] fix(nix): update node_modules hash and use build-time bun version for version check (#28) ## Changes 1. **nix/hashes.json**: Update `x86_64-linux` node_modules hash to match current nixpkgs-unstable snapshot 2. **nix/deepagent-code.nix**: Add `patchPhase` that dynamically reads the actual bun version at build time, instead of hardcoding `^1.3.14`. This avoids build failures when nixpkgs-unstable ships a slightly different bun version (e.g., 1.3.13). ## Related Closes #27 Signed-off-by: thomas-yanga --- nix/deepagent-code.nix | 5 +++++ nix/hashes.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/nix/deepagent-code.nix b/nix/deepagent-code.nix index 5166f141..a5c55569 100644 --- a/nix/deepagent-code.nix +++ b/nix/deepagent-code.nix @@ -18,6 +18,11 @@ stdenvNoCC.mkDerivation (finalAttrs: { inherit (node_modules) version src; inherit node_modules; + patchPhase = '' + BUN_VERSION=$(bun --version) + sed -i "s/const expectedBunVersionRange.*/const expectedBunVersionRange = \"^$BUN_VERSION\";/" packages/script/src/index.ts + ''; + nativeBuildInputs = [ bun nodejs # for patchShebangs node_modules diff --git a/nix/hashes.json b/nix/hashes.json index cf1467c9..17fbfc4d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,6 +1,6 @@ { "nodeModules": { - "x86_64-linux": "sha256-OttgLsPJSYW1b9RGqiYSi7qPdDC3GuD2FE7k5tPe6Iw=", + "x86_64-linux": "sha256-vB4imi5LtCxnDuOaJFQh+asK51RUDCnIVCkIzBQN12o=", "aarch64-linux": "sha256-VsmW8tTiT3VkTxp5oiYc/maJLSsBMxK6VVUSVshWVT8=", "aarch64-darwin": "sha256-/MaQBX2zoJMtLA8jHt8+uKpIFve/eec3RfcKT3FvXC8=", "x86_64-darwin": "sha256-3ggap9rgR2TvVbYgnXsgkg6Rj1TqNZu32QgTVRdN0f0=" From 2899d795eb5e1173a87346900aed94876b1d8a73 Mon Sep 17 00:00:00 2001 From: deepagent-ai Date: Sun, 5 Jul 2026 03:02:33 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(v3.8):=20=E6=A1=8C=E9=9D=A2=E7=AB=AF?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20Server=20Edition=20=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=20+=20IM=20=E5=AE=8C=E6=95=B4=E5=AE=9E=E7=8E=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据面 (deepagent-code): - 新增 /global/capabilities 端点(protocolVersion 3.8 + features), 经网关 /w 代理供桌面端做兼容握手,补齐设计 §20.1 缺口 - IM HttpApi:群组/消息/已读/agent 路由 + WebSocket(typing/read_receipt/ message_created 广播),SQLite schema 迁移 - core/im:orchestrator、executor、repository、mention-parser、broadcaster 桌面端 (app): - gateway-client:控制面登录/刷新/登出/容器 provision + 401 刷新重试 - GatewayProvider:内存态 bearer、鉴权 fetch、容器就绪门控 - ServerConnection.Server 变体(keyed server:,bearer 永不持久化) - im-client:Bearer/Basic 鉴权、directory 走 header 穿透网关代理、 WS access_token cookie;IM UI(群聊面板/侧栏/输入/typing/已读回执) - 连接服务端登录对话框 Co-Authored-By: Claude Opus 4.8 --- packages/app/src/app.tsx | 65 +-- .../src/components/im/agent-status-chip.tsx | 49 +++ .../src/components/im/group-chat-panel.tsx | 126 ++++++ packages/app/src/components/im/im-sidebar.tsx | 102 +++++ .../src/components/im/message-composer.tsx | 244 ++++++++++ .../app/src/components/im/message-item.tsx | 52 +++ .../app/src/components/im/message-list.tsx | 43 ++ .../components/im/message-metadata-card.tsx | 114 +++++ packages/app/src/components/im/types.ts | 42 ++ .../settings-v2/dialog-connect-server.tsx | 153 +++++++ .../src/components/settings-v2/servers.tsx | 8 + packages/app/src/context/gateway.tsx | 152 +++++++ packages/app/src/context/server-sdk.tsx | 21 +- packages/app/src/context/server.tsx | 67 ++- packages/app/src/hooks/use-im-websocket.ts | 215 +++++++++ packages/app/src/pages/im.tsx | 43 ++ packages/app/src/utils/gateway-client.test.ts | 115 +++++ packages/app/src/utils/gateway-client.ts | 198 +++++++++ packages/app/src/utils/im-client.test.ts | 107 +++++ packages/app/src/utils/im-client.ts | 162 +++++++ packages/app/src/utils/server.ts | 12 +- packages/cli/src/commands/handlers/serve.ts | 13 +- packages/core/src/database/migration.gen.ts | 1 + .../20260704000000_im_system_tables.ts | 100 +++++ packages/core/src/im/agent-executor.ts | 121 +++++ packages/core/src/im/agent-list-provider.ts | 55 +++ packages/core/src/im/agent-orchestrator.ts | 221 ++++++++++ packages/core/src/im/broadcaster.ts | 76 ++++ packages/core/src/im/context-builder.ts | 176 ++++++++ packages/core/src/im/id.ts | 27 ++ packages/core/src/im/index.ts | 11 + packages/core/src/im/mention-parser.ts | 58 +++ packages/core/src/im/protocol.ts | 11 + packages/core/src/im/repository.ts | 416 ++++++++++++++++++ packages/core/src/im/sql.ts | 153 +++++++ packages/core/src/im/websocket.ts | 131 ++++++ packages/core/test/im-e2e.test.ts | 193 ++++++++ packages/core/test/im-integration.test.ts | 306 +++++++++++++ packages/core/test/im-orchestrator.test.ts | 211 +++++++++ packages/core/test/im-repository.test.ts | 406 +++++++++++++++++ packages/core/test/mention-parser.test.ts | 113 +++++ .../src/im/agent-executor-server.ts | 181 ++++++++ .../src/server/routes/instance/httpapi/api.ts | 4 + .../routes/instance/httpapi/groups/global.ts | 28 ++ .../instance/httpapi/groups/im-websocket.ts | 33 ++ .../routes/instance/httpapi/groups/im.ts | 276 ++++++++++++ .../instance/httpapi/handlers/global.ts | 15 + .../instance/httpapi/handlers/im-websocket.ts | 193 ++++++++ .../routes/instance/httpapi/handlers/im.ts | 411 +++++++++++++++++ .../server/routes/instance/httpapi/server.ts | 29 ++ .../httpapi/utils/workspace-context.ts | 45 ++ .../test/server/httpapi-im-agent.test.ts | 136 ++++++ .../test/server/httpapi-instance.test.ts | 19 + 53 files changed, 6211 insertions(+), 48 deletions(-) create mode 100644 packages/app/src/components/im/agent-status-chip.tsx create mode 100644 packages/app/src/components/im/group-chat-panel.tsx create mode 100644 packages/app/src/components/im/im-sidebar.tsx create mode 100644 packages/app/src/components/im/message-composer.tsx create mode 100644 packages/app/src/components/im/message-item.tsx create mode 100644 packages/app/src/components/im/message-list.tsx create mode 100644 packages/app/src/components/im/message-metadata-card.tsx create mode 100644 packages/app/src/components/im/types.ts create mode 100644 packages/app/src/components/settings-v2/dialog-connect-server.tsx create mode 100644 packages/app/src/context/gateway.tsx create mode 100644 packages/app/src/hooks/use-im-websocket.ts create mode 100644 packages/app/src/pages/im.tsx create mode 100644 packages/app/src/utils/gateway-client.test.ts create mode 100644 packages/app/src/utils/gateway-client.ts create mode 100644 packages/app/src/utils/im-client.test.ts create mode 100644 packages/app/src/utils/im-client.ts create mode 100644 packages/core/src/database/migration/20260704000000_im_system_tables.ts create mode 100644 packages/core/src/im/agent-executor.ts create mode 100644 packages/core/src/im/agent-list-provider.ts create mode 100644 packages/core/src/im/agent-orchestrator.ts create mode 100644 packages/core/src/im/broadcaster.ts create mode 100644 packages/core/src/im/context-builder.ts create mode 100644 packages/core/src/im/id.ts create mode 100644 packages/core/src/im/index.ts create mode 100644 packages/core/src/im/mention-parser.ts create mode 100644 packages/core/src/im/protocol.ts create mode 100644 packages/core/src/im/repository.ts create mode 100644 packages/core/src/im/sql.ts create mode 100644 packages/core/src/im/websocket.ts create mode 100644 packages/core/test/im-e2e.test.ts create mode 100644 packages/core/test/im-integration.test.ts create mode 100644 packages/core/test/im-orchestrator.test.ts create mode 100644 packages/core/test/im-repository.test.ts create mode 100644 packages/core/test/mention-parser.test.ts create mode 100644 packages/deepagent-code/src/im/agent-executor-server.ts create mode 100644 packages/deepagent-code/src/server/routes/instance/httpapi/groups/im-websocket.ts create mode 100644 packages/deepagent-code/src/server/routes/instance/httpapi/groups/im.ts create mode 100644 packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im-websocket.ts create mode 100644 packages/deepagent-code/src/server/routes/instance/httpapi/handlers/im.ts create mode 100644 packages/deepagent-code/src/server/routes/instance/httpapi/utils/workspace-context.ts create mode 100644 packages/deepagent-code/test/server/httpapi-im-agent.test.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 2c4fd7b1..2848c15f 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -31,6 +31,7 @@ import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { DebugProvider } from "@/context/debug" import { FileProvider } from "@/context/file" +import { GatewayProvider } from "@/context/gateway" import { ServerSDKProvider } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider } from "@/context/global" @@ -55,6 +56,7 @@ const HomeRoute = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) const AgentSystemRoute = lazy(() => import("@/pages/agent-system")) const ReviewRoute = lazy(() => import("@/pages/review")) +const IMRoute = lazy(() => import("@/pages/im")) const SessionRoute = Object.assign( () => ( @@ -311,39 +313,42 @@ export function AppInterface(props: { disableHealthCheck?: boolean }) { return ( - - - - ( - - - - - - {routerProps.children} - - - - - - )} - > - - + + + + + ( + + + + + + {routerProps.children} + + + + + + )} + > + + } /> - - - - - + + + + + + + ) } diff --git a/packages/app/src/components/im/agent-status-chip.tsx b/packages/app/src/components/im/agent-status-chip.tsx new file mode 100644 index 00000000..500935db --- /dev/null +++ b/packages/app/src/components/im/agent-status-chip.tsx @@ -0,0 +1,49 @@ +import { createMemo } from "solid-js" + +interface AgentStatusChipProps { + agentID: string + status: string +} + +export function AgentStatusChip(props: AgentStatusChipProps) { + const getStatusColor = createMemo(() => { + switch (props.status) { + case "started": + case "running": + return "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200" + case "success": + return "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200" + case "failed": + return "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200" + case "timeout": + return "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200" + default: + return "bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200" + } + }) + + const getStatusIcon = createMemo(() => { + switch (props.status) { + case "started": + case "running": + return "⏳" + case "success": + return "✓" + case "failed": + return "✗" + case "timeout": + return "⏱" + default: + return "•" + } + }) + + return ( +
+ {getStatusIcon()} + Agent {props.agentID} + + {props.status} +
+ ) +} diff --git a/packages/app/src/components/im/group-chat-panel.tsx b/packages/app/src/components/im/group-chat-panel.tsx new file mode 100644 index 00000000..196a1079 --- /dev/null +++ b/packages/app/src/components/im/group-chat-panel.tsx @@ -0,0 +1,126 @@ +import { createSignal, createEffect, onMount, Show } from "solid-js" +import { MessageList } from "./message-list" +import { MessageComposer } from "./message-composer" +import { useIMWebSocket } from "@/hooks/use-im-websocket" +import type { LocalMessage } from "@/hooks/use-im-websocket" +import { useIMClient } from "@/utils/im-client" +import type { AgentDescriptor } from "./types" + +interface GroupChatPanelProps { + groupID: string +} + +export function GroupChatPanel(props: GroupChatPanelProps) { + const client = useIMClient() + const [historicalMessages, setHistoricalMessages] = createSignal([]) + const [agents, setAgents] = createSignal([]) + const [loading, setLoading] = createSignal(true) + + const { connected, messages: realtimeMessages, agentStatuses, typingMembers, send } = useIMWebSocket( + client, + () => props.groupID, + ) + + // The single desktop user identity, as the server assigns it. Used to exclude + // our own echoed typing/read events from the UI. + const SELF_MEMBER_ID = "server" + + const othersTyping = () => Array.from(typingMembers()).filter((id) => id !== SELF_MEMBER_ID) + + // Load historical messages + createEffect(() => { + const groupID = props.groupID + setLoading(true) + client + .listMessages(groupID, 50) + .then((data) => { + // Server returns messages newest-first (desc). Reverse to ascending so + // they render oldest→newest in the list. + const historical: LocalMessage[] = (data.messages || []).slice().reverse() + setHistoricalMessages(historical) + setLoading(false) + }) + .catch((error) => { + console.error("Failed to load messages:", error) + setLoading(false) + }) + }) + + // Load available agents + onMount(() => { + client + .listAgents() + .then((data) => { + setAgents(data || []) + }) + .catch((error) => { + console.error("Failed to load agents:", error) + }) + }) + + // Merge historical and realtime messages, de-duplicating by id (a message the + // user sent appears both in the historical fetch and the realtime broadcast) + // and sorting ascending by creation time. + const allMessages = () => { + const byId = new Map() + for (const m of historicalMessages()) byId.set(m.id, m) + for (const m of realtimeMessages()) byId.set(m.id, m) + return Array.from(byId.values()).sort((a, b) => a.createdAt - b.createdAt) + } + + // Mark the group read whenever new messages land (and on first load). Sends + // both the HTTP mark-read (persisted last_read_at) and a realtime receipt. + createEffect(() => { + const msgs = allMessages() + if (loading() || msgs.length === 0) return + const readAt = Date.now() + client.markRead(props.groupID, readAt).catch((error) => { + console.error("Failed to mark read:", error) + }) + send({ type: "read_receipt", data: { groupID: props.groupID, memberID: SELF_MEMBER_ID, readAt } }) + }) + + const handleTyping = (typing: boolean) => { + send({ type: "typing", data: { groupID: props.groupID, memberID: SELF_MEMBER_ID, typing } }) + } + + const handleSendMessage = async (content: string) => { + // Send via HTTP API; the created message arrives back over the WebSocket. + try { + await client.createMessage(props.groupID, { content, type: "text" }) + } catch (error) { + console.error("Failed to send message:", error) + alert("Failed to send message") + } + } + + return ( + +
Loading messages...
+ + } + > +
+
+

Group Chat

+
+ {connected() ? "Connected" : "Disconnected"} +
+
+ + + + 0}> +
+ {othersTyping().length === 1 ? "Someone is typing…" : "Several people are typing…"} +
+
+ + +
+
+ ) +} diff --git a/packages/app/src/components/im/im-sidebar.tsx b/packages/app/src/components/im/im-sidebar.tsx new file mode 100644 index 00000000..527730c9 --- /dev/null +++ b/packages/app/src/components/im/im-sidebar.tsx @@ -0,0 +1,102 @@ +import { createSignal, createEffect, Show, For } from "solid-js" +import { useIMClient } from "@/utils/im-client" +import type { IMGroup } from "./types" + +interface IMSidebarProps { + selectedGroupID: string | null + onSelectGroup: (groupID: string) => void +} + +export function IMSidebar(props: IMSidebarProps) { + const client = useIMClient() + const [groups, setGroups] = createSignal([]) + const [loading, setLoading] = createSignal(true) + const [creating, setCreating] = createSignal(false) + + const loadGroups = () => { + setLoading(true) + client + .listGroups() + .then((data) => { + setGroups(data) + setLoading(false) + }) + .catch((error) => { + console.error("Failed to load groups:", error) + setLoading(false) + }) + } + + createEffect(() => { + loadGroups() + }) + + const handleCreateGroup = async () => { + const name = window.prompt("New group name")?.trim() + if (!name) return + setCreating(true) + try { + const group = await client.createGroup({ name, type: "project" }) + setGroups((prev) => [...prev, group]) + props.onSelectGroup(group.id) + } catch (error) { + console.error("Failed to create group:", error) + alert("Failed to create group") + } finally { + setCreating(false) + } + } + + return ( + +
Loading groups...
+ + } + > +
+
+

IM Groups

+ +
+ +
+ 0} + fallback={ +
No groups yet
+ } + > +
+ + {(group) => ( + + )} + +
+
+
+
+
+ ) +} diff --git a/packages/app/src/components/im/message-composer.tsx b/packages/app/src/components/im/message-composer.tsx new file mode 100644 index 00000000..ca12ac5d --- /dev/null +++ b/packages/app/src/components/im/message-composer.tsx @@ -0,0 +1,244 @@ +import { createSignal, createMemo, createEffect, onCleanup, Show, For } from "solid-js" +import type { AgentDescriptor } from "./types" + +interface MessageComposerProps { + onSend: (content: string) => void + onTyping?: (typing: boolean) => void + agents: AgentDescriptor[] + disabled?: boolean +} + +export function MessageComposer(props: MessageComposerProps) { + const [content, setContent] = createSignal("") + const [showMentionMenu, setShowMentionMenu] = createSignal(false) + const [mentionQuery, setMentionQuery] = createSignal("") + const [mentionPosition, setMentionPosition] = createSignal({ top: 0, left: 0 }) + const [selectedIndex, setSelectedIndex] = createSignal(0) + let textareaRef: HTMLTextAreaElement | undefined + let typingActive = false + let typingStopTimer: ReturnType | undefined + + // Emit a throttled typing signal: "true" on first keystroke, "false" after a + // short idle gap. Cleaned up on unmount. + const signalTyping = () => { + if (!props.onTyping) return + if (!typingActive) { + typingActive = true + props.onTyping(true) + } + if (typingStopTimer) clearTimeout(typingStopTimer) + typingStopTimer = setTimeout(() => { + typingActive = false + props.onTyping?.(false) + }, 3000) + } + + const stopTyping = () => { + if (typingStopTimer) clearTimeout(typingStopTimer) + if (typingActive) { + typingActive = false + props.onTyping?.(false) + } + } + + onCleanup(stopTyping) + + // Filter agents based on mention query + const filteredAgents = createMemo(() => { + const query = mentionQuery() + if (!query) return props.agents + const lowerQuery = query.toLowerCase() + return props.agents.filter((agent) => + agent.id.toLowerCase().includes(lowerQuery) || + agent.displayName?.toLowerCase().includes(lowerQuery) || + agent.description?.toLowerCase().includes(lowerQuery) + ) + }) + + // Detect @ mentions and show autocomplete + createEffect(() => { + const currentContent = content() + if (!textareaRef) return + + const cursorPos = textareaRef.selectionStart + const textBeforeCursor = currentContent.slice(0, cursorPos) + + // Check if we're in a mention context + const mentionMatch = textBeforeCursor.match(/@(\w*)$/) + + if (mentionMatch) { + setMentionQuery(mentionMatch[1]) + setShowMentionMenu(true) + setSelectedIndex(0) + + // Calculate position for the dropdown + const coords = getCaretCoordinates(textareaRef, cursorPos) + setMentionPosition({ + top: coords.top - textareaRef.scrollTop, + left: coords.left, + }) + } else { + setShowMentionMenu(false) + setMentionQuery("") + } + }) + + const handleKeyDown = (e: KeyboardEvent) => { + if (showMentionMenu() && filteredAgents().length > 0) { + if (e.key === "ArrowDown") { + e.preventDefault() + setSelectedIndex((prev) => (prev + 1) % filteredAgents().length) + return + } + if (e.key === "ArrowUp") { + e.preventDefault() + setSelectedIndex((prev) => (prev - 1 + filteredAgents().length) % filteredAgents().length) + return + } + if (e.key === "Tab" || e.key === "Enter") { + if (e.key === "Enter" && !e.shiftKey) { + // Only handle Tab and Shift+Enter for mention completion + e.preventDefault() + insertMention(filteredAgents()[selectedIndex()]) + return + } + if (e.key === "Tab") { + e.preventDefault() + insertMention(filteredAgents()[selectedIndex()]) + return + } + } + if (e.key === "Escape") { + e.preventDefault() + setShowMentionMenu(false) + return + } + } + + // Normal message sending (Enter without Shift) + if (e.key === "Enter" && !e.shiftKey && !showMentionMenu()) { + e.preventDefault() + if (content().trim() && !props.disabled) { + props.onSend(content()) + setContent("") + stopTyping() + } + } + } + + const insertMention = (agent: AgentDescriptor) => { + if (!textareaRef) return + + const cursorPos = textareaRef.selectionStart + const textBeforeCursor = content().slice(0, cursorPos) + const textAfterCursor = content().slice(cursorPos) + + // Remove the partial mention and insert the full one + const mentionMatch = textBeforeCursor.match(/@(\w*)$/) + if (mentionMatch) { + const beforeMention = textBeforeCursor.slice(0, mentionMatch.index) + const newContent = `${beforeMention}@${agent.id} ${textAfterCursor}` + setContent(newContent) + + // Set cursor position after the mention + setTimeout(() => { + if (!textareaRef) return + const newCursorPos = beforeMention.length + agent.id.length + 2 + textareaRef.setSelectionRange(newCursorPos, newCursorPos) + textareaRef.focus() + }, 0) + } + + setShowMentionMenu(false) + } + + return ( +
+