ソースを参照

feat: add agent pages

Thomas Zhang 3 ヶ月 前
コミット
0706035371

+ 100 - 0
src/lib/components/agents/card/card.svelte

@@ -0,0 +1,100 @@
+<script lang="ts">
+	import { LibraryBig, Toolbox } from '@lucide/svelte';
+	import type { ClassValue } from 'svelte/elements';
+
+	import { resolve } from '$app/paths';
+
+	import { GlassPane } from '$lib/components/common/glass-pane';
+	import { Icon } from '$lib/components/common/icon';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Tags } from '$lib/components/common/tags';
+	import type { Agent } from '$lib/types/entities';
+
+	let {
+		class: className = '',
+		agent,
+		selectedTags,
+		onselect,
+	}: {
+		class?: ClassValue;
+		agent: Agent;
+		selectedTags?: Set<string>;
+		onselect?: () => void;
+	} = $props();
+</script>
+
+{#snippet content()}
+	<GlassPane
+		class={[
+			'relative flex h-64 flex-col gap-2 overflow-hidden rounded-xl p-4 hover:border-white/20 hover:bg-white/5',
+			className,
+		]}
+		reactive
+	>
+		<!-- header -->
+		<div class="flex shrink-0 items-center gap-2">
+			<Icon icon={agent.icon} size={36} />
+			<div
+				class="text-md line-clamp-2 flex-1 truncate leading-none font-medium text-wrap text-white/75 group-hover:text-white"
+			>
+				{agent.name}
+			</div>
+			{#if agent.status}
+				<StatusBadge class="shrink-0" status={agent.status} />
+			{/if}
+		</div>
+
+		{#if agent.description}
+			<div class="line-clamp-3 shrink-0 text-sm text-white/50">
+				{agent.description}
+			</div>
+		{:else}
+			<div class="shrink-0 text-xs text-white/25">&lt;no description&gt;</div>
+		{/if}
+
+		<div class="mb-2 overflow-hidden">
+			{#if agent.tags.length > 0}
+				<Tags activeTags={selectedTags} tags={agent.tags} />
+			{/if}
+		</div>
+
+		<div class="mt-auto flex shrink-0 flex-col gap-2">
+			{#if agent.kbs.length > 0}
+				{@const visible = agent.kbs.slice(0, 2)}
+				{@const overflow = agent.kbs.length - visible.length}
+				<div class="flex items-center gap-1 text-xs text-white/50">
+					<LibraryBig class="shrink-0 text-primary/75" size={16} strokeWidth={2} />
+					<span class="truncate">
+						{visible.map((kb) => kb.name).join(', ')}{overflow > 0 ? ` +${overflow}` : ''}
+					</span>
+				</div>
+			{/if}
+			{#if agent.tools.length > 0}
+				{@const visible = agent.tools.slice(0, 2)}
+				{@const overflow = agent.tools.length - visible.length}
+				<div class="flex items-center gap-1 text-xs text-white/50">
+					<Toolbox class="shrink-0 text-primary/75" size={16} strokeWidth={2} />
+					<span class="truncate">
+						{visible.map((t) => t.name).join(', ')}{overflow > 0 ? ` +${overflow}` : ''}
+					</span>
+				</div>
+			{/if}
+		</div>
+	</GlassPane>
+{/snippet}
+
+{#if onselect === undefined}
+	<a class="group block" href={resolve('/workloads/agents/[id]', { id: agent.id })}>
+		{@render content()}
+	</a>
+{:else}
+	<div
+		class="group block"
+		role="button"
+		tabindex="0"
+		onclick={() => onselect()}
+		onkeydown={(e) => e.key === 'Enter' && onselect()}
+	>
+		{@render content()}
+	</div>
+{/if}

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

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

+ 234 - 0
src/lib/components/agents/form/form.svelte

@@ -0,0 +1,234 @@
+<script lang="ts">
+	import { Card } from '$lib/components/common/card';
+	import { Icon } from '$lib/components/common/icon';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import { ArrayInput } from '$lib/components/forms/array-input';
+	import { DeploymentForm } from '$lib/components/forms/deployment';
+	import { IconPicker } from '$lib/components/forms/icon-picker';
+	import { Optional } from '$lib/components/forms/optional';
+	import { KBSelectDialog } from '$lib/components/kbs/select';
+	import { ModelSelectDialog } from '$lib/components/models/select';
+	import { ToolSelectDialog } from '$lib/components/tools/select';
+	import type { KnowledgeBase, Model, Tool } from '$lib/types/entities';
+
+	import { type AgentFormValue, DEFAULT_AGENT_FORM_VALUE } from './types';
+
+	let {
+		workspaceId,
+		disabled = false,
+		submitting = false,
+		value,
+		cancelHref,
+		onsubmit,
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		submitting?: boolean;
+		value?: AgentFormValue;
+		cancelHref: string;
+		onsubmit: (value: AgentFormValue) => void;
+	} = $props();
+
+	const isEdit = $derived(value !== undefined);
+	let form = $state<AgentFormValue>({ ...DEFAULT_AGENT_FORM_VALUE });
+	let errors = $state<{
+		name?: string;
+	}>({});
+	let modelSelectionShown = $state(false);
+	let kbSelectionShown = $state(false);
+	let toolSelectionShown = $state(false);
+
+	const initForm = (value?: AgentFormValue) => {
+		if (!value) {
+			form = { ...DEFAULT_AGENT_FORM_VALUE };
+		} else {
+			form = {
+				...$state.snapshot(value),
+			};
+		}
+	};
+
+	$effect(() => initForm(value));
+
+	const onModelSelect = (model: Model) => {
+		form.model = { id: model.id, type: model.mode, icon: model.icon, name: model.name };
+		modelSelectionShown = false;
+	};
+
+	const onKbSelect = (kbs: KnowledgeBase[]): KnowledgeBase[] => {
+		form.kbs = kbs.map((kb) => ({ id: kb.id, type: kb.mode, icon: kb.icon, name: kb.name }));
+		return kbs;
+	};
+
+	const onToolSelect = (tools: Tool[]): Tool[] => {
+		form.tools = tools.map((tool) => ({
+			id: tool.id,
+			type: tool.mode,
+			icon: tool.icon,
+			name: tool.name,
+		}));
+		return tools;
+	};
+
+	const handleSubmit = async () => {
+		onsubmit(form);
+	};
+</script>
+
+{#snippet panel()}
+	<div class="flex items-center gap-2">
+		<Button href={cancelHref} size="sm" variant="outline">Cancel</Button>
+		<Button disabled={submitting} size="sm" onclick={handleSubmit}>
+			{#if submitting}
+				<Spinner />
+			{/if}
+			{isEdit ? 'Save Changes' : 'Create'}
+		</Button>
+	</div>
+{/snippet}
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div>
+			<h1 class="text-xl font-semibold text-white/90">
+				{isEdit ? 'Edit Agent' : 'New Agent'}
+			</h1>
+		</div>
+		{@render panel()}
+	</div>
+
+	<!-- Basic Information panel (shared) -->
+	<Card class="mb-4" title="Basic Information">
+		<div class="mb-4 flex gap-8">
+			<!-- Icon -->
+			<div class="shrink-0">
+				<IconPicker bind:value={form.icon} />
+			</div>
+			<!-- Name -->
+			<div class="min-w-0 flex-1 space-y-4">
+				<Field.Set>
+					<Field.Field>
+						<Field.Label for="name">Name</Field.Label>
+						<Input id="name" maxlength={64} bind:value={form.name} />
+						<Field.Error>{errors.name}</Field.Error>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="description">Description</Field.Label>
+						<Textarea
+							id="description"
+							placeholder="What does this tool do?"
+							rows={3}
+							bind:value={form.description}
+						/>
+						<Field.Description>
+							Provide a brief description that can also help AI assistant to understand the agent's
+							functionality.
+						</Field.Description>
+					</Field.Field>
+				</Field.Set>
+			</div>
+		</div>
+		<Optional title="Tags">
+			<Field.Field>
+				<ArrayInput {disabled} bind:value={form.tags} />
+				<Field.Description>
+					Tags can be used for filtering and searching in the list view.
+				</Field.Description>
+			</Field.Field>
+		</Optional>
+	</Card>
+
+	<Card class="mb-4" title="Configuration">
+		<Field.Set>
+			<Field.Field>
+				<Field.Label for="systemPrompt">System Prompt</Field.Label>
+				<Textarea
+					id="systemPrompt"
+					placeholder="You are a helpful assistant..."
+					rows={5}
+					bind:value={form.systemPrompt}
+				/>
+			</Field.Field>
+			<Field.Field>
+				<Field.Label>Language Model</Field.Label>
+				<Field.Content>
+					<div class="flex flex-wrap items-center gap-2">
+						<Button size="sm" variant="outline" onclick={() => (modelSelectionShown = true)}>
+							{#if form.model}
+								<Icon icon={form.model.icon} size={14} />
+								<div>{form.model.name}</div>
+							{:else}
+								Select model...
+							{/if}
+						</Button>
+					</div>
+				</Field.Content>
+			</Field.Field>
+			<Field.Field>
+				<Field.Label>Tools</Field.Label>
+				<Field.Content>
+					<div class="flex flex-wrap items-center gap-2">
+						{#each form.tools as tool (tool.id)}
+							<Button size="sm" variant="outline" onclick={() => (toolSelectionShown = true)}>
+								<Icon icon={tool.icon} size={14} />
+								<div>{tool.name}</div>
+							</Button>
+						{:else}
+							<Button size="sm" variant="outline" onclick={() => (toolSelectionShown = true)}>
+								Select tools...
+							</Button>
+						{/each}
+					</div>
+				</Field.Content>
+			</Field.Field>
+			<Field.Field>
+				<Field.Label>Knowledge Bases</Field.Label>
+				<Field.Content>
+					<div class="flex flex-wrap items-center gap-2">
+						{#each form.kbs as kb (kb.id)}
+							<Button size="sm" variant="outline" onclick={() => (kbSelectionShown = true)}>
+								<Icon icon={kb.icon} size={14} />
+								<div>{kb.name}</div>
+							</Button>
+						{:else}
+							<Button size="sm" variant="outline" onclick={() => (kbSelectionShown = true)}>
+								Select knowledge bases...
+							</Button>
+						{/each}
+					</div>
+				</Field.Content>
+			</Field.Field>
+		</Field.Set>
+	</Card>
+
+	<Card class="mb-4" title="Deployment">
+		<Optional title="Runtime Settings">
+			<DeploymentForm {disabled} {workspaceId} bind:form />
+		</Optional>
+	</Card>
+
+	<!-- Footer actions -->
+	<div class="flex justify-end">
+		{@render panel()}
+	</div>
+</div>
+
+<ModelSelectDialog
+	category="language"
+	{workspaceId}
+	bind:open={modelSelectionShown}
+	onselect={(model) => onModelSelect(model)}
+/>
+
+<KBSelectDialog {workspaceId} bind:open={kbSelectionShown} onselect={(kbs) => onKbSelect(kbs)} />
+
+<ToolSelectDialog
+	{workspaceId}
+	bind:open={toolSelectionShown}
+	onselect={(tools) => onToolSelect(tools)}
+/>

+ 5 - 0
src/lib/components/agents/form/index.ts

@@ -0,0 +1,5 @@
+import AgentForm from './form.svelte';
+
+export type { AgentFormValue } from './types';
+export { AgentForm };
+export { DEFAULT_AGENT_FORM_VALUE, toAgentFormValue, validateAgentFormValue } from './types';

+ 68 - 0
src/lib/components/agents/form/types.ts

@@ -0,0 +1,68 @@
+import type { DeploymentFormValue } from '$lib/components/forms/deployment';
+import {
+	fromDeploymentFormValue,
+	toDeploymentFormValue,
+} from '$lib/components/forms/deployment/types';
+import type { Agent, IDRef } from '$lib/types/entities';
+
+type AgentFormValue = {
+	icon?: string;
+	name: string;
+	description: string;
+	tags?: string[];
+	systemPrompt?: string;
+	model?: IDRef;
+	kbs: IDRef[];
+	tools: IDRef[];
+} & DeploymentFormValue;
+
+const DEFAULT_AGENT_FORM_VALUE: AgentFormValue = {
+	icon: 'bot',
+	name: '',
+	description: '',
+	kbs: [],
+	tools: [],
+	runtime: 'loco-agents',
+	runtimeVersion: 'loco-simple-agent',
+};
+
+const toAgentFormValue = (agent: Agent): AgentFormValue => {
+	return {
+		icon: agent.icon,
+		name: agent.name,
+		description: agent.description,
+		tags: agent.tags,
+		systemPrompt: agent.systemPrompt,
+		model: agent.model,
+		kbs: agent.kbs,
+		tools: agent.tools,
+		...toDeploymentFormValue(agent.runtime),
+	};
+};
+
+const fromAgentFormValue = (value: AgentFormValue): Agent => {
+	return {
+		id: '',
+		stack: 'default',
+		icon: value.icon,
+		name: value.name,
+		description: value.description,
+		tags: value.tags || [],
+		systemPrompt: value.systemPrompt,
+		model: value.model!,
+		kbs: value.kbs,
+		tools: value.tools,
+		runtime: fromDeploymentFormValue(value),
+	};
+};
+
+const validateAgentFormValue = (value: AgentFormValue): { agent: Agent; errors: string[] } => {
+	const errors: string[] = [];
+	if (!value.name.trim()) {
+		errors.push('Name is required');
+	}
+	return { agent: fromAgentFormValue(value), errors };
+};
+
+export type { AgentFormValue };
+export { DEFAULT_AGENT_FORM_VALUE, toAgentFormValue, validateAgentFormValue };

+ 88 - 0
src/lib/components/agents/select/dialog.svelte

@@ -0,0 +1,88 @@
+<script lang="ts">
+	import { Bot } from '@lucide/svelte';
+	import { untrack } from 'svelte';
+
+	import * as agentsApi from '$lib/api/agents';
+	import { AgentCard } from '$lib/components/agents/card';
+	import { Empty } from '$lib/components/common/empty';
+	import { SelectDialog } from '$lib/components/common/select-dialog';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import type { Agent } from '$lib/types/entities';
+
+	let {
+		workspaceId,
+		open = $bindable(false),
+		value = $bindable([]),
+		onselect,
+	}: {
+		workspaceId: string;
+		open?: boolean;
+		value?: Agent[];
+		onselect?: (agents: Agent[]) => Agent[];
+	} = $props();
+
+	let query = $state('');
+	let loading = $state(false);
+	let agents = $state<Agent[]>([]);
+
+	const loadAgents = async () => {
+		if (loading || agents.length > 0) {
+			return;
+		}
+		loading = true;
+		try {
+			agents = await agentsApi.list(workspaceId);
+		} catch (error) {
+			console.error('Failed to load agents', error);
+		}
+		loading = false;
+	};
+
+	$effect(() => {
+		if (open) {
+			untrack(() => {
+				loadAgents();
+			});
+		}
+	});
+
+	const select = (agent: Agent) => {
+		if (value.includes(agent)) {
+			value = value.filter((a) => a.id !== agent.id);
+		} else {
+			value = [...value, agent];
+		}
+		if (onselect) {
+			value = onselect(value);
+		}
+	};
+</script>
+
+<SelectDialog title="Select Agent" bind:open bind:query>
+	{#snippet content()}
+		{#if loading}
+			<div class="flex h-full w-full items-center justify-center gap-2">
+				<Spinner />
+				<div class="text-sm text-white/50">Loading Agents...</div>
+			</div>
+		{:else if agents.length === 0}
+			<Empty icon={Bot}>
+				{#snippet caption()}
+					<div>No agents found</div>
+				{/snippet}
+			</Empty>
+		{:else}
+			<div class="grid grid-cols-2 gap-2">
+				{#each agents as agent (agent.id)}
+					<AgentCard
+						class={value.includes(agent)
+							? 'border-primary/50 bg-primary/25 hover:border-primary/75 hover:bg-primary/50'
+							: ''}
+						{agent}
+						onselect={() => select(agent)}
+					/>
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</SelectDialog>

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

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

+ 9 - 0
src/routes/workloads/agents/+page.server.ts

@@ -0,0 +1,9 @@
+import * as agentsApi from '$lib/api/agents';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, locals }) => ({
+	agents: await agentsApi.list(locals.workspaceId!, fetch),
+});
+
+export { load };

+ 77 - 0
src/routes/workloads/agents/+page.svelte

@@ -0,0 +1,77 @@
+<script lang="ts">
+	import { Bot } from '@lucide/svelte';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	import { resolve } from '$app/paths';
+
+	import { AgentCard } from '$lib/components/agents/card';
+	import { FilterList } from '$lib/components/common/filter-list';
+	import type { FilterRow } from '$lib/components/common/filters';
+	import { agentsStore } from '$lib/stores/agents.svelte';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let query = $state('');
+	const allTags = $derived([...new Set(agentsStore.list.flatMap((m) => m.tags ?? []))].sort());
+	const selectedTags = new SvelteSet<string>();
+
+	let filters: FilterRow[] = $derived([
+		...(allTags.length > 0
+			? [
+					{
+						id: 'tags',
+						type: 'multi' as const,
+						label: 'Tags',
+						options: allTags.map((t) => ({ value: t, label: t })),
+						selected: selectedTags,
+					},
+				]
+			: []),
+	]);
+
+	const filtered = $derived.by(() => {
+		let list = agentsStore.list;
+		const q = query.trim().toLowerCase();
+		if (q) list = list.filter((a) => a.name.toLowerCase().includes(q));
+		for (const filter of filters) {
+			if (filter.id === 'tags' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((a) => a.tags?.some((t) => filter.selected.has(t)));
+			}
+		}
+		return list;
+	});
+
+	$effect.root(() => {
+		agentsStore.hydrate(data.agents);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({ module: 'Agents', pages: [] });
+	});
+</script>
+
+<svelte:head>
+	<title>Agents - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<FilterList
+		creationLink={resolve('/workloads/agents/new')}
+		itemCount={agentsStore.list.length}
+		itemIcon={Bot}
+		itemKey={(m) => m.id}
+		itemName="agent"
+		itemNamePlural="agents"
+		items={filtered}
+		bind:query
+		bind:filters
+	>
+		{#snippet itemRenderer(agent)}
+			<AgentCard {agent} {selectedTags} />
+		{/snippet}
+	</FilterList>
+</div>

+ 16 - 0
src/routes/workloads/agents/[id]/+page.server.ts

@@ -0,0 +1,16 @@
+import { error } from '@sveltejs/kit';
+
+import * as agentsApi from '$lib/api/agents';
+import { server } from '$lib/server';
+
+import type { PageServerLoad } from './$types';
+import { derefAgent } from './types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const agent = await agentsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!agent) error(404);
+	const catalog = server.catalog.getWorkspaceCatalog(locals.workspaceId!);
+	return { agent: await derefAgent(agent, catalog) };
+};
+
+export { load };

+ 200 - 0
src/routes/workloads/agents/[id]/+page.svelte

@@ -0,0 +1,200 @@
+<script lang="ts">
+	import { Send, SquarePen } from '@lucide/svelte';
+	import type { Snippet } from 'svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import * as agentsApi from '$lib/api/agents';
+	import { Card } from '$lib/components/common/card';
+	import { ConfirmDialog } from '$lib/components/common/confirmation';
+	import { Icon } from '$lib/components/common/icon';
+	import { MoreMenu } from '$lib/components/common/more-menu';
+	import { PropertyTable, toDeploymentProps } from '$lib/components/common/property-table';
+	import { toInfoProps } from '$lib/components/common/property-table/types.js';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import Button from '$lib/components/controls/button/button.svelte';
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+	import { ManagementTabs } from '$lib/components/management/index.js';
+	import { agentsStore } from '$lib/stores/agents.svelte.js';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte.js';
+	import { toAgentCR } from '$lib/types/custom-resources';
+	import type { Changes, CoreResources } from '$lib/types/workspace';
+	import * as yaml from '$lib/yaml';
+
+	let { data } = $props();
+
+	$effect.root(() => {
+		agentsStore.upsert(data.agent);
+		agentsStore.refreshStatus(data.agent.id);
+	});
+
+	const agent = $derived(agentsStore.get(data.agent.id) ?? data.agent);
+	const status = $derived(agentsStore.getStatus(agent.id));
+
+	const infoProps = $derived(toInfoProps(agent));
+	const configProps = $derived.by(() => {
+		const props: Record<string, string | number | string[] | Snippet> = {};
+		props['Model'] = modelRef;
+		props['Knowledge Bases'] = kbRefs;
+		props['Tools'] = toolRefs;
+		return props;
+	});
+	const deploymentProps = $derived.by(() => {
+		return toDeploymentProps(agent);
+	});
+	const cr = $derived.by(() => yaml.stringify(toAgentCR(agent)));
+	let deleteOpen = $state(false);
+
+	$effect.root(() => {
+		agentsStore.upsert(data.agent);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Agents',
+			pages: [{ name: agent.name, href: resolve('/workloads/agents/[id]', { id: agent.id }) }],
+		});
+	});
+
+	const onTest = () => {
+		goto(resolve(`/utilities/testing/agent?agent=${agent.id}`));
+	};
+
+	const onDeploy = () => {
+		agentsStore.update(agent);
+	};
+
+	const onDelete = async () => {
+		await agentsStore.remove(agent.id);
+		goto(resolve('/workloads/agents'));
+	};
+
+	const fetchResources = async (): Promise<CoreResources> => {
+		return agentsApi.getResources(data.workspaceId!, agent.id);
+	};
+
+	const fetchChanges = async (): Promise<Changes> => {
+		return agentsApi.getChanges(data.workspaceId!, agent.id);
+	};
+</script>
+
+<svelte:head>
+	<title>{agent.name} - LocoStack</title>
+</svelte:head>
+
+{#snippet refLink(href: string, icon: string | undefined, label: string)}
+	<Button {href} size="sm" variant="outline">
+		<Icon {icon} size={14} />
+		<div>{label}</div>
+	</Button>
+{/snippet}
+
+{#snippet modelRef()}
+	{@render refLink(
+		resolve('/workloads/models/[id]', { id: agent.model.id }),
+		agent.model.icon,
+		agent.model.name || agent.model.id,
+	)}
+{/snippet}
+
+{#snippet kbRefs()}
+	<div class="flex flex-wrap gap-2">
+		{#each agent.kbs as kb (kb.id)}
+			{@render refLink(resolve('/workloads/kbs/[id]', { id: kb.id }), undefined, kb.name || kb.id)}
+		{:else}
+			<div class="text-white/75 text-sm">None</div>
+		{/each}
+	</div>
+{/snippet}
+
+{#snippet toolRefs()}
+	<div class="flex flex-wrap gap-2">
+		{#each agent.tools as tool (tool.id)}
+			{@render refLink(
+				resolve('/workloads/tools/[id]', { id: tool.id }),
+				tool.icon,
+				tool.name || tool.id,
+			)}
+		{:else}
+			<div class="text-white/75 text-sm">None</div>
+		{/each}
+	</div>
+{/snippet}
+
+<div class="mx-auto max-w-2xl p-6">
+	<!-- Page header -->
+	<div class="mb-4 flex items-center justify-between gap-4">
+		<div class="flex items-center gap-4">
+			<Icon icon={agent.icon} />
+			<div class="flex flex-1 flex-col overflow-hidden">
+				<div class="truncate text-lg font-medium text-white/75">{agent.name}</div>
+			</div>
+		</div>
+
+		<div class="flex items-center gap-2">
+			<StatusBadge status={agent.status || 'not-deployed'} />
+			<Button
+				href={resolve('/workloads/agents/[id]/edit', { id: agent.id })}
+				size="sm"
+				variant="outline"
+			>
+				<SquarePen size={14} strokeWidth={2} />
+				Edit
+			</Button>
+			<MoreMenu ondelete={() => (deleteOpen = true)}>
+				{#snippet menu()}
+					{#if agent.status}
+						<DropdownMenu.Item onclick={() => onTest()}>
+							<Send size={14} strokeWidth={2} />
+							Test
+						</DropdownMenu.Item>
+					{:else}
+						<DropdownMenu.Item onclick={() => onDeploy()}>
+							<Send size={14} strokeWidth={2} />
+							Deploy
+						</DropdownMenu.Item>
+					{/if}
+				{/snippet}
+			</MoreMenu>
+		</div>
+	</div>
+
+	<Card class="mb-4" title="Basic Information">
+		<PropertyTable properties={infoProps} />
+	</Card>
+
+	<Card class="mb-4" title="Configuration">
+		<PropertyTable properties={configProps} />
+	</Card>
+
+	<Card class="mb-4" title="System Prompt">
+		<div class="text-white/75">{agent.systemPrompt}</div>
+	</Card>
+
+	<Card class="mb-8" title="Deployment">
+		<PropertyTable properties={deploymentProps} />
+	</Card>
+
+	<ManagementTabs
+		conditions={status?.conditions}
+		{cr}
+		onloadresources={fetchResources}
+		onloadchanges={fetchChanges}
+	/>
+</div>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting..."
+	title="Delete agent?"
+	variant="destructive"
+	onconfirm={() => onDelete()}
+	bind:open={deleteOpen}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{agent.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>

+ 17 - 0
src/routes/workloads/agents/[id]/edit/+page.server.ts

@@ -0,0 +1,17 @@
+import { error } from '@sveltejs/kit';
+
+import * as agentsApi from '$lib/api/agents';
+import { server } from '$lib/server';
+
+import { derefAgent } from '../types';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const agent = await agentsApi.get(locals.workspaceId!, params.id, fetch).catch(() => null);
+	if (!agent) error(404);
+	const catalog = server.catalog.getWorkspaceCatalog(locals.workspaceId!);
+	return { agent: await derefAgent(agent, catalog) };
+};
+
+export { load };

+ 57 - 0
src/routes/workloads/agents/[id]/edit/+page.svelte

@@ -0,0 +1,57 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import {
+		AgentForm,
+		type AgentFormValue,
+		toAgentFormValue,
+		validateAgentFormValue,
+	} from '$lib/components/agents/form';
+	import { agentsStore } from '$lib/stores/agents.svelte';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		agentsStore.upsert(data.agent);
+	});
+
+	const agent = $derived(agentsStore.get(data.agent.id) ?? data.agent);
+	const value = $derived(toAgentFormValue(agent));
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Agents',
+			pages: [
+				{
+					name: `Edit [${agent.name}]`,
+					href: resolve('/workloads/agents/[id]/edit', { id: agent.id }),
+				},
+			],
+		});
+	});
+
+	const onSave = async (formValue: AgentFormValue) => {
+		const { agent, errors } = validateAgentFormValue(formValue);
+		if (errors.length > 0 || agent === undefined) {
+			return;
+		}
+		await agentsStore.update({ ...agent, id: data.agent.id });
+		goto(resolve('/workloads/agents/[id]', { id: data.agent.id }));
+	};
+</script>
+
+<svelte:head>
+	<title>Edit {agent.name} - LocoStack</title>
+</svelte:head>
+
+<AgentForm
+	cancelHref={resolve('/workloads/agents/[id]', { id: data.agent.id })}
+	{value}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSave(formValue)}
+/>

+ 44 - 0
src/routes/workloads/agents/[id]/types.ts

@@ -0,0 +1,44 @@
+import type { WorkspaceCatalog } from '$lib/server/catalog';
+import type { Agent } from '$lib/types/entities';
+
+const derefAgent = async (agent: Agent, catalog: WorkspaceCatalog): Promise<Agent> => {
+	const models = await catalog.models.list();
+	const model = models.find((m) => m.id === agent.model.id);
+	if (model) {
+		agent.model = {
+			type: model.mode,
+			id: model.id,
+			name: model.name,
+			icon: model.icon,
+		};
+	}
+	const kbs = await catalog.kbs.list();
+	agent.kbs = agent.kbs.map((kbRef) => {
+		const kb = kbs.find((k) => k.id === kbRef.id);
+		if (kb) {
+			return {
+				type: kb.mode,
+				id: kb.id,
+				name: kb.name,
+				icon: kb.icon,
+			};
+		}
+		return kbRef;
+	});
+	const tools = await catalog.tools.list();
+	agent.tools = agent.tools.map((toolRef) => {
+		const tool = tools.find((t) => t.id === toolRef.id);
+		if (tool) {
+			return {
+				type: tool.mode,
+				id: tool.id,
+				name: tool.name,
+				icon: tool.icon,
+			};
+		}
+		return toolRef;
+	});
+	return agent;
+};
+
+export { derefAgent };

+ 40 - 0
src/routes/workloads/agents/new/+page.svelte

@@ -0,0 +1,40 @@
+<script lang="ts">
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import { AgentForm, validateAgentFormValue } from '$lib/components/agents/form';
+	import type { AgentFormValue } from '$lib/components/agents/form/types';
+	import { agentsStore } from '$lib/stores/agents.svelte';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Agents',
+			pages: [{ name: '[New Agent]', href: resolve('/workloads/agents/new') }],
+		});
+	});
+
+	const onSubmit = async (formValue: AgentFormValue) => {
+		const { agent, errors } = validateAgentFormValue(formValue);
+		if (errors.length > 0 || agent === undefined) {
+			return;
+		}
+		await agentsStore.create(agent);
+		goto(resolve('/workloads/agents'));
+	};
+</script>
+
+<svelte:head>
+	<title>New Agent - LocoStack</title>
+</svelte:head>
+
+<AgentForm
+	cancelHref={resolve('/workloads/agents')}
+	workspaceId={data.workspaceId!}
+	onsubmit={(formValue) => onSubmit(formValue)}
+/>