Per-tree AI model policy (owner-only admin view)

The operator decides which model providers exist (env / registry — Anthropic,
OpenAI, x.AI, Ollama, several at once). The *tree owner* decides who uses which:

- Members' assistant -> one configured provider (or none)
- Recommender (association/connection finder) -> one configured provider (or none)
- Owner -> may use any configured provider

Backend: two nullable columns on `trees` (ai_member_provider,
ai_recommender_provider) + migration; `configured_llm_providers()` exposes the
registry as {name, model} with no secrets; owner-gated GET/PATCH
/trees/{id}/ai validate names against the configured set. Frontend: owner-only
"AI models" page with a dropdown per role, graceful 403 for non-owners, and a
sidebar link.

Per-model-within-a-provider selection is a follow-up; today each provider maps
to its single configured model.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Justin Paul <justin@jpaul.me>
This commit is contained in:
2026-06-09 20:52:30 -04:00
parent ceafb299d6
commit c6b1e72130
12 changed files with 717 additions and 1 deletions
+171
View File
@@ -0,0 +1,171 @@
"use client";
import { useParams } from "next/navigation";
import { useCallback, useEffect, useState } from "react";
import { api } from "@/lib/api/client";
import type { components } from "@/lib/api/schema";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
type Policy = components["schemas"]["TreeAiPolicyRead"];
// `null`/"" means "no AI for this role". The <select> uses "" for that option
// and we translate to null on save.
const NONE = "";
export default function AiPolicyPage() {
const { id: treeId } = useParams<{ id: string }>();
const [policy, setPolicy] = useState<Policy | null>(null);
const [member, setMember] = useState<string>(NONE);
const [recommender, setRecommender] = useState<string>(NONE);
const [ready, setReady] = useState(false);
const [forbidden, setForbidden] = useState(false);
const [saving, setSaving] = useState(false);
const [msg, setMsg] = useState<string | null>(null);
const load = useCallback(async () => {
const { data, response } = await api.GET("/api/v1/trees/{tree_id}/ai", {
params: { path: { tree_id: treeId } },
});
if (response.status === 403) {
setForbidden(true);
setReady(true);
return;
}
if (data) {
setPolicy(data);
setMember(data.member_provider ?? NONE);
setRecommender(data.recommender_provider ?? NONE);
}
setReady(true);
}, [treeId]);
useEffect(() => {
load();
}, [load]);
async function save() {
setSaving(true);
setMsg(null);
const { data, error } = await api.PATCH("/api/v1/trees/{tree_id}/ai", {
params: { path: { tree_id: treeId } },
body: {
member_provider: member || null,
recommender_provider: recommender || null,
},
});
setSaving(false);
if (error || !data) {
setMsg("Couldn't save — pick a provider your operator has configured.");
return;
}
setPolicy(data);
setMember(data.member_provider ?? NONE);
setRecommender(data.recommender_provider ?? NONE);
setMsg("Saved.");
}
if (!ready) {
return <div className="p-6 text-sm text-[var(--muted)]">Loading</div>;
}
if (forbidden) {
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-xl font-semibold">AI models</h1>
<p className="mt-2 text-sm text-[var(--muted)]">
Only the tree owner can configure which AI models this tree uses.
</p>
</div>
);
}
const providers = policy?.configured_providers ?? [];
const dirty =
member !== (policy?.member_provider ?? NONE) ||
recommender !== (policy?.recommender_provider ?? NONE);
const Select = ({
value,
onChange,
}: {
value: string;
onChange: (v: string) => void;
}) => (
<select
className="h-9 w-full max-w-xs rounded-md border border-[var(--border)] bg-[var(--surface)] px-2 text-sm"
value={value}
onChange={(e) => onChange(e.target.value)}
>
<option value={NONE}>No AI</option>
{providers.map((p) => (
<option key={p.name} value={p.name}>
{p.name} {p.model}
</option>
))}
</select>
);
return (
<div className="mx-auto max-w-2xl p-6">
<h1 className="text-xl font-semibold">AI models</h1>
<p className="mt-1 text-sm text-[var(--muted)]">
Choose which configured model each role uses. As the owner you can use any
configured provider; these settings pin members and the recommender to one.
</p>
{providers.length === 0 ? (
<Card className="mt-6">
<CardContent className="py-6 text-sm text-[var(--muted)]">
No AI providers are configured on this deployment. Set provider
credentials in the server environment (Anthropic, OpenAI, x.AI, or
Ollama) and they&apos;ll appear here.
</CardContent>
</Card>
) : (
<>
<Card className="mt-6">
<CardContent className="flex flex-col gap-6 py-6">
<div className="flex flex-col gap-2">
<div>
<div className="text-sm font-medium">Members&apos; assistant</div>
<div className="text-xs text-[var(--muted)]">
The model non-owner members&apos; AI assistant uses.
</div>
</div>
<Select value={member} onChange={setMember} />
</div>
<div className="flex flex-col gap-2">
<div>
<div className="text-sm font-medium">Recommender</div>
<div className="text-xs text-[var(--muted)]">
The model that finds associations and suggests connections.
</div>
</div>
<Select value={recommender} onChange={setRecommender} />
</div>
<div className="flex items-center gap-3">
<Button onClick={save} disabled={!dirty || saving}>
{saving ? "Saving…" : "Save"}
</Button>
{msg && <span className="text-sm text-[var(--muted)]">{msg}</span>}
</div>
</CardContent>
</Card>
<div className="mt-4 text-xs text-[var(--muted)]">
<span className="font-medium">Configured providers:</span>{" "}
{providers.map((p) => `${p.name} (${p.model})`).join(", ")}.
{policy?.default_provider && (
<> Default: {policy.default_provider}.</>
)}{" "}
As the owner you can use all of them.
</div>
</>
)}
</div>
);
}