chat-ui-agent-demo / src /lib /components /agent /AgentWorkspaceSetup.svelte
Mike0021's picture
deploy: production agent journey fixes
6f07329 verified
Raw History Blame Contribute Delete
15.4 kB
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { base } from "$app/paths";
import type { Model } from "$lib/types/Model";
import type { WorkspacePolicy } from "$lib/types/Workspace";
import {
isAgentCompatibleModel,
type AgentRepositoryKind,
type AgentRepositoryType,
type AgentThinkingLevel,
type AgentWorkspaceSetupValue,
} from "./types";
interface Props {
models: Model[];
initialValue: AgentWorkspaceSetupValue;
onsave?: (value: AgentWorkspaceSetupValue) => void | Promise<void>;
ondraftchange?: (value: AgentWorkspaceSetupValue) => void;
oncancel?: () => void;
}
let { models, initialValue, onsave, ondraftchange, oncancel }: Props = $props();
const initial = untrack(() => initialValue);
const initialModels = untrack(() => models.filter(isAgentCompatibleModel));
let repoKind = $state<AgentRepositoryKind>(initial.repo.kind);
let hfRef = $state(initial.repo.kind === "hf" ? (initial.repo.ref ?? "") : "");
let gitRef = $state(initial.repo.kind === "git" ? (initial.repo.ref ?? "") : "");
let repoType = $state<AgentRepositoryType>(initial.repo.repoType ?? "space");
let branch = $state(initial.branch || "main");
let flavor = $state<"cpu-basic">("cpu-basic");
let model = $state(
initialModels.some((entry) => entry.id === initial.model)
? initial.model
: (initialModels[0]?.id ?? "")
);
let thinkingLevel = $state<AgentThinkingLevel>(initial.thinkingLevel || "medium");
let policy = $state<WorkspacePolicy>(initial.policy);
let validationError = $state("");
let saving = $state(false);
let repositoryOptions = $state<Array<{ ref: string; private: boolean }>>([]);
let repositoriesLoading = $state(false);
let repositoriesLoadingFor = $state<AgentRepositoryType | undefined>();
let repositoriesLoadedFor = $state<AgentRepositoryType | undefined>();
let repositoriesError = $state("");
let repositoryLoadController: AbortController | undefined;
let availableModels = $derived(models.filter(isAgentCompatibleModel));
const sourceOptions: Array<{
value: AgentRepositoryKind;
title: string;
description: string;
}> = [
{
value: "blank",
title: "Blank workspace",
description: "Start from your prompt with an empty working directory.",
},
{
value: "hf",
title: "Hugging Face repo",
description: "Work on one of your Spaces, models, or datasets.",
},
{
value: "git",
title: "Git URL",
description: "Clone a repository using a public HTTPS URL.",
},
];
const thinkingOptions: Array<{ value: AgentThinkingLevel; label: string }> = [
{ value: "off", label: "Off" },
{ value: "minimal", label: "Minimal" },
{ value: "low", label: "Low" },
{ value: "medium", label: "Medium" },
{ value: "high", label: "High" },
{ value: "xhigh", label: "Extra high" },
{ value: "max", label: "Maximum" },
];
const policyOptions: Array<{
value: WorkspacePolicy;
label: string;
description: string;
}> = [
{
value: "auto",
label: "Auto",
description:
"Allow local edits and commands; ask before sensitive reads or external actions.",
},
{
value: "ask",
label: "Ask more",
description: "Ask before every file change or shell command.",
},
{
value: "read-only",
label: "Read-only",
description: "Block all file changes and shell commands; per-call overrides are disabled.",
},
];
function validate(): string | undefined {
if (!model || !availableModels.some((entry) => entry.id === model)) {
return "Choose an available Agent model.";
}
const normalizedBranch = branch.trim();
if (!normalizedBranch) return "Enter a branch name.";
if (
normalizedBranch.startsWith("-") ||
normalizedBranch.startsWith("/") ||
normalizedBranch.endsWith("/") ||
normalizedBranch.endsWith(".") ||
normalizedBranch.endsWith(".lock") ||
normalizedBranch.includes("..") ||
normalizedBranch.includes("@{") ||
normalizedBranch.includes("//") ||
// Git rejects ASCII control characters in branch names.
// eslint-disable-next-line no-control-regex
/[~^:?*[\\\x00-\x20\x7f]/.test(normalizedBranch)
) {
return "Enter a valid Git branch name.";
}
if (
repoKind === "hf" &&
!/^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(hfRef.trim())
) {
return "Enter a Hugging Face repository as namespace/name.";
}
if (repoKind === "git") {
try {
const url = new URL(gitRef.trim());
if (
url.protocol !== "https:" ||
!url.hostname ||
url.username ||
url.password ||
url.search ||
url.hash
) {
throw new Error("invalid URL");
}
} catch {
return "Enter a public credential-free HTTPS Git URL.";
}
}
return undefined;
}
function workspaceValue(normalize: boolean): AgentWorkspaceSetupValue {
const refValue = repoKind === "hf" ? hfRef : repoKind === "git" ? gitRef : undefined;
const ref = normalize ? refValue?.trim() : refValue;
return {
repo: {
kind: repoKind,
...(ref ? { ref } : {}),
...(repoKind === "hf" ? { repoType } : {}),
},
branch: normalize ? branch.trim() : branch,
flavor,
model,
thinkingLevel,
policy,
};
}
let draftEffectInitialized = false;
$effect(() => {
const value = workspaceValue(false);
if (!draftEffectInitialized) {
draftEffectInitialized = true;
return;
}
ondraftchange?.(value);
});
function cancel() {
ondraftchange?.(workspaceValue(false));
oncancel?.();
}
async function submit() {
const error = validate();
if (error) {
validationError = error;
return;
}
validationError = "";
saving = true;
try {
await onsave?.(workspaceValue(true));
} catch (error) {
validationError =
error instanceof Error ? error.message : "Could not save the Agent workspace.";
} finally {
saving = false;
}
}
async function loadRepositories(force = false) {
const requestedType = repoType;
if (
!force &&
(repositoriesLoadedFor === requestedType ||
(repositoriesLoading && repositoriesLoadingFor === requestedType))
) {
return;
}
repositoryLoadController?.abort();
const controller = new AbortController();
repositoryLoadController = controller;
repositoriesLoading = true;
repositoriesLoadingFor = requestedType;
repositoriesError = "";
repositoryOptions = [];
try {
const response = await fetch(
`${base}/api/agent/repositories?type=${encodeURIComponent(requestedType)}&limit=50`,
{ signal: controller.signal }
);
if (!response.ok) {
let message = "Could not load your Hugging Face repositories.";
try {
const failure = (await response.json()) as { message?: unknown };
if (typeof failure.message === "string" && failure.message.trim()) {
message = failure.message;
}
} catch {
// Keep the stable fallback for non-JSON gateway responses.
}
throw new Error(message);
}
const data = (await response.json()) as {
repositories?: Array<{ ref?: unknown; private?: unknown }>;
};
if (controller.signal.aborted || repoType !== requestedType) return;
repositoryOptions = (data.repositories ?? []).flatMap((repository) =>
typeof repository.ref === "string"
? [{ ref: repository.ref, private: repository.private === true }]
: []
);
repositoriesLoadedFor = requestedType;
} catch (error) {
if (controller.signal.aborted || repoType !== requestedType) return;
repositoriesError =
error instanceof Error ? error.message : "Could not load your Hugging Face repositories.";
} finally {
if (repositoryLoadController === controller) {
repositoryLoadController = undefined;
repositoriesLoading = false;
repositoriesLoadingFor = undefined;
}
}
}
onDestroy(() => repositoryLoadController?.abort());
const fieldClass =
"min-h-11 w-full rounded-xl border border-gray-200 bg-white px-3.5 py-2 text-sm text-gray-900 outline-hidden focus:border-blue-500 focus:ring-2 focus:ring-blue-500/25 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100";
</script>
<form
class="flex w-full flex-col gap-5 p-5 sm:p-6"
aria-labelledby="agent-workspace-setup-title"
onsubmit={(event) => {
event.preventDefault();
submit();
}}
>
<header class="pr-8">
<h2
id="agent-workspace-setup-title"
class="text-xl font-semibold text-gray-900 dark:text-gray-100"
>
Set up an Agent workspace
</h2>
<p class="mt-1 text-sm leading-relaxed text-gray-500 dark:text-gray-400">
Choose where the agent will work. The sandbox starts only after you send your prompt.
</p>
</header>
<fieldset class="flex flex-col gap-2">
<legend class="mb-1 text-sm font-medium text-gray-800 dark:text-gray-200"
>Workspace source</legend
>
<div class="grid gap-2 sm:grid-cols-3">
{#each sourceOptions as option}
<label
class="min-h-24 cursor-pointer rounded-xl border p-3 outline-hidden transition-colors focus-within:border-blue-500 focus-within:ring-2 focus-within:ring-blue-500/25 {repoKind ===
option.value
? 'border-blue-500 bg-blue-50/70 dark:border-blue-500 dark:bg-blue-950/25'
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:hover:border-gray-600'}"
>
<span class="flex items-start gap-2.5">
<input
type="radio"
name="agent-workspace-source"
value={option.value}
bind:group={repoKind}
onchange={() => (validationError = "")}
class="mt-0.5 size-4 shrink-0 accent-blue-600"
/>
<span>
<span class="block text-sm font-semibold text-gray-900 dark:text-gray-100">
{option.title}
</span>
<span class="mt-1 block text-xs leading-relaxed text-gray-500 dark:text-gray-400">
{option.description}
</span>
</span>
</span>
</label>
{/each}
</div>
</fieldset>
{#if repoKind === "hf"}
<div class="grid gap-3 sm:grid-cols-[9rem_1fr]">
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Repository type</span>
<select
bind:value={repoType}
class={fieldClass}
onchange={() => void loadRepositories(true)}
>
<option value="space">Space</option>
<option value="model">Model</option>
<option value="dataset">Dataset</option>
</select>
</label>
<label class="flex min-w-0 flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200"
>Hugging Face repository</span
>
<input
type="text"
bind:value={hfRef}
required
list="agent-hf-repositories"
autocomplete="off"
placeholder="username/my-space"
class={fieldClass}
onfocus={() => void loadRepositories()}
/>
<datalist id="agent-hf-repositories">
{#each repositoryOptions as repository (repository.ref)}
<option value={repository.ref}>{repository.private ? "Private" : "Public"}</option>
{/each}
</datalist>
{#if repositoriesError}
<span class="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs" role="status">
<span class="text-amber-700 dark:text-amber-300">{repositoriesError}</span>
<button
type="button"
class="font-medium text-blue-600 hover:underline dark:text-blue-400"
onclick={() => void loadRepositories(true)}>Retry</button
>
<span class="text-gray-500 dark:text-gray-400">or enter namespace/name manually</span>
</span>
{:else}
<span class="text-xs text-gray-500 dark:text-gray-400">
{repositoriesLoading
? "Loading your repositories…"
: repositoryOptions.length
? `${repositoryOptions.length} recent repositories available`
: "Choose from your HF assets or enter namespace/name"}
</span>
{/if}
</label>
</div>
{:else if repoKind === "git"}
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">HTTPS Git URL</span>
<input
type="url"
bind:value={gitRef}
required
inputmode="url"
autocomplete="url"
placeholder="https://github.com/org/repository.git"
class={fieldClass}
/>
</label>
{/if}
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Working branch</span>
<input type="text" bind:value={branch} required autocomplete="off" class={fieldClass} />
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Hardware</span>
<select bind:value={flavor} class={fieldClass}>
<option value="cpu-basic">CPU Basic</option>
</select>
<span class="text-xs text-gray-500 dark:text-gray-400">Fast-starting CPU sandbox</span>
</label>
</div>
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Approval policy</span>
<select
bind:value={policy}
class={fieldClass}
aria-label="Approval policy"
aria-describedby="agent-policy-description"
>
{#each policyOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
<span id="agent-policy-description" class="text-xs text-gray-500 dark:text-gray-400">
{policyOptions.find((option) => option.value === policy)?.description}
</span>
</label>
<div class="grid gap-3 sm:grid-cols-2">
<label class="flex min-w-0 flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Agent model</span>
<select bind:value={model} class={fieldClass} aria-label="Agent model">
{#each availableModels as option (option.id)}
<option value={option.id}>{option.displayName || option.name || option.id}</option>
{/each}
{#if !availableModels.length}
<option value="" disabled>No compatible Agent models</option>
{/if}
</select>
</label>
<label class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-gray-800 dark:text-gray-200">Thinking level</span>
<select bind:value={thinkingLevel} class={fieldClass} aria-label="Thinking level">
{#each thinkingOptions as option (option.value)}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</label>
</div>
{#if validationError}
<p
role="alert"
class="rounded-xl border border-red-300/70 bg-red-50 px-3.5 py-2.5 text-sm text-red-700 dark:border-red-500/40 dark:bg-red-500/10 dark:text-red-300"
>
{validationError}
</p>
{/if}
<footer class="flex flex-col-reverse gap-2 pt-1 sm:flex-row sm:justify-end">
<button
type="button"
disabled={saving}
class="min-h-11 rounded-xl border border-gray-300 bg-white px-4 text-sm font-medium text-gray-800 outline-hidden hover:bg-gray-50 focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:hover:bg-gray-700 dark:focus-visible:ring-offset-gray-800"
onclick={cancel}
>
Cancel
</button>
<button
type="submit"
disabled={saving}
class="min-h-11 rounded-xl bg-gray-900 px-4 text-sm font-semibold text-white outline-hidden hover:bg-black focus-visible:ring-2 focus-visible:ring-blue-500 focus-visible:ring-offset-2 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white dark:focus-visible:ring-offset-gray-800"
>
{saving ? "Saving workspace…" : "Use this workspace"}
</button>
</footer>
</form>