Parcourir la source

feat: add model pages

Thomas Zhang il y a 3 mois
Parent
commit
8174a7ed92

+ 104 - 0
src/lib/components/models/card/card.svelte

@@ -0,0 +1,104 @@
+<script lang="ts">
+	import { AudioLines, FileText, Images, Video } from '@lucide/svelte';
+
+	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 ManagedModel,
+		type Model,
+		MODEL_PROVIDERS,
+		MODEL_RUNTIMES,
+	} from '$lib/types/entities';
+
+	let {
+		model,
+		selectedTags = new Set<string>(),
+		onselect,
+	}: { model: Model; selectedTags?: Set<string>; onselect?: () => void } = $props();
+
+	const modalityIcons: Record<string, typeof FileText> = {
+		text: FileText,
+		image: Images,
+		video: Video,
+		audio: AudioLines,
+	};
+</script>
+
+{#snippet content()}
+	<GlassPane
+		class="relative flex h-36 gap-4 overflow-hidden rounded-xl px-4 py-2 hover:border-white/20 hover:bg-white/5"
+		reactive
+	>
+		<!-- icon: 48px square, vertically centered -->
+		<div class="flex shrink-0 items-center justify-center">
+			<Icon icon={model.icon} />
+		</div>
+
+		<!-- content -->
+		<div class="flex flex-1 flex-col gap-1 overflow-hidden">
+			<!-- header: name + status badge -->
+			<div class="flex items-center justify-between gap-2">
+				<span class="truncate text-lg font-medium text-white/75 group-hover:text-white">
+					{model.name}
+				</span>
+				{#if model.status}
+					<StatusBadge class="shrink-0" status={model.status} />
+				{/if}
+			</div>
+
+			<!-- second line: provider or runtime + version + modelId -->
+			<div class="flex items-center gap-2">
+				<div class="shrink-0 rounded border border-white/10 px-1 py-px text-sm text-white/50">
+					{#if model.mode === 'external'}
+						{MODEL_PROVIDERS.find((p) => p.value === model.provider)?.label ?? model.provider}
+					{:else}
+						{MODEL_RUNTIMES.find((r) => r.value === (model as ManagedModel).runtime.name)?.label ??
+							(model as ManagedModel).runtime.name}
+					{/if}
+				</div>
+				{#if model.info.baseModelId}
+					<div class="flex-1 truncate text-sm text-white/50">
+						{model.info.baseModelId}
+					</div>
+				{/if}
+			</div>
+
+			<!-- inputs -->
+			{#if model.info.inputs?.length}
+				<div class="my-1 flex flex-none items-center gap-2 text-sm text-white/50">
+					{#each model.info.inputs as input (input)}
+						{@const InputIcon = modalityIcons[input]}
+						<InputIcon size={14} strokeWidth={2} />
+					{/each}
+				</div>
+			{:else}
+				<div class="text-xs text-white/25">&lt;modalities unknown&gt;</div>
+			{/if}
+
+			<!-- tags -->
+			<div class="mt-auto">
+				<Tags activeTags={selectedTags} tags={model.tags} />
+			</div>
+		</div>
+	</GlassPane>
+{/snippet}
+
+{#if onselect === undefined}
+	<a class="group block" href={resolve('/workloads/models/[id]', { id: model.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/models/card/index.ts

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

+ 488 - 0
src/lib/components/models/form/form.svelte

@@ -0,0 +1,488 @@
+<script lang="ts">
+	import { Card } from '$lib/components/common/card';
+	import { Button } from '$lib/components/controls/button';
+	import * as Field from '$lib/components/controls/field';
+	import { Input } from '$lib/components/controls/input';
+	import * as Select from '$lib/components/controls/select';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { Textarea } from '$lib/components/controls/textarea';
+	import * as ToggleGroup from '$lib/components/controls/toggle-group';
+	import { ArrayInput } from '$lib/components/forms/array-input';
+	import { AuthForm } from '$lib/components/forms/auth';
+	import { DeploymentForm } from '$lib/components/forms/deployment';
+	import { IconPicker } from '$lib/components/forms/icon-picker';
+	import { Optional } from '$lib/components/forms/optional';
+	import {
+		MODEL_CATEGORIES,
+		MODEL_MODALITIES,
+		MODEL_PROVIDERS,
+		MODEL_RUNTIMES,
+	} from '$lib/types/entities';
+
+	import { DEFAULT_MODEL_FORM_VALUE, type ModelFormValue } from './types';
+
+	let {
+		workspaceId,
+		disabled = false,
+		submitting = false,
+		value,
+		cancelHref,
+		onsubmit = () => {},
+	}: {
+		workspaceId: string;
+		disabled?: boolean;
+		submitting?: boolean;
+		value?: ModelFormValue;
+		cancelHref: string;
+		onsubmit: (value: ModelFormValue) => void;
+	} = $props();
+	const isEdit = $derived(value !== undefined);
+	let form = $state<ModelFormValue>({ ...DEFAULT_MODEL_FORM_VALUE });
+	let errors = $state<{
+		name?: string;
+	}>({});
+	const providerLabel = $derived(
+		MODEL_PROVIDERS.find((p) => p.value === form.provider)?.label ?? form.provider,
+	);
+	const runtimes = $derived(MODEL_RUNTIMES.filter((r) => r.supports.has(form.modelCategory)));
+
+	const initForm = (value?: ModelFormValue) => {
+		if (!value) {
+			form = { ...DEFAULT_MODEL_FORM_VALUE };
+		} else {
+			form = {
+				...$state.snapshot(value),
+			};
+		}
+	};
+
+	const onSelectModelCategory = (category: string) => {
+		if (form.runtime === 'llama.cpp') {
+			if (category === 'embedding') {
+				form.runtimeVersion = 'llama.cpp-embedding';
+				form.runtimeContextWindow = 512;
+			} else if (category === 'reranker') {
+				form.runtimeVersion = 'llama.cpp-reranker';
+				form.runtimeContextWindow = 512;
+			} else {
+				form.runtimeVersion = undefined;
+				form.runtimeContextWindow = 8192;
+			}
+		}
+	};
+
+	$effect(() => initForm(value));
+
+	const handleSubmit = () => {
+		onsubmit(form);
+	};
+</script>
+
+{#snippet panel()}
+	<div class="flex items-center gap-2">
+		<Button href={cancelHref} size="sm" variant="outline">Cancel</Button>
+		<Button disabled={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 Model' : 'New Model'}
+			</h1>
+			{#if isEdit}
+				<div class="text-xs text-white/30 capitalize">{form.mode}</div>
+			{/if}
+		</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 {disabled} bind:value={form.icon} />
+			</div>
+			<!-- Name, Description, Mode, Model Category -->
+			<div class="min-w-0 flex-1 space-y-4">
+				<Field.Set>
+					<Field.Field>
+						<Field.Label for="name">Name</Field.Label>
+						<Input
+							id="name"
+							{disabled}
+							maxlength={64}
+							placeholder="e.g. Qwen3-4B"
+							bind:value={form.name}
+						/>
+						<Field.Error>{errors.name}</Field.Error>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="description">Description</Field.Label>
+						<Textarea
+							id="description"
+							{disabled}
+							placeholder="What will this model be used for?"
+							rows={3}
+							bind:value={form.description}
+						/>
+						<Field.Description>
+							Provide a brief description that can also help AI assistant to understand the model's
+							intended usage.
+						</Field.Description>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="mode">Mode</Field.Label>
+						<ToggleGroup.Root
+							id="mode"
+							disabled={isEdit || disabled}
+							type="single"
+							variant="outline"
+							bind:value={form.mode}
+						>
+							<ToggleGroup.Item value="external">External</ToggleGroup.Item>
+							<ToggleGroup.Item value="managed">Managed</ToggleGroup.Item>
+						</ToggleGroup.Root>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="modelCategory">Model Category</Field.Label>
+						<ToggleGroup.Root
+							id="modelCategory"
+							{disabled}
+							onValueChange={(v) => onSelectModelCategory(v)}
+							type="single"
+							variant="outline"
+							bind:value={form.modelCategory}
+						>
+							{#each MODEL_CATEGORIES as type (type.value)}
+								<ToggleGroup.Item value={type.value}>{type.label}</ToggleGroup.Item>
+							{/each}
+						</ToggleGroup.Root>
+					</Field.Field>
+				</Field.Set>
+			</div>
+		</div>
+
+		<Optional class="mb-4" 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>
+
+		<!-- Provider (collapsible) -->
+		<Optional class="mb-4" title="Provider">
+			<Field.Set>
+				<Field.Field>
+					<Field.Label for="baseModelId">Base Model ID</Field.Label>
+					<Input id="baseModelId" {disabled} bind:value={form.baseModelId} />
+				</Field.Field>
+				<div class="grid grid-cols-3 gap-4">
+					<Field.Field>
+						<Field.Label for="family">Family</Field.Label>
+						<Input id="family" {disabled} bind:value={form.family} />
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="releaseDate">Release Date</Field.Label>
+						<Input id="releaseDate" {disabled} bind:value={form.releaseDate} />
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="parameterCount">Parameter Count</Field.Label>
+						<Input id="parameterCount" {disabled} bind:value={form.parameterCount} />
+					</Field.Field>
+				</div>
+			</Field.Set>
+		</Optional>
+
+		<Optional title="Capabilities">
+			<Field.Set>
+				{#if form.modelCategory === 'language'}
+					<div class="grid grid-cols-2 gap-4">
+						<Field.Field>
+							<Field.Label for="contextWindow">Context Window</Field.Label>
+							<Input
+								id="contextWindow"
+								{disabled}
+								min={0}
+								type="number"
+								bind:value={form.contextWindow}
+							/>
+						</Field.Field>
+						<Field.Field>
+							<Field.Label for="maxOutputTokens">Max Output Tokens</Field.Label>
+							<Input
+								id="maxOutputTokens"
+								{disabled}
+								min={0}
+								type="number"
+								bind:value={form.maxOutputTokens}
+							/>
+						</Field.Field>
+						<Field.Field>
+							<Field.Label for="inputs">Inputs</Field.Label>
+							<ToggleGroup.Root
+								id="inputs"
+								{disabled}
+								type="multiple"
+								variant="outline"
+								bind:value={form.inputs}
+							>
+								{#each MODEL_MODALITIES as option (option.value)}
+									<ToggleGroup.Item value={option.value}>
+										{option.label}
+									</ToggleGroup.Item>
+								{/each}
+							</ToggleGroup.Root>
+						</Field.Field>
+						<Field.Field>
+							<Field.Label for="outputs">Outputs</Field.Label>
+							<ToggleGroup.Root
+								id="outputs"
+								{disabled}
+								type="multiple"
+								variant="outline"
+								bind:value={form.outputs}
+							>
+								{#each MODEL_MODALITIES as option (option.value)}
+									<ToggleGroup.Item value={option.value}>
+										{option.label}
+									</ToggleGroup.Item>
+								{/each}
+							</ToggleGroup.Root>
+						</Field.Field>
+					</div>
+				{:else if form.modelCategory === 'embedding'}
+					<Field.Field>
+						<Field.Label for="embeddingDimensions">Embedding Dimensions</Field.Label>
+						<Input
+							id="embeddingDimensions"
+							{disabled}
+							min={1}
+							placeholder="1024"
+							type="number"
+							bind:value={form.embeddingDimensions}
+						/>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="maxInputTokens">Max Input Tokens</Field.Label>
+						<Input
+							id="maxInputTokens"
+							{disabled}
+							min={0}
+							placeholder="128000"
+							type="number"
+							bind:value={form.maxInputTokens}
+						/>
+						<Field.Description>Maximum input length. Set to 0 for unlimited.</Field.Description>
+					</Field.Field>
+				{/if}
+				<Field.Field>
+					<Field.Label for="languages">Languages</Field.Label>
+					<ArrayInput {disabled} bind:value={form.languages} />
+					<Field.Description>e.g. English, Chinese, Spanish</Field.Description>
+				</Field.Field>
+				<Field.Field>
+					<Field.Label for="tags">Tags</Field.Label>
+					<ArrayInput {disabled} bind:value={form.tags} />
+					<Field.Description>e.g. Reasoning, Tool Use, Code Generation</Field.Description>
+				</Field.Field>
+			</Field.Set>
+		</Optional>
+	</Card>
+
+	<!-- Managed panels -->
+	{#if form.mode === 'managed'}
+		<Card class="mb-4" title="Deployment">
+			<Field.Set>
+				<Field.Field>
+					<Field.Label for="huggingFaceEndpoint">Hugging Face Endpoint</Field.Label>
+					<Input
+						id="huggingFaceEndpoint"
+						{disabled}
+						placeholder="https://huggingface.co"
+						bind:value={form.huggingFaceEndpoint}
+					/>
+				</Field.Field>
+				<div class="grid grid-cols-2 gap-4">
+					<Field.Field>
+						<Field.Label for="huggingFaceRepo">Hugging Face Repository</Field.Label>
+						<Input id="huggingFaceRepo" {disabled} bind:value={form.huggingFaceRepo} />
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="huggingFaceRevision">Revision</Field.Label>
+						<Input
+							id="huggingFaceRevision"
+							{disabled}
+							placeholder="main"
+							bind:value={form.huggingFaceRevision}
+						/>
+					</Field.Field>
+				</div>
+				<Field.Field>
+					<Field.Label for="huggingFaceRepoFileName">File</Field.Label>
+					<Input id="huggingFaceRepoFileName" {disabled} bind:value={form.huggingFaceFileName} />
+				</Field.Field>
+				<Optional title="Runtime Settings">
+					<DeploymentForm {disabled} {runtimes} {workspaceId} bind:form />
+				</Optional>
+			</Field.Set>
+		</Card>
+	{/if}
+
+	{#if form.mode === 'external'}
+		<Card class="mb-4" title={`Endpoint${form.mode === 'external' ? '' : ' (Optional)'}`}>
+			<Field.Set>
+				<div class="grid grid-cols-2 gap-4">
+					<Field.Field>
+						<Field.Label for="provider">Provider</Field.Label>
+						<Select.Root {disabled} type="single" bind:value={form.provider}>
+							<Select.Trigger id="provider" class="w-full">{providerLabel}</Select.Trigger>
+							<Select.Content>
+								{#each MODEL_PROVIDERS as p (p.value)}
+									<Select.Item label={p.label} value={p.value} />
+								{/each}
+							</Select.Content>
+						</Select.Root>
+					</Field.Field>
+					<Field.Field>
+						<Field.Label for="providerModel">Model ID</Field.Label>
+						<Input id="providerModel" {disabled} bind:value={form.providerModel} />
+					</Field.Field>
+				</div>
+				<Field.Field>
+					<Field.Label for="apiBase">API Base</Field.Label>
+					<Input id="apiBase" {disabled} placeholder="Provider default" bind:value={form.apiBase} />
+				</Field.Field>
+				<Field.Field>
+					<Field.Label for="apiVersion">API Version</Field.Label>
+					<Input
+						id="apiVersion"
+						{disabled}
+						placeholder="Provider default"
+						bind:value={form.apiVersion}
+					/>
+				</Field.Field>
+				<AuthForm {disabled} {workspaceId} bind:form />
+			</Field.Set>
+		</Card>
+	{/if}
+
+	{#if form.mode === 'managed' || form.modelCategory === 'language'}
+		<Card class="mb-4" title="Inference Settings">
+			<Field.Set>
+				{#if form.mode === 'external'}
+					{#if form.modelCategory === 'language'}
+						<Optional title="Default Sampling">
+							<div class="grid grid-cols-3 gap-4">
+								<Field.Field>
+									<Field.Label for="defaultTemperature">Temperature</Field.Label>
+									<Input
+										id="defaultTemperature"
+										{disabled}
+										placeholder="0.8"
+										type="number"
+										bind:value={form.defaultTemperature}
+									/>
+								</Field.Field>
+								<Field.Field>
+									<Field.Label for="defaultMaxTokens">Max Tokens</Field.Label>
+									<Input
+										id="defaultMaxTokens"
+										{disabled}
+										placeholder="Infinite"
+										type="number"
+										bind:value={form.defaultMaxTokens}
+									/>
+								</Field.Field>
+								<Field.Field>
+									<Field.Label for="defaultTopP">Top-P</Field.Label>
+									<Input
+										id="defaultTopP"
+										{disabled}
+										placeholder="0.95"
+										type="number"
+										bind:value={form.defaultTopP}
+									/>
+								</Field.Field>
+							</div>
+						</Optional>
+					{/if}
+				{:else if form.mode === 'managed'}
+					{#if form.modelCategory === 'language'}
+						<Field.Field>
+							<Field.Label for="runtimeChatTemplate">Chat Template</Field.Label>
+							<Textarea
+								id="runtimeChatTemplate"
+								class="h-48 break-all"
+								{disabled}
+								placeholder="Chat template can be found in the model's repository"
+								rows={10}
+								bind:value={form.runtimeChatTemplate}
+							/>
+						</Field.Field>
+					{/if}
+					<Field.Field>
+						<Field.Label for="runtimeContextWindow">Context Window Size</Field.Label>
+						<Input
+							id="runtimeContextWindow"
+							{disabled}
+							type="number"
+							bind:value={form.runtimeContextWindow}
+						/>
+					</Field.Field>
+					{#if form.modelCategory === 'language'}
+						<Optional title="Runtime Settings">
+							<div class="grid grid-cols-3 gap-4">
+								<Field.Field>
+									<Field.Label for="runtimeTemperature">Temperature</Field.Label>
+									<Input
+										id="runtimeTemperature"
+										{disabled}
+										placeholder="0.8"
+										type="number"
+										bind:value={form.runtimeTemperature}
+									/>
+								</Field.Field>
+								<Field.Field>
+									<Field.Label for="runtimeTopK">Top-K</Field.Label>
+									<Input
+										id="runtimeTopK"
+										{disabled}
+										placeholder="40"
+										type="number"
+										bind:value={form.runtimeTopK}
+									/>
+								</Field.Field>
+								<Field.Field>
+									<Field.Label for="runtimeTopP">Top-P</Field.Label>
+									<Input
+										id="runtimeTopP"
+										{disabled}
+										placeholder="0.95"
+										type="number"
+										bind:value={form.runtimeTopP}
+									/>
+								</Field.Field>
+							</div>
+						</Optional>
+					{/if}
+				{/if}
+			</Field.Set>
+		</Card>
+	{/if}
+
+	<!-- Footer actions -->
+	<div class="flex justify-end">
+		{@render panel()}
+	</div>
+</div>

+ 3 - 0
src/lib/components/models/form/index.ts

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

+ 212 - 0
src/lib/components/models/form/types.ts

@@ -0,0 +1,212 @@
+import type { AuthFormValue } from '$lib/components/forms/auth';
+import { fromAuthFormValue, toAuthFormValue } from '$lib/components/forms/auth/types';
+import type { DeploymentFormValue } from '$lib/components/forms/deployment';
+import {
+	fromDeploymentFormValue,
+	toDeploymentFormValue,
+} from '$lib/components/forms/deployment/types';
+import type {
+	ExternalModel,
+	ManagedModel,
+	Model,
+	ModelBase,
+	ModelCategory,
+	ModelModality,
+} from '$lib/types/entities';
+
+type ModelFormValue = {
+	icon?: string;
+	name: string;
+	description: string;
+	tags: string[];
+	mode: 'external' | 'managed';
+	modelCategory: ModelCategory;
+
+	// info
+	family?: string;
+	baseModelId?: string;
+	releaseDate?: string;
+	parameterCount?: string;
+	contextWindow?: number;
+	maxOutputTokens?: number;
+	inputs?: ModelModality[];
+	outputs?: ModelModality[];
+	embeddingDimensions?: number;
+	maxInputTokens?: number;
+	languages?: string[];
+
+	// inference
+	defaultTemperature?: number;
+	defaultMaxTokens?: number;
+	defaultTopP?: number;
+	runtimeChatTemplate?: string;
+	runtimeContextWindow?: number;
+	runtimeTemperature?: number;
+	runtimeTopP?: number;
+	runtimeTopK?: number;
+
+	// external model
+	provider: string;
+	providerModel: string;
+	apiBase?: string;
+	apiVersion?: string;
+
+	// managed model
+	huggingFaceEndpoint?: string;
+	huggingFaceRepo: string;
+	huggingFaceRevision?: string;
+	huggingFaceFileName?: string;
+} & AuthFormValue &
+	DeploymentFormValue;
+
+const DEFAULT_MODEL_FORM_VALUE: ModelFormValue = {
+	icon: 'language',
+	name: '',
+	description: '',
+	tags: [],
+	mode: 'external',
+	modelCategory: 'language',
+	provider: 'openai',
+	providerModel: 'chatgpt-oss',
+	authType: 'api-key',
+	huggingFaceRepo: '',
+	runtime: 'llama.cpp',
+	runtimeContextWindow: 8192,
+	cpu: '100m',
+};
+
+const toModelFormValue = (model: Model): ModelFormValue => {
+	let value = {
+		...DEFAULT_MODEL_FORM_VALUE,
+		icon: model.icon,
+		name: model.name,
+		description: model.description,
+		tags: model.tags,
+		mode: model.mode,
+		modelCategory: model.category,
+		family: model.info.family,
+		baseModelId: model.info.baseModelId,
+		releaseDate: model.info.releaseDate,
+		parameterCount: model.info.parameterCount,
+		contextWindow: model.info.contextWindow,
+		inputs: model.info.inputs,
+		outputs: model.info.outputs,
+		embeddingDimensions: model.info.embeddingDimensions,
+		maxInputTokens: model.info.maxInputTokens,
+		languages: model.info.languages,
+	};
+	if (model.mode === 'external') {
+		const externalModel = model as ExternalModel;
+		value = {
+			...value,
+			apiBase: externalModel.apiBase,
+			apiVersion: externalModel.apiVersion,
+			...toAuthFormValue(externalModel.auth),
+			defaultTemperature: externalModel.defaultInferenceParams?.temperature
+				? parseFloat(externalModel.defaultInferenceParams.temperature)
+				: undefined,
+			defaultMaxTokens: externalModel.defaultInferenceParams?.maxTokens,
+			defaultTopP: externalModel.defaultInferenceParams?.topP
+				? parseFloat(externalModel.defaultInferenceParams.topP)
+				: undefined,
+		};
+	} else if (model.mode === 'managed') {
+		const managedModel = model as ManagedModel;
+		value = {
+			...value,
+			huggingFaceEndpoint: managedModel.huggingFace?.endpoint,
+			huggingFaceRepo: managedModel.huggingFace?.repo ?? '',
+			huggingFaceRevision: managedModel.huggingFace?.revision,
+			huggingFaceFileName: managedModel.huggingFace?.fileName,
+			...toDeploymentFormValue(managedModel.runtime),
+			runtimeChatTemplate: managedModel.runtimeInferenceParameters?.chatTemplate,
+			runtimeContextWindow: managedModel.runtimeInferenceParameters?.contextWindow,
+			runtimeTemperature: managedModel.runtimeInferenceParameters?.temperature
+				? parseFloat(managedModel.runtimeInferenceParameters.temperature)
+				: undefined,
+			runtimeTopP: managedModel.runtimeInferenceParameters?.topP
+				? parseFloat(managedModel.runtimeInferenceParameters.topP)
+				: undefined,
+			runtimeTopK: managedModel.runtimeInferenceParameters?.topK,
+		};
+	}
+	return value;
+};
+
+const fromModelFormValue = (value: ModelFormValue): Model => {
+	const model: ModelBase = {
+		id: '',
+		stack: 'default',
+		icon: value.icon,
+		name: value.name,
+		description: value.description,
+		tags: value.tags,
+		category: value.modelCategory,
+		info: {
+			family: value.family,
+			baseModelId: value.baseModelId,
+			releaseDate: value.releaseDate,
+			parameterCount: value.parameterCount,
+			languages: value.languages,
+		},
+	};
+	if (value.modelCategory === 'language') {
+		model.info.contextWindow = value.contextWindow;
+		model.info.inputs = value.inputs;
+		model.info.outputs = value.outputs;
+	} else if (value.modelCategory === 'embedding') {
+		model.info.embeddingDimensions = value.embeddingDimensions;
+		model.info.maxInputTokens = value.maxInputTokens;
+	}
+	if (value.mode === 'external') {
+		const externalModel: ExternalModel = {
+			...model,
+			mode: 'external',
+			provider: value.provider,
+			providerModel: value.providerModel,
+			apiBase: value.apiBase,
+			apiVersion: value.apiVersion,
+			auth: fromAuthFormValue(value),
+			defaultInferenceParams: {
+				temperature: value.defaultTemperature?.toString(),
+				maxTokens: value.defaultMaxTokens,
+				topP: value.defaultTopP?.toString(),
+			},
+		};
+		return externalModel;
+	} else {
+		const managedModel: ManagedModel = {
+			...model,
+			mode: 'managed',
+			huggingFace: {
+				endpoint: value.huggingFaceEndpoint,
+				repo: value.huggingFaceRepo,
+				revision: value.huggingFaceRevision,
+				fileName: value.huggingFaceFileName,
+			},
+			runtime: fromDeploymentFormValue(value),
+			runtimeInferenceParameters: {
+				chatTemplate: value.runtimeChatTemplate,
+				contextWindow: value.runtimeContextWindow,
+				temperature:
+					value.runtimeTemperature !== undefined ? `${value.runtimeTemperature}` : undefined,
+				topP: value.runtimeTopP !== undefined ? `${value.runtimeTopP}` : undefined,
+				topK: value.runtimeTopK,
+			},
+		};
+		return managedModel;
+	}
+};
+
+const validateModelFormValue = (
+	value: ModelFormValue,
+): { model: Model | undefined; errors: string[] } => {
+	const errors: string[] = [];
+	if (!value.name.trim()) {
+		errors.push('Name is required');
+	}
+	return { model: fromModelFormValue(value), errors };
+};
+
+export type { ModelFormValue };
+export { DEFAULT_MODEL_FORM_VALUE, fromModelFormValue, toModelFormValue, validateModelFormValue };

+ 75 - 0
src/lib/components/models/select/dialog.svelte

@@ -0,0 +1,75 @@
+<script lang="ts">
+	import { Brain } from '@lucide/svelte';
+	import { untrack } from 'svelte';
+
+	import * as modelsApi from '$lib/api/models';
+	import { Empty } from '$lib/components/common/empty';
+	import { SelectDialog } from '$lib/components/common/select-dialog';
+	import { Spinner } from '$lib/components/controls/spinner';
+	import { ModelCard } from '$lib/components/models/card';
+	import type { Model, ModelCategory } from '$lib/types/entities';
+
+	let {
+		workspaceId,
+		category,
+		open = $bindable(false),
+		onselect,
+	}: {
+		workspaceId: string;
+		category?: ModelCategory;
+		open?: boolean;
+		onselect?: (model: Model) => void;
+	} = $props();
+
+	let query = $state('');
+	let loading = $state(false);
+	let loadedCategory: ModelCategory | undefined = $state(undefined);
+	let models = $state<Model[]>([]);
+
+	const loadModels = async () => {
+		if (loading) {
+			return;
+		}
+		loading = true;
+		try {
+			models = (await modelsApi.list(workspaceId)).filter((m) =>
+				category === undefined ? true : m.category === category,
+			);
+			loadedCategory = category;
+		} catch (error) {
+			console.error('Failed to load models', error);
+		}
+		loading = false;
+	};
+
+	$effect(() => {
+		if (open || category !== loadedCategory) {
+			untrack(() => {
+				loadModels();
+			});
+		}
+	});
+</script>
+
+<SelectDialog title="Select Model" 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 Models...</div>
+			</div>
+		{:else if models.length === 0}
+			<Empty icon={Brain}>
+				{#snippet caption()}
+					<div>No models found</div>
+				{/snippet}
+			</Empty>
+		{:else}
+			<div class="grid grid-cols-2 gap-2">
+				{#each models as model (model.id)}
+					<ModelCard {model} onselect={() => onselect?.(model)} />
+				{/each}
+			</div>
+		{/if}
+	{/snippet}
+</SelectDialog>

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

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

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

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

+ 111 - 0
src/routes/workloads/models/+page.svelte

@@ -0,0 +1,111 @@
+<script lang="ts">
+	import { Brain } from '@lucide/svelte';
+	import { SvelteSet } from 'svelte/reactivity';
+
+	import { resolve } from '$app/paths';
+
+	import { FilterList } from '$lib/components/common/filter-list';
+	import * as Filters from '$lib/components/common/filters';
+	import { ModelCard } from '$lib/components/models/card';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { modelsStore } from '$lib/stores/models.svelte';
+	import { MODEL_CATEGORIES, MODEL_MODALITIES } from '$lib/types/entities';
+
+	import type { PageData } from './$types';
+
+	const { data }: { data: PageData } = $props();
+	let query = $state('');
+	const allTags = $derived([...new Set(modelsStore.list.flatMap((m) => m.tags ?? []))].sort());
+	const selectedTags = new SvelteSet<string>();
+
+	let filters: Filters.FilterRow[] = $derived([
+		{
+			id: 'mode',
+			type: 'single',
+			label: 'Mode',
+			options: [
+				{ value: 'all', label: 'All' },
+				{ value: 'external', label: 'External' },
+				{ value: 'managed', label: 'Managed' },
+			],
+			selected: 'all',
+			default: 'all',
+		},
+		{
+			id: 'category',
+			type: 'multi',
+			label: 'Category',
+			options: MODEL_CATEGORIES,
+			selected: new SvelteSet<string>(),
+		},
+		{
+			id: 'modality',
+			type: 'multi',
+			label: 'Modalities',
+			options: MODEL_MODALITIES,
+			selected: new SvelteSet<string>(),
+		},
+		...(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 = modelsStore.list;
+		const q = query.trim().toLowerCase();
+		if (q) list = list.filter((m) => m.name.toLowerCase().includes(q));
+		for (const filter of filters) {
+			if (filter.id === 'mode') {
+				list = list.filter((m) => (filter.selected === 'all' ? true : m.mode === filter.selected));
+			} else if (filter.id === 'category' && filter.type === 'multi') {
+				if (filter.selected.size > 0) list = list.filter((m) => filter.selected.has(m.category));
+			} else if (filter.id === 'modality' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((m) => m.info.inputs?.some((t) => filter.selected.has(t)));
+			} else if (filter.id === 'tags' && filter.type === 'multi') {
+				if (filter.selected.size > 0)
+					list = list.filter((m) => m.tags?.some((t) => filter.selected.has(t)));
+			}
+		}
+		return list;
+	});
+
+	$effect.root(() => {
+		modelsStore.hydrate(data.models);
+	});
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({ module: 'Models', pages: [] });
+	});
+</script>
+
+<svelte:head>
+	<title>Models - LocoStack</title>
+</svelte:head>
+
+<div class="mx-auto max-w-400">
+	<FilterList
+		creationLink={resolve('/workloads/models/new')}
+		itemCount={modelsStore.list.length}
+		itemIcon={Brain}
+		itemKey={(m) => m.id}
+		itemName="model"
+		itemNamePlural="models"
+		items={filtered}
+		bind:query
+		bind:filters
+	>
+		{#snippet itemRenderer(model)}
+			<ModelCard {model} {selectedTags} />
+		{/snippet}
+	</FilterList>
+</div>

+ 13 - 0
src/routes/workloads/models/[id]/+page.server.ts

@@ -0,0 +1,13 @@
+import { error } from '@sveltejs/kit';
+
+import * as modelsApi from '$lib/api/models';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, locals, params }) => {
+	const model = await modelsApi.get(locals.workspaceId!, params.id, fetch);
+	if (!model) error(404);
+	return { model };
+};
+
+export { load };

+ 251 - 0
src/routes/workloads/models/[id]/+page.svelte

@@ -0,0 +1,251 @@
+<script lang="ts">
+	import { FileBraces, Send, SquarePen } from '@lucide/svelte';
+	import type { Snippet } from 'svelte';
+
+	import { goto } from '$app/navigation';
+	import { resolve } from '$app/paths';
+
+	import * as modelsApi from '$lib/api/models';
+	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,
+		toAuthProps,
+		toDeploymentProps,
+	} from '$lib/components/common/property-table';
+	import { toInfoProps } from '$lib/components/common/property-table/types';
+	import { StatusBadge } from '$lib/components/common/status-badge';
+	import { Button } from '$lib/components/controls/button';
+	import * as Dialog from '$lib/components/controls/dialog';
+	import * as DropdownMenu from '$lib/components/controls/dropdown-menu';
+	import { ManagementTabs } from '$lib/components/management';
+	import { getBreadcrumbContext } from '$lib/stores/breadcrumb.svelte';
+	import { modelsStore } from '$lib/stores/models.svelte';
+	import { toExternalModelCR, toManagedModelCR } from '$lib/types/custom-resources';
+	import {
+		type ExternalModel,
+		type ManagedModel,
+		MODEL_CATEGORIES,
+		MODEL_PROVIDERS,
+	} from '$lib/types/entities';
+	import type { Changes, CoreResources } from '$lib/types/workspace';
+	import * as yaml from '$lib/yaml';
+
+	import type { PageData } from './$types';
+
+	let { data }: { data: PageData } = $props();
+
+	$effect.root(() => {
+		modelsStore.upsert(data.model);
+		modelsStore.refreshStatus(data.model.id);
+	});
+
+	const model = $derived(modelsStore.get(data.model.id) ?? data.model);
+	const status = $derived(modelsStore.getStatus(model.id));
+
+	const infoProps = $derived(toInfoProps(model));
+	const configProps = $derived.by(() => {
+		const props: Record<string, string | number | string[] | Snippet> = {};
+		if (model.category)
+			props['Category'] =
+				MODEL_CATEGORIES.find((t) => t.value === model.category)?.label ?? model.category;
+		if (model.mode === 'external') {
+			const ext = model as ExternalModel;
+			props['Provider'] =
+				MODEL_PROVIDERS.find((t) => t.value === ext.provider)?.label ?? ext.provider;
+			props['Model ID'] = ext.providerModel;
+			if (ext.apiBase) props['API Base'] = ext.apiBase;
+			if (ext.apiVersion) props['API Version'] = ext.apiVersion;
+			Object.assign(props, toAuthProps(ext.auth));
+			if (model.defaultInferenceParams?.temperature !== undefined)
+				props['Temperature'] = model.defaultInferenceParams.temperature;
+			if (model.defaultInferenceParams?.maxTokens !== undefined)
+				props['Max Tokens'] = model.defaultInferenceParams.maxTokens;
+			if (model.defaultInferenceParams?.topP !== undefined)
+				props['TopP'] = model.defaultInferenceParams.topP;
+		} else if (model.mode === 'managed') {
+			const man = model as ManagedModel;
+			if (man.huggingFace) {
+				props['Hugging Face'] = man.huggingFace.repo;
+				if (man.huggingFace.revision) props['Revision'] = man.huggingFace.revision;
+				if (man.huggingFace.fileName) props['File Name'] = man.huggingFace.fileName;
+			}
+			if (man.runtimeInferenceParameters?.chatTemplate) props['Chat Template'] = chatTemplate;
+			if (man.runtimeInferenceParameters?.contextWindow)
+				props['Context Window'] = man.runtimeInferenceParameters.contextWindow;
+			if (model.runtimeInferenceParameters?.temperature !== undefined)
+				props['Temperature'] = model.runtimeInferenceParameters.temperature;
+			if (model.runtimeInferenceParameters?.topK !== undefined)
+				props['Top-K'] = model.runtimeInferenceParameters.topK;
+			if (model.runtimeInferenceParameters?.topP !== undefined)
+				props['Top-P'] = model.runtimeInferenceParameters.topP;
+		}
+		return props;
+	});
+	const capProps = $derived.by(() => {
+		const props: Record<string, string | number | string[]> = {};
+		if (model.info.family) props['Family'] = model.info.family;
+		if (model.info.baseModelId) props['Base Model ID'] = model.info.baseModelId;
+		if (model.info.releaseDate) props['Release Date'] = model.info.releaseDate;
+		if (model.category === 'language') {
+			if (model.info.contextWindow) props['Context Window'] = model.info.contextWindow;
+			if (model.info.inputs?.length) props['Inputs'] = model.info.inputs;
+			if (model.info.outputs?.length) props['Outputs'] = model.info.outputs;
+			if (model.info.capabilities?.length) props['Capabilities'] = model.info.capabilities;
+		} else if (model.category === 'embedding') {
+			if (model.info.embeddingDimensions)
+				props['Embedding Dimensions'] = model.info.embeddingDimensions;
+			if (model.info.maxInputTokens) props['Max Input Tokens'] = model.info.maxInputTokens;
+		}
+		if (model.info.languages?.length) props['Languages'] = model.info.languages;
+		return props;
+	});
+	const deploymentProps = $derived.by(() => {
+		if (model.mode !== 'managed') return {};
+		const man = model as ManagedModel;
+		return toDeploymentProps(man.runtime);
+	});
+	const cr = $derived.by(() => {
+		if (model.mode === 'managed') return yaml.stringify(toManagedModelCR(model as ManagedModel));
+		else if (model.mode === 'external') return yaml.stringify(toExternalModelCR(model));
+		else return undefined;
+	});
+	let deleteOpen = $state(false);
+	let chatTemplateShown = $state(false);
+
+	const breadcrumb = getBreadcrumbContext();
+	$effect(() => {
+		breadcrumb.set({
+			module: 'Models',
+			pages: [{ name: model.name, href: resolve('/workloads/models/[id]', { id: model.id }) }],
+		});
+	});
+
+	const onTest = () => {
+		goto(resolve(`/utilities/testing/model?model=${model.id}`));
+	};
+
+	const onDeploy = () => {
+		modelsStore.update(model);
+	};
+
+	const onDelete = async () => {
+		await modelsStore.remove(model.id);
+		goto(resolve('/workloads/models'));
+	};
+
+	const fetchResources = async (): Promise<CoreResources> => {
+		return modelsApi.getResources(data.workspaceId!, model.id);
+	};
+
+	const fetchChanges = async (): Promise<Changes> => {
+		return modelsApi.getChanges(data.workspaceId!, model.id);
+	};
+</script>
+
+<svelte:head>
+	<title>{model.name} - LocoStack</title>
+</svelte:head>
+
+{#snippet chatTemplate()}
+	<Button size="icon-xs" variant="outline" onclick={() => (chatTemplateShown = !chatTemplateShown)}>
+		<FileBraces />
+	</Button>
+
+	<Dialog.Root bind:open={chatTemplateShown}>
+		<Dialog.Content class="sm:max-w-3xl">
+			<Dialog.Header>
+				<Dialog.Title>Chat Template</Dialog.Title>
+			</Dialog.Header>
+			<div class="h-96 overflow-auto">
+				<pre class="text-sm leading-4">{(model as ManagedModel).runtimeInferenceParameters
+						?.chatTemplate}</pre>
+			</div>
+		</Dialog.Content>
+	</Dialog.Root>
+{/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={model.icon} />
+			<div class="flex flex-1 flex-col overflow-hidden">
+				<div class="truncate text-lg font-medium text-white/75">{model.name}</div>
+				<div class="text-xs text-white/50">
+					{model.mode === 'external' ? 'External' : 'Managed'}
+				</div>
+			</div>
+		</div>
+
+		<div class="flex items-center gap-2">
+			<StatusBadge status={model.status || 'not-deployed'} />
+			<Button
+				href={resolve('/workloads/models/[id]/edit', { id: model.id })}
+				size="sm"
+				variant="outline"
+			>
+				<SquarePen size={14} strokeWidth={2} />
+				Edit
+			</Button>
+			<MoreMenu ondelete={() => (deleteOpen = true)}>
+				{#snippet menu()}
+					{#if model.status || model.mode === 'external'}
+						<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>
+
+	{#if Object.keys(capProps).length > 0}
+		<Card class="mb-4" title="Model Capabilities">
+			<PropertyTable properties={capProps} />
+		</Card>
+	{/if}
+
+	{#if model.mode === 'managed'}
+		<Card class="mb-8" title="Deployment">
+			<PropertyTable properties={deploymentProps} />
+		</Card>
+	{/if}
+
+	<ManagementTabs
+		conditions={status?.conditions}
+		{cr}
+		onloadresources={model.mode === 'managed' ? fetchResources : undefined}
+		onloadchanges={fetchChanges}
+	/>
+</div>
+
+<!-- Delete confirmation dialog -->
+<ConfirmDialog
+	confirmLabel="Delete"
+	confirmingLabel="Deleting…"
+	title="Delete model?"
+	variant="destructive"
+	onconfirm={() => onDelete()}
+	bind:open={deleteOpen}
+>
+	{#snippet description()}
+		<strong class="text-white/90">"{model.name}"</strong> will be permanently deleted.
+	{/snippet}
+</ConfirmDialog>

+ 13 - 0
src/routes/workloads/models/[id]/edit/+page.server.ts

@@ -0,0 +1,13 @@
+import { error } from '@sveltejs/kit';
+
+import * as modelsApi from '$lib/api/models';
+
+import type { PageServerLoad } from './$types';
+
+const load: PageServerLoad = async ({ fetch, params, locals }) => {
+	const model = await modelsApi.get(locals.workspaceId, params.id, fetch).catch(() => null);
+	if (!model) error(404);
+	return { model };
+};
+
+export { load };

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

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

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

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