Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/web/src/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -2156,6 +2156,14 @@ select:disabled,
color: var(--muted-foreground);
}

/* Inline validation inside a connect form (e.g. an Ollama model Ollama
doesn't actually report) — same treatment as `.chat-thread-error`. */
.onboarding-inline-error {
margin: 0.25rem 0 0;
font-size: 0.82rem;
color: var(--destructive);
}

/* The secondary three providers, tucked behind a subtle expander so the
primary six-card row is what a person sees first — same square-cornered
chrome as the rest of the wizard, not a popover. Full-width so it reads
Expand Down
10 changes: 9 additions & 1 deletion apps/web/src/chat/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,15 @@ export function Composer({
disabled={disabled}
onChange={(event) => setText(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.shiftKey) {
if (event.key !== "Enter") return;
// Auto-repeat fires the same keydown many times while a key is
// held; without this guard that means many sends (plain Enter)
// or many newlines (Shift+Enter) from one keystroke.
if (event.repeat) {
event.preventDefault();
return;
}
if (!event.shiftKey) {
event.preventDefault();
send();
}
Expand Down
64 changes: 64 additions & 0 deletions apps/web/src/onboarding/ollama-tags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
// fetchOllamaTags: validates the base URL actually reaches Ollama and
// parses its native /api/tags shape, on the bare origin (not /v1).

import { afterEach, describe, expect, test } from "bun:test";

import { fetchOllamaTags, OllamaTagsError, ollamaOrigin } from "./ollama-tags";

const realFetch = globalThis.fetch;
afterEach(() => {
globalThis.fetch = realFetch;
});

describe("ollamaOrigin", () => {
test("strips a trailing /v1 used for chat completions", () => {
expect(ollamaOrigin("http://localhost:11434/v1")).toBe("http://localhost:11434");
});
test("leaves a bare origin alone", () => {
expect(ollamaOrigin("http://localhost:11434")).toBe("http://localhost:11434");
});
});

describe("fetchOllamaTags", () => {
test("hits /api/tags on the bare origin and returns model names", async () => {
let requested = "";
globalThis.fetch = ((input: RequestInfo | URL) => {
requested = String(input);
return Promise.resolve(
new Response(JSON.stringify({ models: [{ name: "qwen2.5:14b" }, { name: "llama3" }] }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}) as typeof fetch;

const tags = await fetchOllamaTags("http://localhost:11434/v1");
expect(requested).toBe("http://localhost:11434/api/tags");
expect(tags).toEqual(["qwen2.5:14b", "llama3"]);
});

test("rejects with a clear message when the server isn't Ollama", async () => {
globalThis.fetch = ((input: RequestInfo | URL) => {
void input;
return Promise.resolve(new Response("not found", { status: 404 }));
}) as typeof fetch;
await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf(
OllamaTagsError,
);
});

test("rejects when the response shape doesn't match", async () => {
globalThis.fetch = ((input: RequestInfo | URL) => {
void input;
return Promise.resolve(
new Response(JSON.stringify({ nope: true }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
}) as typeof fetch;
await expect(fetchOllamaTags("http://localhost:11434/v1")).rejects.toBeInstanceOf(
OllamaTagsError,
);
});
});
49 changes: 49 additions & 0 deletions apps/web/src/onboarding/ollama-tags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Validates a local Ollama connect against the server the user actually
// typed in, from the browser — the hub never sees this request. `GET
// /api/tags` is Ollama's own native listing route, served on the plain
// base origin even though the chat-completions base URL a caller stores
// carries a trailing `/v1`.

import { type } from "arktype";

export class OllamaTagsError extends Error {}

const TagsResponse = type({ models: type({ name: "string" }).array() });

/** Ollama's native routes (`/api/tags`) live on the bare origin; the
* OpenAI-compatible chat routes this app stores as the offering's base URL
* live under `/v1` on that same origin. */
export function ollamaOrigin(baseURL: string): string {
return baseURL
.trim()
.replace(/\/v1\/?$/, "")
.replace(/\/+$/, "");
}

