From cd2d743b85de0d0fbe7fb6bfbb6a142c165e25c7 Mon Sep 17 00:00:00 2001 From: yuhp Date: Tue, 24 Feb 2026 16:57:21 +0800 Subject: [PATCH] feat: implement session agent selection --- apps/webclaw/src/routeTree.gen.ts | 21 ++++++++++ apps/webclaw/src/routes/api/agents.ts | 33 ++++++++++++++++ apps/webclaw/src/routes/api/sessions.ts | 21 ++++++---- apps/webclaw/src/screens/chat/chat-queries.ts | 26 +++++++++++++ apps/webclaw/src/screens/chat/chat-screen.tsx | 38 ++++++++++++++++++- .../screens/chat/components/chat-header.tsx | 31 ++++++++++++++- apps/webclaw/src/screens/chat/utils.ts | 13 +++++++ 7 files changed, 172 insertions(+), 11 deletions(-) create mode 100644 apps/webclaw/src/routes/api/agents.ts diff --git a/apps/webclaw/src/routeTree.gen.ts b/apps/webclaw/src/routeTree.gen.ts index 9fc7637..d2ba3d9 100644 --- a/apps/webclaw/src/routeTree.gen.ts +++ b/apps/webclaw/src/routeTree.gen.ts @@ -19,6 +19,7 @@ import { Route as ApiSendRouteImport } from './routes/api/send' import { Route as ApiPingRouteImport } from './routes/api/ping' import { Route as ApiPathsRouteImport } from './routes/api/paths' import { Route as ApiHistoryRouteImport } from './routes/api/history' +import { Route as ApiAgentsRouteImport } from './routes/api/agents' const NewRoute = NewRouteImport.update({ id: '/new', @@ -70,11 +71,17 @@ const ApiHistoryRoute = ApiHistoryRouteImport.update({ path: '/api/history', getParentRoute: () => rootRouteImport, } as any) +const ApiAgentsRoute = ApiAgentsRouteImport.update({ + id: '/api/agents', + path: '/api/agents', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/agents': typeof ApiAgentsRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute @@ -87,6 +94,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/agents': typeof ApiAgentsRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute @@ -100,6 +108,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/connect': typeof ConnectRoute '/new': typeof NewRoute + '/api/agents': typeof ApiAgentsRoute '/api/history': typeof ApiHistoryRoute '/api/paths': typeof ApiPathsRoute '/api/ping': typeof ApiPingRoute @@ -114,6 +123,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/agents' | '/api/history' | '/api/paths' | '/api/ping' @@ -126,6 +136,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/agents' | '/api/history' | '/api/paths' | '/api/ping' @@ -138,6 +149,7 @@ export interface FileRouteTypes { | '/' | '/connect' | '/new' + | '/api/agents' | '/api/history' | '/api/paths' | '/api/ping' @@ -151,6 +163,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute ConnectRoute: typeof ConnectRoute NewRoute: typeof NewRoute + ApiAgentsRoute: typeof ApiAgentsRoute ApiHistoryRoute: typeof ApiHistoryRoute ApiPathsRoute: typeof ApiPathsRoute ApiPingRoute: typeof ApiPingRoute @@ -232,6 +245,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHistoryRouteImport parentRoute: typeof rootRouteImport } + '/api/agents': { + id: '/api/agents' + path: '/api/agents' + fullPath: '/api/agents' + preLoaderRoute: typeof ApiAgentsRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -239,6 +259,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, ConnectRoute: ConnectRoute, NewRoute: NewRoute, + ApiAgentsRoute: ApiAgentsRoute, ApiHistoryRoute: ApiHistoryRoute, ApiPathsRoute: ApiPathsRoute, ApiPingRoute: ApiPingRoute, diff --git a/apps/webclaw/src/routes/api/agents.ts b/apps/webclaw/src/routes/api/agents.ts new file mode 100644 index 0000000..1ce17ed --- /dev/null +++ b/apps/webclaw/src/routes/api/agents.ts @@ -0,0 +1,33 @@ +import { createFileRoute } from '@tanstack/react-router' +import { json } from '@tanstack/react-start' +import { gatewayRpc } from '../../server/gateway' + +type AgentsListGatewayResponse = { + agents?: Array> +} + +export const Route = createFileRoute('/api/agents')({ + server: { + handlers: { + GET: async () => { + try { + const payload = await gatewayRpc( + 'agents.list', + {}, + ) + const agents = Array.isArray(payload.agents) ? payload.agents : [] + const defaultAgentId = + (process.env.CLAWDBOT_AGENT_ID || 'main').trim() || 'main' + return json({ agents, defaultAgentId }) + } catch (err) { + return json( + { + error: err instanceof Error ? err.message : String(err), + }, + { status: 500 }, + ) + } + }, + }, + }, +}) diff --git a/apps/webclaw/src/routes/api/sessions.ts b/apps/webclaw/src/routes/api/sessions.ts index ddc04bf..d8623a4 100644 --- a/apps/webclaw/src/routes/api/sessions.ts +++ b/apps/webclaw/src/routes/api/sessions.ts @@ -93,9 +93,14 @@ export const Route = createFileRoute('/api/sessions')({ typeof body.label === 'string' ? body.label.trim() : '' const label = requestedLabel || undefined + const requestedAgentId = + typeof body.agentId === 'string' ? body.agentId.trim() : '' + const agentId = requestedAgentId || 'main' + const friendlyId = randomUUID() + const sessionKey = `agent:${agentId}:${friendlyId}` - const params: Record = { key: friendlyId } + const params: Record = { key: sessionKey } if (label) params.label = label const payload = await gatewayRpc( @@ -103,12 +108,12 @@ export const Route = createFileRoute('/api/sessions')({ params, ) - const sessionKeyRaw = payload.key - const sessionKey = - typeof sessionKeyRaw === 'string' && sessionKeyRaw.trim().length > 0 - ? sessionKeyRaw.trim() - : '' - if (sessionKey.length === 0) { + const responseKey = payload.key + const resolvedSessionKey = + typeof responseKey === 'string' && responseKey.trim().length > 0 + ? responseKey.trim() + : sessionKey + if (resolvedSessionKey.length === 0) { throw new Error('gateway returned an invalid response') } @@ -121,7 +126,7 @@ export const Route = createFileRoute('/api/sessions')({ return json({ ok: true, - sessionKey, + sessionKey: resolvedSessionKey, friendlyId, entry: payload.entry, }) diff --git a/apps/webclaw/src/screens/chat/chat-queries.ts b/apps/webclaw/src/screens/chat/chat-queries.ts index 373c2b1..61282fa 100644 --- a/apps/webclaw/src/screens/chat/chat-queries.ts +++ b/apps/webclaw/src/screens/chat/chat-queries.ts @@ -14,6 +14,7 @@ type GatewayStatusResponse = { export const chatQueryKeys = { sessions: ['chat', 'sessions'] as const, + agents: ['chat', 'agents'] as const, history: function history(friendlyId: string, sessionKey: string) { return ['chat', 'history', friendlyId, sessionKey] as const }, @@ -56,6 +57,31 @@ export async function fetchGatewayStatus(): Promise { } } +type AgentsResult = { + agents: Array<{ id: string; name: string }> + defaultAgentId: string +} + +export async function fetchAgents(): Promise { + const res = await fetch('/api/agents') + if (!res.ok) throw new Error(await readError(res)) + const data = await res.json() as { + agents?: Array<{ id: string; name?: string }> + defaultAgentId?: string + } + const agents = Array.isArray(data.agents) + ? data.agents.map((agent) => ({ + id: agent.id, + name: agent.name || agent.id, + })) + : [] + const defaultAgentId = + typeof data.defaultAgentId === 'string' && data.defaultAgentId.trim().length > 0 + ? data.defaultAgentId.trim() + : 'main' + return { agents, defaultAgentId } +} + export function updateHistoryMessages( queryClient: QueryClient, friendlyId: string, diff --git a/apps/webclaw/src/screens/chat/chat-screen.tsx b/apps/webclaw/src/screens/chat/chat-screen.tsx index 16dafe4..b2cf540 100644 --- a/apps/webclaw/src/screens/chat/chat-screen.tsx +++ b/apps/webclaw/src/screens/chat/chat-screen.tsx @@ -3,6 +3,7 @@ import { Navigate, useNavigate } from '@tanstack/react-router' import { useQuery, useQueryClient } from '@tanstack/react-query' import { + deriveAgentIdFromKey, deriveFriendlyIdFromKey, isMissingGatewayAuth, isSessionNotFound, @@ -13,6 +14,7 @@ import { appendHistoryMessage, chatQueryKeys, clearHistoryMessages, + fetchAgents, fetchGatewayStatus, removeHistoryMessageByClientId, updateHistoryMessageByClientId, @@ -68,6 +70,7 @@ export function ChatScreen({ const [sending, setSending] = useState(false) const [creatingSession, setCreatingSession] = useState(false) const [isRedirecting, setIsRedirecting] = useState(false) + const [selectedAgentId, setSelectedAgentId] = useState('main') const { headerRef, composerRef, mainRef, pinGroupMinHeight, headerHeight } = useChatMeasurements() const [waitingForResponse, setWaitingForResponse] = useState( @@ -123,6 +126,33 @@ export function ChatScreen({ }, staleTime: Infinity, }) + + const agentsQuery = useQuery({ + queryKey: chatQueryKeys.agents, + queryFn: fetchAgents, + staleTime: 5 * 60 * 1000, + }) + + // Set the correct agent ID for the current context + const currentAgentId = useMemo(() => { + if (isNewChat) return selectedAgentId + return deriveAgentIdFromKey(activeCanonicalKey) || 'main' + }, [isNewChat, selectedAgentId, activeCanonicalKey]) + + const defaultAgentIdAppliedRef = useRef(false) + useEffect(() => { + if (defaultAgentIdAppliedRef.current) return + const data = agentsQuery.data + if (!data) return + defaultAgentIdAppliedRef.current = true + const { agents, defaultAgentId } = data + const matched = agents.some((agent) => agent.id === defaultAgentId) + if (matched) { + setSelectedAgentId(defaultAgentId) + } else if (agents.length > 0) { + setSelectedAgentId(agents[0].id) + } + }, [agentsQuery.data]) const gatewayStatusQuery = useQuery({ queryKey: ['gateway', 'status'], queryFn: fetchGatewayStatus, @@ -318,7 +348,7 @@ export function ChatScreen({ const res = await fetch('/api/sessions', { method: 'POST', headers: { 'content-type': 'application/json' }, - body: JSON.stringify({}), + body: JSON.stringify({ agentId: selectedAgentId }), }) if (!res.ok) throw new Error(await readError(res)) @@ -343,7 +373,7 @@ export function ChatScreen({ } finally { setCreatingSession(false) } - }, [queryClient]) + }, [queryClient, selectedAgentId]) const send = useCallback( (body: string, helpers: ChatComposerHelpers) => { @@ -596,6 +626,10 @@ export function ChatScreen({ showExport={!isNewChat} usedTokens={activeSession?.totalTokens} maxTokens={activeSession?.contextTokens} + isNewChat={isNewChat} + agents={agentsQuery.data?.agents} + selectedAgentId={currentAgentId} + onSelectAgent={setSelectedAgentId} /> {hideUi ? null : ( diff --git a/apps/webclaw/src/screens/chat/components/chat-header.tsx b/apps/webclaw/src/screens/chat/components/chat-header.tsx index 9227039..deae477 100644 --- a/apps/webclaw/src/screens/chat/components/chat-header.tsx +++ b/apps/webclaw/src/screens/chat/components/chat-header.tsx @@ -17,6 +17,10 @@ type ChatHeaderProps = { onExport: (format: ExportFormat) => void exportDisabled?: boolean showExport?: boolean + isNewChat?: boolean + agents?: Array<{ id: string; name: string }> + selectedAgentId?: string + onSelectAgent?: (id: string) => void } function ChatHeaderComponent({ @@ -29,6 +33,10 @@ function ChatHeaderComponent({ onExport, exportDisabled = false, showExport = true, + isNewChat = false, + agents = [], + selectedAgentId, + onSelectAgent, }: ChatHeaderProps) { return (
) : null} -
+
{activeTitle} + {isNewChat && agents.length > 0 ? ( + + ) : !isNewChat && selectedAgentId ? ( + + Agent: {agents.find((a) => a.id === selectedAgentId)?.name || selectedAgentId} + + ) : null}
{showExport ? ( diff --git a/apps/webclaw/src/screens/chat/utils.ts b/apps/webclaw/src/screens/chat/utils.ts index 7fec5d4..8f4f86b 100644 --- a/apps/webclaw/src/screens/chat/utils.ts +++ b/apps/webclaw/src/screens/chat/utils.ts @@ -15,6 +15,19 @@ export function deriveFriendlyIdFromKey(key: string | undefined): string { return tailTrimmed.length > 0 ? tailTrimmed : trimmed } +export function deriveAgentIdFromKey(key: string | undefined): string | null { + if (!key) return null + const trimmed = key.trim() + if (trimmed.length === 0) return null + const parts = trimmed.split(':') + // format is typically agent:: + if (parts.length >= 3 && parts[0] === 'agent') { + const agentId = parts[1] + return agentId && agentId.trim().length > 0 ? agentId.trim() : null + } + return null +} + export function textFromMessage(msg: GatewayMessage): string { const parts = Array.isArray(msg.content) ? msg.content : [] return parts