Skip to content

Commit ed14650

Browse files
Copilothotlong
andauthored
feat: add agent selector dropdown and GET /api/v1/ai/agents endpoint
Agent-Logs-Url: https://github.com/objectstack-ai/framework/sessions/cb9e5852-20ac-43d6-a26a-28a8829f5a45 Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent e310272 commit ed14650

5 files changed

Lines changed: 338 additions & 36 deletions

File tree

apps/studio/src/components/AiChatPanel.tsx

Lines changed: 139 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import { useState, useRef, useEffect, useMemo } from 'react';
3+
import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
44
import { useChat } from '@ai-sdk/react';
55
import { DefaultChatTransport } from 'ai';
66
import type { UIMessage } from 'ai';
@@ -10,6 +10,9 @@ import {
1010
} from 'lucide-react';
1111
import { Button } from '@/components/ui/button';
1212
import { ScrollArea } from '@/components/ui/scroll-area';
13+
import {
14+
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
15+
} from '@/components/ui/select';
1316
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
1417
import { cn } from '@/lib/utils';
1518
import { useAiChatPanel, loadMessages, saveMessages } from '@/hooks/use-ai-chat-panel';
@@ -18,6 +21,18 @@ import { getApiBaseUrl } from '@/lib/config';
1821
const PANEL_WIDTH = 380;
1922
const COLLAPSED_WIDTH = 48;
2023

24+
/** @internal — exported for testing */
25+
export const AGENT_STORAGE_KEY = 'objectstack:ai-chat-agent';
26+
/** @internal — exported for testing */
27+
export const GENERAL_CHAT_VALUE = '__general__';
28+
29+
/** Summary returned by GET /api/v1/ai/agents */
30+
interface AgentSummary {
31+
name: string;
32+
label: string;
33+
role: string;
34+
}
35+
2136
/**
2237
* Extract the text content from a UIMessage's parts array.
2338
*/
@@ -69,6 +84,70 @@ function formatToolOutput(output: unknown, maxLen = 80): string {
6984
return raw.length > maxLen ? raw.slice(0, maxLen) + '…' : raw;
7085
}
7186

87+
/**
88+
* Build the chat API URL for the given agent selection.
89+
* @internal — exported for testing
90+
*/
91+
export function chatApiUrl(baseUrl: string, agentName: string | null): string {
92+
if (!agentName || agentName === GENERAL_CHAT_VALUE) {
93+
return `${baseUrl}/api/v1/ai/chat`;
94+
}
95+
return `${baseUrl}/api/v1/ai/agents/${agentName}/chat`;
96+
}
97+
98+
/**
99+
* Load persisted agent selection from localStorage.
100+
* @internal — exported for testing
101+
*/
102+
export function loadSelectedAgent(): string {
103+
try {
104+
return localStorage.getItem(AGENT_STORAGE_KEY) ?? GENERAL_CHAT_VALUE;
105+
} catch {
106+
return GENERAL_CHAT_VALUE;
107+
}
108+
}
109+
110+
/**
111+
* Persist agent selection to localStorage.
112+
* @internal — exported for testing
113+
*/
114+
export function saveSelectedAgent(agent: string): void {
115+
try {
116+
localStorage.setItem(AGENT_STORAGE_KEY, agent);
117+
} catch {
118+
// silently ignore
119+
}
120+
}
121+
122+
/**
123+
* Hook to fetch the list of available agents from the server.
124+
*/
125+
function useAgentList(baseUrl: string) {
126+
const [agents, setAgents] = useState<AgentSummary[]>([]);
127+
const [loading, setLoading] = useState(true);
128+
129+
useEffect(() => {
130+
let cancelled = false;
131+
setLoading(true);
132+
133+
fetch(`${baseUrl}/api/v1/ai/agents`, { credentials: 'include' })
134+
.then((res) => (res.ok ? res.json() : { agents: [] }))
135+
.then((data: { agents?: AgentSummary[] }) => {
136+
if (!cancelled) setAgents(data.agents ?? []);
137+
})
138+
.catch(() => {
139+
if (!cancelled) setAgents([]);
140+
})
141+
.finally(() => {
142+
if (!cancelled) setLoading(false);
143+
});
144+
145+
return () => { cancelled = true; };
146+
}, [baseUrl]);
147+
148+
return { agents, loading };
149+
}
150+
72151
// ── Tool Invocation State Labels ────────────────────────────────────
73152