/** The tags this Ollama server currently has pulled, straight from the
* browser. Throws `OllamaTagsError` with a message safe to show as-is. */
export async function fetchOllamaTags(baseURL: string): Promise<readonly string[]> {
const origin = ollamaOrigin(baseURL);
if (origin === "") {
throw new OllamaTagsError("Enter a base URL first.");
}
let response: Response;
try {
response = await fetch(`${origin}/api/tags`, { headers: { accept: "application/json" } });
} catch (cause) {
throw new OllamaTagsError(
`Couldn't reach Ollama at ${origin}: ${cause instanceof Error ? cause.message : String(cause)}`,
);
}
if (!response.ok) {
throw new OllamaTagsError(
`Ollama at ${origin} answered ${String(response.status)} for /api/tags.`,
);
}
const body: unknown = await response.json().catch(() => undefined);
const parsed = TagsResponse(body);
if (parsed instanceof type.errors) {
throw new OllamaTagsError(`Unexpected response shape from ${origin}/api/tags.`);
}
return parsed.models.map((model) => model.name);
}
56 changes: 48 additions & 8 deletions apps/web/src/onboarding/provider-connect-step.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
// Connects one provider credential through the stock catalog routes and
// derives the single offering it mints; a tenant that already resolves an
// offering skips this step (see `resolveExistingOffering`).
import { Button, Input, RadioGroup, RadioOption } from "@corbits/react-ui";
import { Button, Input, RadioGroup, RadioOption, Select } from "@corbits/react-ui";
import { getResolvedCatalog, shadowOffering } from "@/settings/inference";
import { reportError } from "@corbits/error-sink";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import type { FormEvent } from "react";

import type { ModelProviderPlugin } from "@intx/types";

import { fetchOllamaTags } from "./ollama-tags";

