Browse Source

feat: add agent testing pages

Thomas Zhang 2 months ago
parent
commit
dbd04691ca

+ 32 - 1
src/lib/api/agents.ts

@@ -6,6 +6,12 @@ import type { Changes, CoreResources } from '$lib/types/workspace';
 
 import { handleResponse } from './types';
 
+type ChatMessage = { role: 'system' | 'user' | 'assistant'; content: string };
+
+type ChatCompletionRequest = {
+	messages: ChatMessage[];
+};
+
 const list = (workspaceId: string, fetch = globalThis.fetch): Promise<Agent[]> =>
 	fetch(
 		resolve('/api/workspaces/[workspaceId]/[resource=resource]', {
@@ -99,4 +105,29 @@ const remove = (workspaceId: string, id: string, fetch = globalThis.fetch): Prom
 		if (!res.ok) await handleResponse<void>(res);
 	});
 
-export { create, get, getChanges, getResources, getStatus, list, remove, update };
+const testChatCompletion = (
+	workspaceId: string,
+	id: string,
+	request: ChatCompletionRequest,
+	signal: AbortSignal,
+	fetch = globalThis.fetch,
+): Promise<Response> =>
+	fetch(resolve('/api/workspaces/[workspaceId]/agents/[id]/test', { workspaceId, id }), {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify(request),
+		signal,
+	});
+
+export type { ChatCompletionRequest, ChatMessage };
+export {
+	create,
+	get,
+	getChanges,
+	getResources,
+	getStatus,
+	list,
+	remove,
+	testChatCompletion,
+	update,
+};

+ 208 - 0
src/lib/components/agents/testing/chat.svelte

@@ -0,0 +1,208 @@
+<script lang="ts">
+	import { MessageSquareOff, Send, Square } from '@lucide/svelte';
+
+	import * as agentsApi from '$lib/api/agents';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Button } from '$lib/components/controls/button';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import type { Agent } from '$lib/types/entities';
+
+	let { workspaceId, agent }: { workspaceId: string; agent: Agent } = $props();
+
+	let systemPrompt = $state('');
+	let messages = $state<agentsApi.ChatMessage[]>([]);
+	let inputText = $state('');
+	let chatLoading = $state(false);
+	let chatError = $state<string | null>(null);
+	let chatAbortController = $state<AbortController | null>(null);
+	let threadEndEl = $state<HTMLDivElement | undefined>(undefined);
+
+	$effect(() => {
+		messages = [];
+		chatError = null;
+	});
+
+	$effect(() => {
+		// Access every message's content so this re-runs during streaming.
+		for (const m of messages) void m.content;
+		threadEndEl?.scrollIntoView({ block: 'nearest' });
+	});
+
+	const sendChat = async () => {
+		if (!agent || !inputText.trim() || chatLoading) return;
+
+		const userText = inputText.trim();
+		inputText = '';
+		chatError = null;
+
+		const outMessages: agentsApi.ChatMessage[] = [
+			...(systemPrompt.trim() ? [{ role: 'system' as const, content: systemPrompt.trim() }] : []),
+			...messages,
+			{ role: 'user', content: userText },
+		];
+
+		messages.push({ role: 'user', content: userText });
+		const assistantIdx = messages.length;
+		messages.push({ role: 'assistant', content: '' });
+
+		chatLoading = true;
+		const ctrl = new AbortController();
+		chatAbortController = ctrl;
+
+		try {
+			const res = await agentsApi.testChatCompletion(
+				workspaceId,
+				agent.id,
+				{
+					messages: outMessages,
+				},
+				ctrl.signal,
+			);
+
+			if (!res.ok) {
+				const errBody = (await res.json().catch(() => ({ message: res.statusText }))) as {
+					message?: string;
+				};
+				throw new Error(errBody.message ?? res.statusText);
+			}
+
+			const reader = res.body!.getReader();
+			const decoder = new TextDecoder();
+			let buf = '';
+
+			for (;;) {
+				const { done, value } = await reader.read();
+				if (done) break;
+				buf += decoder.decode(value, { stream: true });
+				const lines = buf.split('\n');
+				buf = lines.pop() ?? '';
+
+				for (const line of lines) {
+					if (!line.startsWith('data: ')) continue;
+					const chunk = line.slice(6).trim();
+					if (chunk === '[DONE]') continue;
+					try {
+						const parsed = JSON.parse(chunk) as {
+							choices?: [{ delta?: { content?: string } }];
+						};
+						const delta = parsed.choices?.[0]?.delta?.content ?? '';
+						if (delta) messages[assistantIdx].content += delta;
+					} catch {
+						// skip malformed chunk
+					}
+				}
+			}
+		} catch (err) {
+			if (err instanceof Error && err.name === 'AbortError') return;
+			messages.splice(assistantIdx, 1);
+			chatError = err instanceof Error ? err.message : 'An error occurred';
+		} finally {
+			chatLoading = false;
+			chatAbortController = null;
+		}
+	};
+
+	const stopChat = () => {
+		chatAbortController?.abort();
+	};
+
+	const clearSession = () => {
+		messages = [];
+		systemPrompt = '';
+		chatError = null;
+	};
+
+	const onChatKeydown = (e: KeyboardEvent) => {
+		if (e.key === 'Enter' && !e.shiftKey) {
+			e.preventDefault();
+			sendChat();
+		}
+	};
+</script>
+
+<GlassPane class="overflow-hidden bg-black/25">
+	<div class="bg-white/5 p-4">
+		<div class="flex items-center justify-between">
+			<p class="text-lg font-thin">Chat</p>
+			<Button
+				class="text-white/50"
+				size="sm"
+				title="Clear session"
+				variant="ghost"
+				onclick={() => clearSession()}
+			>
+				<MessageSquareOff size={18} strokeWidth={2} />
+			</Button>
+		</div>
+	</div>
+
+	<!-- Message thread -->
+	<div class="h-96 overflow-y-auto rounded-lg p-4">
+		{#each messages as msg, i (i)}
+			{#if msg.role === 'user'}
+				<div class="mb-3 flex justify-end">
+					<div
+						class="max-w-[80%] rounded-2xl rounded-tr-sm bg-primary/40 px-4 py-2 text-sm text-white/90"
+					>
+						{msg.content}
+					</div>
+				</div>
+			{:else if msg.role === 'assistant'}
+				<div class="mb-3 flex justify-start">
+					<div
+						class="max-w-[80%] rounded-2xl rounded-tl-sm border border-white/10 bg-white/5 px-4 py-2 text-sm text-white/80"
+					>
+						{#if msg.content}
+							<pre class="font-sans whitespace-pre-wrap">{msg.content}</pre>
+						{:else}
+							<span class="animate-pulse text-white/30">▋</span>
+						{/if}
+					</div>
+				</div>
+			{/if}
+		{:else}
+			<div class="flex h-full items-center justify-center">
+				<p class="text-sm text-white/50">Send a message to start the conversation.</p>
+			</div>
+		{/each}
+
+		{#if chatError}
+			<div class="mt-2 rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-300">
+				{chatError}
+			</div>
+		{/if}
+
+		<div bind:this={threadEndEl}></div>
+	</div>
+
+	<div class="shrink-0 bg-white/5 px-4 pt-4 pb-2">
+		<Textarea
+			class="max-h-40 min-h-16 border-white/10 bg-white/5 pr-10 text-sm text-white/90 placeholder:text-white/30"
+			rows={2}
+			bind:value={inputText}
+			onkeydown={(e) => onChatKeydown(e)}
+		/>
+		<div class="mt-2 flex items-start justify-between">
+			<p class="truncate text-xs text-white/30">Enter to send, Shift+Enter for newline.</p>
+			<Button
+				disabled={inputText.trim().length === 0}
+				size="icon"
+				title={chatLoading ? 'Stop' : 'Send'}
+				variant="ghost"
+				onclick={() => {
+					if (chatLoading) {
+						stopChat();
+					} else {
+						sendChat();
+					}
+				}}
+			>
+				{#if chatLoading}
+					<Square size={18} strokeWidth={2} />
+				{:else}
+					<Send class="text-primary" size={18} strokeWidth={2} />
+				{/if}
+			</Button>
+		</div>
+	</div>
+</GlassPane>

+ 3 - 0
src/lib/components/agents/testing/index.ts

@@ -0,0 +1,3 @@
+import ChatTest from './chat.svelte';
+
+export { ChatTest };

+ 39 - 0
src/routes/api/workspaces/[workspaceId]/agents/[id]/test/+server.ts

@@ -0,0 +1,39 @@
+import type { ChatCompletionRequest } from '$lib/api/agents';
+import { NotFoundError, server } from '$lib/server';
+
+import type { RequestHandler } from './$types';
+
+const POST: RequestHandler = async ({ locals, params, request }) => {
+	const workspaceId = locals.workspaceId || '';
+	const agents = await server.catalog.getWorkspaceCatalog(workspaceId)?.agents.list();
+	const agent = agents?.find((m) => m.id === params.id);
+	if (!agent) {
+		throw new NotFoundError('Agent not found');
+	}
+	const input: ChatCompletionRequest = await request.json();
+	const { messages } = input;
+
+	const gatewayRes = await server.gateway.fetch('/v1/chat/completions', {
+		method: 'POST',
+		headers: { 'Content-Type': 'application/json' },
+		body: JSON.stringify({ messages, stream: true }),
+	});
+
+	if (!gatewayRes.ok) {
+		const errText = await gatewayRes.text();
+		return new Response(errText, {
+			status: gatewayRes.status,
+			headers: { 'Content-Type': 'application/json' },
+		});
+	}
+
+	return new Response(gatewayRes.body, {
+		headers: {
+			'Content-Type': 'text/event-stream',
+			'Cache-Control': 'no-cache',
+			'X-Accel-Buffering': 'no',
+		},
+	});
+};
+
+export { POST };

+ 13 - 0
src/routes/utilities/testing/agent/+page.server.ts

@@ -0,0 +1,13 @@
+import * as agentsApi from '$lib/api/agents';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, url, locals }) => {
+	const { workspaceId } = locals;
+	if (!workspaceId) return { agents: [], selectedAgentId: null };
+	const agents = await agentsApi.list(workspaceId, fetch);
+	const selectedAgentId = url.searchParams.get('agent');
+	return { agents, selectedAgentId };
+};
+
+export { load };

+ 72 - 0
src/routes/utilities/testing/agent/+page.svelte

@@ -0,0 +1,72 @@
+<script lang="ts">
+	import { BotMessageSquare } from '@lucide/svelte';
+
+	import { resolve } from '$app/paths';
+
+	import { AgentSelectDialog } from '$lib/components/agents/select';
+	import { ChatTest } from '$lib/components/agents/testing';
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import type { Agent } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Agents',
+			pages: [{ name: 'Chat', href: resolve('/utilities/testing/agent') }],
+		});
+	});
+
+	let agentSelectionShown = $state(false);
+	let selectedAgent = $state<Agent | undefined>(undefined);
+
+	$effect.root(() => {
+		if (data.selectedAgentId) {
+			const found = data.agents.find((m) => m.id === data.selectedAgentId);
+			if (found) selectedAgent = found;
+		}
+	});
+
+	const onselect = (agents: Agent[]): Agent[] => {
+		selectedAgent = agents[0];
+		agentSelectionShown = false;
+		return [];
+	};
+</script>
+
+<svelte:head>
+	<title>Agent Chat - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-4xl p-6">
+	<div class="mb-4 flex items-center gap-2 text-xl font-semibold text-white/90">
+		<BotMessageSquare opacity={0.5} size={20} strokeWidth={3} />
+		Agent Chat
+	</div>
+	<div class="mb-4">
+		<Button size="sm" variant="outline" onclick={() => (agentSelectionShown = true)}>
+			{#if selectedAgent}
+				<Icon icon={selectedAgent.icon} size={14} />
+				<div>{selectedAgent.name}</div>
+			{:else}
+				Select agent...
+			{/if}
+		</Button>
+	</div>
+
+	{#if !selectedAgent}
+		<GlassPane class="flex items-center justify-center bg-black/50 p-16">
+			<p class="text-sm text-white/30">Select a model above to begin testing.</p>
+		</GlassPane>
+	{:else}
+		<ChatTest agent={selectedAgent} workspaceId={data.workspaceId!} />
+	{/if}
+</div>
+
+<AgentSelectDialog workspaceId={data.workspaceId!} bind:open={agentSelectionShown} {onselect} />