74153
interface ToolInvocationDisplayProps {
@@ -199,15 +278,17 @@ function ToolInvocationDisplay({ part, onApprove, onDeny }: ToolInvocationDispla
199278
export function AiChatPanel() {
200279
const { isOpen, setOpen, toggle } = useAiChatPanel();
201280
const [input, setInput] = useState('');
281+
const [selectedAgent, setSelectedAgent] = useState<string>(loadSelectedAgent);
202282
const scrollRef = useRef<HTMLDivElement>(null);
203283
const inputRef = useRef<HTMLTextAreaElement>(null);
204284
const baseUrl = getApiBaseUrl();
285+
const { agents, loading: agentsLoading } = useAgentList(baseUrl);
205286

206287
const initialMessages = useMemo(() => loadMessages() as UIMessage[], []);
207288

208289
const transport = useMemo(
209-
() => new DefaultChatTransport({ api: `${baseUrl}/api/v1/ai/chat` }),
210-
[baseUrl],
290+
() => new DefaultChatTransport({ api: chatApiUrl(baseUrl, selectedAgent) }),
291+
[baseUrl, selectedAgent],
211292
);
212293

213294
const { messages, sendMessage, setMessages, status, error, addToolApprovalResponse } = useChat({
@@ -243,6 +324,14 @@ export function AiChatPanel() {
243324
saveMessages([]);
244325
};
245326

327+
const handleAgentChange = useCallback((value: string) => {
328+
setSelectedAgent(value);
329+
saveSelectedAgent(value);
330+
// Clear conversation when switching agents to avoid context confusion
331+
setMessages([]);
332+
saveMessages([]);
333+
}, [setMessages]);
334+
246335
const handleSend = () => {
247336
const text = input.trim();
248337
if (!text || isStreaming) return;
@@ -300,27 +389,54 @@ export function AiChatPanel() {
300389
style={{ width: PANEL_WIDTH }}
301390
>
302391
{/* ── Header ── */}
303-
<div className="flex h-12 shrink-0 items-center justify-between border-b px-3">
304-
<div className="flex items-center gap-2 text-sm font-semibold">
305-
<Bot className="h-4 w-4 text-primary" />
306-
AI Chat
392+
<div className="shrink-0 border-b">
393+
<div className="flex h-12 items-center justify-between px-3">
394+
<div className="flex items-center gap-2 text-sm font-semibold">
395+
<Bot className="h-4 w-4 text-primary" />
396+
AI Chat
397+
</div>
398+
<div className="flex items-center gap-1">
399+
<TooltipProvider>
400+
<Tooltip>
401+
<TooltipTrigger asChild>
402+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={clearHistory}>
403+
<Trash2 className="h-3.5 w-3.5" />
404+
<span className="sr-only">Clear chat</span>
405+
</Button>
406+
</TooltipTrigger>
407+
<TooltipContent><p>Clear history</p></TooltipContent>
408+
</Tooltip>
409+
</TooltipProvider>
410+
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setOpen(false)}>
411+
<X className="h-4 w-4" />
412+
<span className="sr-only">Close</span>
413+
</Button>
414+
</div>
307415
</div>
308-
<div className="flex items-center gap-1">
309-
<TooltipProvider>
310-
<Tooltip>
311-
<TooltipTrigger asChild>
312-
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={clearHistory}>
313-
<Trash2 className="h-3.5 w-3.5" />
314-
<span className="sr-only">Clear chat</span>
315-
</Button>
316-
</TooltipTrigger>
317-
<TooltipContent><p>Clear history</p></TooltipContent>
318-
</Tooltip>
319-
</TooltipProvider>
320-
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => setOpen(false)}>
321-
<X className="h-4 w-4" />
322-
<span className="sr-only">Close</span>
323-
</Button>
416+
{/* ── Agent Selector ── */}
417+
<div className="px-3 pb-2">
418+
<Select
419+
value={selectedAgent}
420+
onValueChange={handleAgentChange}
421+
disabled={agentsLoading || isStreaming}
422+
>
423+
<SelectTrigger
424+
data-testid="agent-selector"
425+
className="h-8 text-xs"
426+
>
427+
<SelectValue placeholder="Select agent…" />
428+
</SelectTrigger>
429+
<SelectContent>
430+
<SelectItem value={GENERAL_CHAT_VALUE}>
431+
General Chat
432+
</SelectItem>
433+
{agents.map((a) => (
434+
<SelectItem key={a.name} value={a.name}>
435+
{a.label}
436+
</SelectItem>
437+
))}
438+
</SelectContent>
439+
</Select>
324440
</div>
325441
</div>
326442

apps/studio/test/ai-chat-panel.test.tsx

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,3 +199,77 @@ describe('Messages with tool invocation parts', () => {
199199
expect(toolPart).toBeDefined();
200200
});
201201
});
202+
203+
// ═══════════════════════════════════════════════════════════════════
204+
// Agent Selector
205+
// ═══════════════════════════════════════════════════════════════════
206+
207+
import {
208+
AGENT_STORAGE_KEY,
209+
GENERAL_CHAT_VALUE,
210+
chatApiUrl,
211+
loadSelectedAgent,
212+
saveSelectedAgent,
213+
} from '../src/components/AiChatPanel';
214+
215+
describe('Agent Selector', () => {
216+
beforeEach(() => {
217+
localStorage.clear();
218+
});
219+
220+
describe('chatApiUrl', () => {
221+
it('should return general chat URL when no agent selected', () => {
222+
expect(chatApiUrl('', null)).toBe('/api/v1/ai/chat');
223+
expect(chatApiUrl('', GENERAL_CHAT_VALUE)).toBe('/api/v1/ai/chat');
224+
});
225+
226+
it('should return agent-specific URL when agent selected', () => {
227+
expect(chatApiUrl('', 'metadata_assistant')).toBe('/api/v1/ai/agents/metadata_assistant/chat');
228+
expect(chatApiUrl('', 'data_chat')).toBe('/api/v1/ai/agents/data_chat/chat');
229+
});
230+
231+
it('should include baseUrl prefix', () => {
232+
expect(chatApiUrl('http://localhost:3000', 'metadata_assistant'))
233+
.toBe('http://localhost:3000/api/v1/ai/agents/metadata_assistant/chat');
234+
expect(chatApiUrl('http://localhost:3000', GENERAL_CHAT_VALUE))
235+
.toBe('http://localhost:3000/api/v1/ai/chat');
236+
});
237+
});
238+
239+
describe('loadSelectedAgent', () => {
240+
it('should return GENERAL_CHAT_VALUE when nothing stored', () => {
241+
expect(loadSelectedAgent()).toBe(GENERAL_CHAT_VALUE);
242+
});
243+
244+
it('should return stored agent name', () => {
245+
localStorage.setItem(AGENT_STORAGE_KEY, 'metadata_assistant');
246+
expect(loadSelectedAgent()).toBe('metadata_assistant');
247+
});
248+
});
249+
250+
describe('saveSelectedAgent', () => {
251+
it('should persist agent selection to localStorage', () => {
252+
saveSelectedAgent('metadata_assistant');
253+
expect(localStorage.getItem(AGENT_STORAGE_KEY)).toBe('metadata_assistant');
254+
});
255+
256+
it('should overwrite previous selection', () => {
257+
saveSelectedAgent('data_chat');
258+
saveSelectedAgent('metadata_assistant');
259+
expect(localStorage.getItem(AGENT_STORAGE_KEY)).toBe('metadata_assistant');
260+
});
261+
262+
it('should not throw when localStorage is unavailable', () => {
263+
const originalSetItem = Storage.prototype.setItem;
264+
Storage.prototype.setItem = () => { throw new Error('QuotaExceeded'); };
265+
expect(() => saveSelectedAgent('metadata_assistant')).not.toThrow();
266+
Storage.prototype.setItem = originalSetItem;
267+
});
268+
});
269+
270+
describe('AGENT_STORAGE_KEY', () => {
271+
it('should be a valid localStorage key', () => {
272+
expect(AGENT_STORAGE_KEY).toBe('objectstack:ai-chat-agent');
273+
});
274+
});
275+
});

0 commit comments

Comments
 (0)