export type ProviderOption = {
readonly plugin: ModelProviderPlugin;
readonly label: string;
Expand Down Expand Up @@ -124,14 +127,30 @@ export function ProviderConnectStep({
const option =
PROVIDER_OPTIONS.find((candidate) => candidate.plugin === selected) ?? PROVIDER_OPTIONS[0];
const isLocal = option?.local === true;

// Fetched straight from the browser to the user-supplied base URL — this
// never touches the hub. Validates the base URL is actually an Ollama
// server and offers exactly the models it has pulled, so a fresh local
// setup can't name a model Ollama doesn't have.
const tagsQuery = useQuery({
queryKey: ["onboarding", "ollama-tags", baseURL.trim()],
queryFn: () => fetchOllamaTags(baseURL),
enabled: isLocal && baseURL.trim().length > 0,
retry: false,
staleTime: 10_000,
});
const tags = tagsQuery.data ?? [];
const modelKnown = !isLocal || tags.includes(modelName);

const ready = isLocal
? baseURL.trim().length > 0 && modelName.trim().length > 0
? baseURL.trim().length > 0 && modelName.trim().length > 0 && modelKnown
: apiKey.trim().length > 0;

function selectOption(plugin: ModelProviderPlugin) {
setSelected(plugin);
const next = PROVIDER_OPTIONS.find((candidate) => candidate.plugin === plugin);
setBaseURL(next?.local === true ? next.baseURL : "");
setModelName("");
}

async function handleSubmit(event: FormEvent<HTMLFormElement>) {
Expand Down Expand Up @@ -198,15 +217,36 @@ export function ProviderConnectStep({
</label>
<label>
Model
<Input
type="text"
autoComplete="off"
placeholder="qwen2.5:14b"
<Select
value={modelName}
aria-label="Model"
disabled={tagsQuery.isFetching || tags.length === 0}
onChange={(event) => setModelName(event.target.value)}
required
/>
>
<option value="">
{tagsQuery.isFetching
? "Checking Ollama…"
: tags.length === 0
? "No models found"
: "Select a model…"}
</option>
{tags.map((tag) => (
<option key={tag} value={tag}>
{tag}
</option>
))}
</Select>
</label>
{tagsQuery.isError ? (
<p className="onboarding-inline-error" role="alert">
{tagsQuery.error instanceof Error ? tagsQuery.error.message : String(tagsQuery.error)}
</p>
) : null}
{!tagsQuery.isError && modelName !== "" && !modelKnown ? (
<p className="onboarding-inline-error" role="alert">
{modelName} is not one of the models Ollama reports at this base URL.
</p>
) : null}
</>
) : (
<label>
Expand Down
7 changes: 5 additions & 2 deletions apps/web/src/pages/chat-thread-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -185,10 +185,13 @@ function ChatTranscript({
);

// Only this agent's asks belong in this transcript; the bench-wide list
// is filtered down to the run address this chat talks to.
// is filtered down to the run address this chat talks to. Polling only
// covers the same "agent still starting" window `chatQuery` polls for —
// once live, the inbox subscription's invalidation above is the only
// trigger, so a single mailbox event issues one read, not two.
const liveAddress = chat?.agent.liveAddress ?? null;
const approvalsQuery = usePendingApprovals(tenantId, {
refetchInterval: liveAddress !== null ? 3000 : false,
refetchInterval: liveAddress === null ? 3000 : false,
});
const approvals =
approvalsQuery.kind === "ready" && liveAddress !== null
Expand Down
17 changes: 14 additions & 3 deletions apps/web/src/pages/workbench-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import { useBench } from "../bench-context";
import { createFetchStockHub } from "../needs-converge";
import { usePendingApprovals } from "../pending-approvals";
import { workbenchKeys } from "../chat-path";
import { tenantKeys } from "../query-client";
import { StageTopBar } from "../shell/stage-top-bar";
import { redeployWorkbenchAgent } from "../workbench-create";
import { workbenchIdFromPath } from "../workbench-path";
Expand Down Expand Up @@ -136,9 +137,13 @@ function WorkbenchInfoColumn({
readonly latestMessage: WorkbenchMessage | undefined;
readonly participants: readonly WorkbenchParticipant[];
}) {
const anyAgentLive = participants.some((p) => p.kind === "agent" && p.address !== "");
// Poll only while an agent in this workbench is still starting (released,
// no live address yet) — the same window `participants` itself polls for.
// Once every agent is live, the inbox subscription's invalidation is the
// only trigger, so one mailbox event issues one read, not two.
const anyAgentStarting = participants.some((p) => p.kind === "agent" && p.address === "");
const approvalsQuery = usePendingApprovals(workbenchTenantId, {
refetchInterval: anyAgentLive ? 3000 : false,
refetchInterval: anyAgentStarting ? 3000 : false,
});
const artifactsQuery = useAPIQuery(
`/api/tenants/${workbenchTenantId}/artifacts`,
Expand Down Expand Up @@ -269,11 +274,17 @@ function Workbench({ workbenchTenantId }: { readonly workbenchTenantId: string }
});

// The workbench mailbox stream is the only signal that an agent answered; it
// carries no thread identity, so it invalidates rather than patches.
// carries no thread identity, so it invalidates rather than patches. An
// agent that parks on an ask sends no mail, but the same stream ticks over
// its turn, so the approvals read invalidates here too instead of the info
// column polling it continuously while any agent is simply live.
useEffect(
() =>
subscribeToInbox(workbenchTenantId, () => {
void queryClient.invalidateQueries({ queryKey: workbenchKeys.scope(workbenchTenantId) });
void queryClient.invalidateQueries({
queryKey: tenantKeys.pendingApprovals(workbenchTenantId),
});
}),
[workbenchTenantId, queryClient],
);
Expand Down
22 changes: 22 additions & 0 deletions apps/web/src/settings/credentials-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,25 @@ export function deleteCredential(tenantId: string, credentialId: string): Promis
{ method: "DELETE" },
);
}

export type UpdateCredentialInput = {
readonly name?: string;
readonly description?: string;
/** `baseURL`/`model` are this form's own convention for a local,
* Ollama-style credential — the stock route stores whatever object is
* sent here as opaque `metadata`, nothing more. */
readonly metadata?: { readonly baseURL?: string; readonly model?: string };
};

export function updateCredential(
tenantId: string,
credentialId: string,
input: UpdateCredentialInput,
): Promise<Credential> {
return request(
`/api/tenants/${tenantId}/credentials/${credentialId}`,
CredentialResponse,
"updating that credential",
{ method: "PATCH", body: JSON.stringify(input) },
);
}
Loading
Loading