mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): AI providers layout — org-grouped two-column override list; key cards beside self-hosted
Per-agent mix rows group under Board / Main PM / cell / intake headers in a two-column grid with compact selects; Grok + Ollama key cards stack in the left column with the self-hosted section to their right. Save-mix and inherit semantics untouched.
This commit is contained in:
@@ -0,0 +1,222 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
const {
|
||||||
|
catalog,
|
||||||
|
getOllamaKey,
|
||||||
|
setOllamaKey,
|
||||||
|
getGrokKey,
|
||||||
|
setGrokKey,
|
||||||
|
getMode,
|
||||||
|
applyMode,
|
||||||
|
getSelfHostedConfig,
|
||||||
|
saveSelfHostedConfig,
|
||||||
|
testSelfHosted,
|
||||||
|
getSelfHostedModels,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
catalog: vi.fn(async () => [
|
||||||
|
{
|
||||||
|
model_name: "claude-opus-4-6",
|
||||||
|
provider_type: "anthropic",
|
||||||
|
display_name: "Claude Opus 4.6",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
model_name: "grok-build-0.1",
|
||||||
|
provider_type: "grok",
|
||||||
|
display_name: "Grok Build 0.1",
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
getOllamaKey: vi.fn(async () => ({ has_key: false, enabled: true })),
|
||||||
|
setOllamaKey: vi.fn(async () => ({ has_key: true, enabled: true })),
|
||||||
|
getGrokKey: vi.fn(async () => ({ has_key: false, enabled: true })),
|
||||||
|
setGrokKey: vi.fn(async () => ({ has_key: true, enabled: true })),
|
||||||
|
getMode: vi.fn(async () => ({ mode: "anthropic", assignments: [] })),
|
||||||
|
applyMode: vi.fn(async (payload: { mode: string }) => ({
|
||||||
|
mode: payload.mode,
|
||||||
|
assignments: [],
|
||||||
|
})),
|
||||||
|
getSelfHostedConfig: vi.fn(async () => ({
|
||||||
|
base_url: null,
|
||||||
|
has_token: false,
|
||||||
|
enabled: true,
|
||||||
|
})),
|
||||||
|
saveSelfHostedConfig: vi.fn(async () => ({
|
||||||
|
base_url: "http://localhost:11434",
|
||||||
|
has_token: false,
|
||||||
|
enabled: true,
|
||||||
|
})),
|
||||||
|
testSelfHosted: vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
model_count: null,
|
||||||
|
error: null,
|
||||||
|
})),
|
||||||
|
getSelfHostedModels: vi.fn(async () => []),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/api/providers", () => ({
|
||||||
|
providersApi: {
|
||||||
|
catalog,
|
||||||
|
getOllamaKey,
|
||||||
|
setOllamaKey,
|
||||||
|
getGrokKey,
|
||||||
|
setGrokKey,
|
||||||
|
getMode,
|
||||||
|
applyMode,
|
||||||
|
getSelfHostedConfig,
|
||||||
|
saveSelfHostedConfig,
|
||||||
|
testSelfHosted,
|
||||||
|
getSelfHostedModels,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { AIRoutingCard } from "../ai-routing-card";
|
||||||
|
|
||||||
|
function withQueryClient(ui: ReactNode) {
|
||||||
|
const client = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
||||||
|
});
|
||||||
|
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("AIRoutingCard", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
catalog.mockClear();
|
||||||
|
getOllamaKey.mockClear();
|
||||||
|
setOllamaKey.mockClear();
|
||||||
|
getGrokKey.mockClear();
|
||||||
|
setGrokKey.mockClear();
|
||||||
|
getMode.mockClear();
|
||||||
|
applyMode.mockClear();
|
||||||
|
getSelfHostedConfig.mockClear();
|
||||||
|
saveSelfHostedConfig.mockClear();
|
||||||
|
testSelfHosted.mockClear();
|
||||||
|
getSelfHostedModels.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders both key cards and the self-hosted section as one two-column band, Grok+Ollama stacked left", async () => {
|
||||||
|
render(withQueryClient(<AIRoutingCard />));
|
||||||
|
await screen.findByText("Grok (xAI) API key");
|
||||||
|
|
||||||
|
const grokSection = screen.getByText("Grok (xAI) API key").closest("section");
|
||||||
|
const ollamaSection = screen
|
||||||
|
.getByText("Ollama Cloud API key")
|
||||||
|
.closest("section");
|
||||||
|
expect(grokSection).toBeInTheDocument();
|
||||||
|
expect(ollamaSection).toBeInTheDocument();
|
||||||
|
|
||||||
|
// Grok + Ollama share the same left-column container.
|
||||||
|
const leftColumn = grokSection?.parentElement;
|
||||||
|
expect(leftColumn).toContainElement(ollamaSection as HTMLElement);
|
||||||
|
|
||||||
|
// That left column and the Self-Hosted section are the two children of
|
||||||
|
// one responsive two-column grid band.
|
||||||
|
const selfHostedHeading = screen.getByText("Self-Hosted LLM");
|
||||||
|
const band = leftColumn?.parentElement;
|
||||||
|
expect(band).toContainElement(selfHostedHeading);
|
||||||
|
expect(band?.className).toContain("grid");
|
||||||
|
expect(band?.className).toContain("lg:grid-cols-2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows 'not set' badges by default and saves+clears the Grok key", async () => {
|
||||||
|
render(withQueryClient(<AIRoutingCard />));
|
||||||
|
await screen.findByText("Grok (xAI) API key");
|
||||||
|
expect(screen.getAllByText("not set")).toHaveLength(2); // Grok + Ollama
|
||||||
|
|
||||||
|
const grokInput = screen.getByPlaceholderText("xai-…");
|
||||||
|
fireEvent.change(grokInput, { target: { value: "xai-secret" } });
|
||||||
|
const grokSection = screen
|
||||||
|
.getByText("Grok (xAI) API key")
|
||||||
|
.closest("section")!;
|
||||||
|
const saveButton = Array.from(grokSection.querySelectorAll("button")).find(
|
||||||
|
(b) => b.textContent === "Save",
|
||||||
|
)!;
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() => expect(setGrokKey).toHaveBeenCalledWith("xai-secret"));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving the Ollama key clears the input on success", async () => {
|
||||||
|
render(withQueryClient(<AIRoutingCard />));
|
||||||
|
await screen.findByText("Ollama Cloud API key");
|
||||||
|
|
||||||
|
const ollamaInput = screen.getByPlaceholderText(
|
||||||
|
"ollama_xxx…",
|
||||||
|
) as HTMLInputElement;
|
||||||
|
fireEvent.change(ollamaInput, { target: { value: "ollama_secret" } });
|
||||||
|
|
||||||
|
const ollamaSection = screen
|
||||||
|
.getByText("Ollama Cloud API key")
|
||||||
|
.closest("section")!;
|
||||||
|
const saveButton = Array.from(
|
||||||
|
ollamaSection.querySelectorAll("button"),
|
||||||
|
).find((b) => b.textContent === "Save")!;
|
||||||
|
fireEvent.click(saveButton);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(setOllamaKey).toHaveBeenCalledWith("ollama_secret"),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(ollamaInput.value).toBe(""));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("groups the per-agent override list by org structure with two-column rows inside each group", async () => {
|
||||||
|
render(withQueryClient(<AIRoutingCard />));
|
||||||
|
await screen.findByText("Per-agent override (mix mode)");
|
||||||
|
|
||||||
|
for (const title of [
|
||||||
|
"Board",
|
||||||
|
"Main PM",
|
||||||
|
"Backend Cell",
|
||||||
|
"Frontend Cell",
|
||||||
|
"UX/UI Cell",
|
||||||
|
"Intake / Secretary",
|
||||||
|
]) {
|
||||||
|
expect(
|
||||||
|
screen.getByRole("heading", { level: 4, name: title }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Spot-check a row from each end of the org chart.
|
||||||
|
expect(screen.getByText("product-owner")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("be-dev-1")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("secretary-1")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// The Backend Cell group renders its 5 rows inside a 2-column grid.
|
||||||
|
const backendHeader = screen.getByRole("heading", {
|
||||||
|
level: 4,
|
||||||
|
name: "Backend Cell",
|
||||||
|
});
|
||||||
|
const rowGrid = backendHeader.nextElementSibling as HTMLElement;
|
||||||
|
expect(rowGrid.className).toContain("sm:grid-cols-2");
|
||||||
|
expect(rowGrid.querySelectorAll(":scope > div")).toHaveLength(5);
|
||||||
|
|
||||||
|
// All 20 agents still render exactly one override select each.
|
||||||
|
const mixSection = screen
|
||||||
|
.getByText("Per-agent override (mix mode)")
|
||||||
|
.closest("section")!;
|
||||||
|
expect(
|
||||||
|
mixSection.querySelectorAll('[role="combobox"]'),
|
||||||
|
).toHaveLength(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("saving the mix with no picks shows an error and never calls applyMode", async () => {
|
||||||
|
render(withQueryClient(<AIRoutingCard />));
|
||||||
|
await screen.findByText("Per-agent override (mix mode)");
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Save mix" }));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(toast.error).toHaveBeenCalledWith(
|
||||||
|
"Pick a model for at least one agent",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(applyMode).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -51,35 +51,63 @@ import { Checkbox } from "@/components/ui/checkbox";
|
|||||||
|
|
||||||
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
|
// Matches the roboco agents_config AGENT_ROLE_MAP / AGENT_TEAM_MAP.
|
||||||
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
|
// Hard-coded so Mix mode shows a stable 18-row picker without an extra
|
||||||
// server round-trip. Order mirrors the org chart in CLAUDE.md.
|
// server round-trip. Grouped + ordered to mirror the org chart in CLAUDE.md.
|
||||||
//
|
//
|
||||||
// NOTE: CEO is explicitly excluded — it's the human-in-the-loop seat
|
// NOTE: CEO is explicitly excluded — it's the human-in-the-loop seat
|
||||||
// (Renzo), not an LLM-backed agent. Routing it anywhere would be a
|
// (Renzo), not an LLM-backed agent. Routing it anywhere would be a
|
||||||
// no-op in spawn_agent but confusing in the UI.
|
// no-op in spawn_agent but confusing in the UI.
|
||||||
const AGENTS: { slug: string; label: string }[] = [
|
const AGENT_GROUPS: { title: string; agents: { slug: string; label: string }[] }[] = [
|
||||||
|
{
|
||||||
|
title: "Board",
|
||||||
|
agents: [
|
||||||
{ slug: "product-owner", label: "Product Owner" },
|
{ slug: "product-owner", label: "Product Owner" },
|
||||||
{ slug: "head-marketing", label: "Head of Marketing" },
|
{ slug: "head-marketing", label: "Head of Marketing" },
|
||||||
{ slug: "auditor", label: "Auditor" },
|
{ slug: "auditor", label: "Auditor" },
|
||||||
{ slug: "main-pm", label: "Main PM" },
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Main PM",
|
||||||
|
agents: [{ slug: "main-pm", label: "Main PM" }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Backend Cell",
|
||||||
|
agents: [
|
||||||
{ slug: "be-pm", label: "Backend PM" },
|
{ slug: "be-pm", label: "Backend PM" },
|
||||||
{ slug: "be-dev-1", label: "Backend Dev 1" },
|
{ slug: "be-dev-1", label: "Backend Dev 1" },
|
||||||
{ slug: "be-dev-2", label: "Backend Dev 2" },
|
{ slug: "be-dev-2", label: "Backend Dev 2" },
|
||||||
{ slug: "be-qa", label: "Backend QA" },
|
{ slug: "be-qa", label: "Backend QA" },
|
||||||
{ slug: "be-doc", label: "Backend Documenter" },
|
{ slug: "be-doc", label: "Backend Documenter" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Frontend Cell",
|
||||||
|
agents: [
|
||||||
{ slug: "fe-pm", label: "Frontend PM" },
|
{ slug: "fe-pm", label: "Frontend PM" },
|
||||||
{ slug: "fe-dev-1", label: "Frontend Dev 1" },
|
{ slug: "fe-dev-1", label: "Frontend Dev 1" },
|
||||||
{ slug: "fe-dev-2", label: "Frontend Dev 2" },
|
{ slug: "fe-dev-2", label: "Frontend Dev 2" },
|
||||||
{ slug: "fe-qa", label: "Frontend QA" },
|
{ slug: "fe-qa", label: "Frontend QA" },
|
||||||
{ slug: "fe-doc", label: "Frontend Documenter" },
|
{ slug: "fe-doc", label: "Frontend Documenter" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "UX/UI Cell",
|
||||||
|
agents: [
|
||||||
{ slug: "ux-pm", label: "UX/UI PM" },
|
{ slug: "ux-pm", label: "UX/UI PM" },
|
||||||
{ slug: "ux-dev-1", label: "UX/UI Dev" },
|
{ slug: "ux-dev-1", label: "UX/UI Dev" },
|
||||||
{ slug: "ux-qa", label: "UX/UI QA" },
|
{ slug: "ux-qa", label: "UX/UI QA" },
|
||||||
{ slug: "ux-doc", label: "UX/UI Documenter" },
|
{ slug: "ux-doc", label: "UX/UI Documenter" },
|
||||||
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok (grok CLI)
|
],
|
||||||
// are the supported runtimes; assigning a Grok model routes them to
|
},
|
||||||
// the grok-prompter / grok-secretary image.
|
{
|
||||||
|
title: "Intake / Secretary",
|
||||||
|
agents: [
|
||||||
|
// Interactive (held-open chat) roles. Claude (SDK driver) and Grok
|
||||||
|
// (grok CLI) are the supported runtimes; assigning a Grok model routes
|
||||||
|
// them to the grok-prompter / grok-secretary image.
|
||||||
{ slug: "intake-1", label: "Intake (Prompter)" },
|
{ slug: "intake-1", label: "Intake (Prompter)" },
|
||||||
{ slug: "secretary-1", label: "Secretary" },
|
{ slug: "secretary-1", label: "Secretary" },
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function AIRoutingCard() {
|
export function AIRoutingCard() {
|
||||||
@@ -319,10 +347,15 @@ export function AIRoutingCard() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-6">
|
<CardContent className="space-y-6">
|
||||||
|
{/* -------- Key cards band: Grok+Ollama (left) / Self-Hosted (right) -------- */}
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
|
<div className="space-y-6">
|
||||||
{/* -------- Grok (xAI) key -------- */}
|
{/* -------- Grok (xAI) key -------- */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">Grok (xAI) API key</Label>
|
<Label className="text-sm font-medium">
|
||||||
|
Grok (xAI) API key
|
||||||
|
</Label>
|
||||||
{hasGrokKey ? (
|
{hasGrokKey ? (
|
||||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
||||||
<KeyRound className="h-3 w-3" /> key set
|
<KeyRound className="h-3 w-3" /> key set
|
||||||
@@ -361,8 +394,8 @@ export function AIRoutingCard() {
|
|||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Used for grok-build-0.1 at api.x.ai/v1. Stored Fernet-encrypted
|
Used for grok-build-0.1 at api.x.ai/v1. Stored
|
||||||
server-side; never returned by the API.
|
Fernet-encrypted server-side; never returned by the API.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -372,7 +405,9 @@ export function AIRoutingCard() {
|
|||||||
{/* -------- Ollama key -------- */}
|
{/* -------- Ollama key -------- */}
|
||||||
<section className="space-y-2">
|
<section className="space-y-2">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<Label className="text-sm font-medium">Ollama Cloud API key</Label>
|
<Label className="text-sm font-medium">
|
||||||
|
Ollama Cloud API key
|
||||||
|
</Label>
|
||||||
{hasOllamaKey ? (
|
{hasOllamaKey ? (
|
||||||
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
<Badge className="bg-emerald-500/10 text-emerald-600 border-0">
|
||||||
<KeyRound className="h-3 w-3" /> key set
|
<KeyRound className="h-3 w-3" /> key set
|
||||||
@@ -413,12 +448,12 @@ export function AIRoutingCard() {
|
|||||||
</label>
|
</label>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Stored Fernet-encrypted server-side; never returned by the API.
|
Stored Fernet-encrypted server-side; never returned by the
|
||||||
|
API.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
<Separator />
|
|
||||||
|
|
||||||
{/* -------- Self-Hosted LLM -------- */}
|
{/* -------- Self-Hosted LLM -------- */}
|
||||||
<SelfHostedSection
|
<SelfHostedSection
|
||||||
@@ -426,6 +461,7 @@ export function AIRoutingCard() {
|
|||||||
onTestResult={handleSelfHostedTestResult}
|
onTestResult={handleSelfHostedTestResult}
|
||||||
onTestSuccess={() => undefined}
|
onTestSuccess={() => undefined}
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<Separator />
|
<Separator />
|
||||||
|
|
||||||
@@ -560,14 +596,24 @@ export function AIRoutingCard() {
|
|||||||
all per-agent overrides with what's picked here.
|
all per-agent overrides with what's picked here.
|
||||||
</p>
|
</p>
|
||||||
<div className="divide-y rounded-md border">
|
<div className="divide-y rounded-md border">
|
||||||
{AGENTS.map((a) => (
|
{AGENT_GROUPS.map((group) => (
|
||||||
|
<div key={group.title} className="p-3">
|
||||||
|
<h4 className="mb-2 text-xs font-semibold text-muted-foreground uppercase tracking-wider">
|
||||||
|
{group.title}
|
||||||
|
</h4>
|
||||||
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||||
|
{group.agents.map((a) => (
|
||||||
<div
|
<div
|
||||||
key={a.slug}
|
key={a.slug}
|
||||||
className="grid grid-cols-[1fr_280px] items-center gap-2 px-3 py-2"
|
className="grid grid-cols-[1fr_170px] items-center gap-2 rounded-md border px-2 py-1.5"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<div className="font-mono text-sm">{a.slug}</div>
|
<div className="truncate font-mono text-xs">
|
||||||
<div className="text-xs text-muted-foreground">{a.label}</div>
|
{a.slug}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-muted-foreground">
|
||||||
|
{a.label}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={mixMap[a.slug] ?? ""}
|
value={mixMap[a.slug] ?? ""}
|
||||||
@@ -578,11 +624,13 @@ export function AIRoutingCard() {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-full">
|
<SelectTrigger size="sm" className="w-full text-xs">
|
||||||
<SelectValue placeholder="(inherit)" />
|
<SelectValue placeholder="(inherit)" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="__clear__">(inherit global)</SelectItem>
|
<SelectItem value="__clear__">
|
||||||
|
(inherit global)
|
||||||
|
</SelectItem>
|
||||||
|
|
||||||
{/* Anthropic models */}
|
{/* Anthropic models */}
|
||||||
{catalogAnthropicOnly.length > 0 && (
|
{catalogAnthropicOnly.length > 0 && (
|
||||||
@@ -592,8 +640,14 @@ export function AIRoutingCard() {
|
|||||||
Anthropic
|
Anthropic
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{catalogAnthropicOnly.map(
|
{catalogAnthropicOnly.map(
|
||||||
(c: { model_name: string; display_name: string }) => (
|
(c: {
|
||||||
<SelectItem key={c.model_name} value={c.model_name}>
|
model_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c.model_name}
|
||||||
|
value={c.model_name}
|
||||||
|
>
|
||||||
{c.display_name}
|
{c.display_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
),
|
||||||
@@ -609,8 +663,14 @@ export function AIRoutingCard() {
|
|||||||
Grok (xAI)
|
Grok (xAI)
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{catalogGrokOnly.map(
|
{catalogGrokOnly.map(
|
||||||
(c: { model_name: string; display_name: string }) => (
|
(c: {
|
||||||
<SelectItem key={c.model_name} value={c.model_name}>
|
model_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c.model_name}
|
||||||
|
value={c.model_name}
|
||||||
|
>
|
||||||
{c.display_name}
|
{c.display_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
),
|
||||||
@@ -626,8 +686,14 @@ export function AIRoutingCard() {
|
|||||||
Ollama Cloud
|
Ollama Cloud
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{catalogOllamaOnly.map(
|
{catalogOllamaOnly.map(
|
||||||
(c: { model_name: string; display_name: string }) => (
|
(c: {
|
||||||
<SelectItem key={c.model_name} value={c.model_name}>
|
model_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c.model_name}
|
||||||
|
value={c.model_name}
|
||||||
|
>
|
||||||
{c.display_name}
|
{c.display_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
),
|
||||||
@@ -643,7 +709,10 @@ export function AIRoutingCard() {
|
|||||||
Self-Hosted
|
Self-Hosted
|
||||||
</SelectLabel>
|
</SelectLabel>
|
||||||
{selfHostedModels.map((m: SelfHostedModel) => (
|
{selfHostedModels.map((m: SelfHostedModel) => (
|
||||||
<SelectItem key={m.model_name} value={m.model_name}>
|
<SelectItem
|
||||||
|
key={m.model_name}
|
||||||
|
value={m.model_name}
|
||||||
|
>
|
||||||
{m.display_name}
|
{m.display_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -655,8 +724,14 @@ export function AIRoutingCard() {
|
|||||||
catalogOllamaOnly.length === 0 &&
|
catalogOllamaOnly.length === 0 &&
|
||||||
selfHostedModels.length === 0 &&
|
selfHostedModels.length === 0 &&
|
||||||
catalogForMix.map(
|
catalogForMix.map(
|
||||||
(c: { model_name: string; display_name: string }) => (
|
(c: {
|
||||||
<SelectItem key={c.model_name} value={c.model_name}>
|
model_name: string;
|
||||||
|
display_name: string;
|
||||||
|
}) => (
|
||||||
|
<SelectItem
|
||||||
|
key={c.model_name}
|
||||||
|
value={c.model_name}
|
||||||
|
>
|
||||||
{c.display_name} — {c.model_name}
|
{c.display_name} — {c.model_name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
),
|
),
|
||||||
@@ -666,6 +741,9 @@ export function AIRoutingCard() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
{catalogOllamaOnly.length === 0 ? (
|
{catalogOllamaOnly.length === 0 ? (
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
Ollama catalog empty — check /api/providers/catalog.
|
Ollama catalog empty — check /api/providers/catalog.
|
||||||
|
|||||||
Reference in New Issue
Block a user