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:
@@ -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'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' assistant</div>
|
||||
<div className="text-xs text-[var(--muted)]">
|
||||
The model non-owner members' 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>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Archive,
|
||||
ArrowDownUp,
|
||||
BookText,
|
||||
Bot,
|
||||
ClipboardCheck,
|
||||
Compass,
|
||||
FolderTree,
|
||||
@@ -151,6 +152,12 @@ export function AppSidebar({ onNavigate }: { onNavigate?: () => void }) {
|
||||
icon={UserPlus}
|
||||
active={pathname.startsWith(`/trees/${treeId}/members`)}
|
||||
/>
|
||||
<Item
|
||||
href={`/trees/${treeId}/ai`}
|
||||
label="AI models"
|
||||
icon={Bot}
|
||||
active={pathname.startsWith(`/trees/${treeId}/ai`)}
|
||||
/>
|
||||
<Item
|
||||
href={`/trees/${treeId}/recovery`}
|
||||
label="Recovery"
|
||||
|
||||
Vendored
+109
@@ -1031,6 +1031,24 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/v1/trees/{tree_id}/ai": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** Get Ai Policy */
|
||||
get: operations["get_ai_policy_api_v1_trees__tree_id__ai_get"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
/** Update Ai Policy */
|
||||
patch: operations["update_ai_policy_api_v1_trees__tree_id__ai_patch"];
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
@@ -1215,6 +1233,13 @@ export interface components {
|
||||
/** Updated */
|
||||
updated: number;
|
||||
};
|
||||
/** ConfiguredProvider */
|
||||
ConfiguredProvider: {
|
||||
/** Name */
|
||||
name: string;
|
||||
/** Model */
|
||||
model: string;
|
||||
};
|
||||
/** DeceasedApply */
|
||||
DeceasedApply: {
|
||||
/** Person Ids */
|
||||
@@ -1886,6 +1911,24 @@ export interface components {
|
||||
/** Token */
|
||||
token: string;
|
||||
};
|
||||
/** TreeAiPolicyRead */
|
||||
TreeAiPolicyRead: {
|
||||
/** Member Provider */
|
||||
member_provider: string | null;
|
||||
/** Recommender Provider */
|
||||
recommender_provider: string | null;
|
||||
/** Configured Providers */
|
||||
configured_providers: components["schemas"]["ConfiguredProvider"][];
|
||||
/** Default Provider */
|
||||
default_provider: string;
|
||||
};
|
||||
/** TreeAiPolicyUpdate */
|
||||
TreeAiPolicyUpdate: {
|
||||
/** Member Provider */
|
||||
member_provider?: string | null;
|
||||
/** Recommender Provider */
|
||||
recommender_provider?: string | null;
|
||||
};
|
||||
/** TreeCreate */
|
||||
TreeCreate: {
|
||||
/** Name */
|
||||
@@ -4651,4 +4694,70 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
get_ai_policy_api_v1_trees__tree_id__ai_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TreeAiPolicyRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
update_ai_policy_api_v1_trees__tree_id__ai_patch: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
tree_id: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["TreeAiPolicyUpdate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TreeAiPolicyRead"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
+186
-1
@@ -4093,6 +4093,100 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/v1/trees/{tree_id}/ai": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"ai"
|
||||
],
|
||||
"summary": "Get Ai Policy",
|
||||
"operationId": "get_ai_policy_api_v1_trees__tree_id__ai_get",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeAiPolicyRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"tags": [
|
||||
"ai"
|
||||
],
|
||||
"summary": "Update Ai Policy",
|
||||
"operationId": "update_ai_policy_api_v1_trees__tree_id__ai_patch",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "tree_id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"format": "uuid",
|
||||
"title": "Tree Id"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeAiPolicyUpdate"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Successful Response",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/TreeAiPolicyRead"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"422": {
|
||||
"description": "Validation Error",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HTTPValidationError"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
@@ -4683,6 +4777,24 @@
|
||||
],
|
||||
"title": "CleanupResult"
|
||||
},
|
||||
"ConfiguredProvider": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"type": "string",
|
||||
"title": "Name"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"title": "Model"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"model"
|
||||
],
|
||||
"title": "ConfiguredProvider"
|
||||
},
|
||||
"DeceasedApply": {
|
||||
"properties": {
|
||||
"person_ids": {
|
||||
@@ -6812,6 +6924,79 @@
|
||||
],
|
||||
"title": "TokenRequest"
|
||||
},
|
||||
"TreeAiPolicyRead": {
|
||||
"properties": {
|
||||
"member_provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Member Provider"
|
||||
},
|
||||
"recommender_provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Recommender Provider"
|
||||
},
|
||||
"configured_providers": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ConfiguredProvider"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Configured Providers"
|
||||
},
|
||||
"default_provider": {
|
||||
"type": "string",
|
||||
"title": "Default Provider"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"member_provider",
|
||||
"recommender_provider",
|
||||
"configured_providers",
|
||||
"default_provider"
|
||||
],
|
||||
"title": "TreeAiPolicyRead"
|
||||
},
|
||||
"TreeAiPolicyUpdate": {
|
||||
"properties": {
|
||||
"member_provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Member Provider"
|
||||
},
|
||||
"recommender_provider": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Recommender Provider"
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "TreeAiPolicyUpdate"
|
||||
},
|
||||
"TreeCreate": {
|
||||
"properties": {
|
||||
"name": {
|
||||
@@ -7081,4 +7266,4 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user