Files
descrybe/apps/web/src/lib/admin-ai-roles.ts
T

257 lines
7.5 KiB
TypeScript

/**
* Platform multi-AI role configs - agreed with backend AI schema agent.
*
* Roles: processing | vectorization | docs_api | support
* Fields: provider, base_url, api_key (secret), model, enabled, optional extras
*
* API (nested under platform settings):
* GET /api/admin/settings -> { ..., ai_roles: Record<role, Public> }
* PUT /api/admin/settings -> { ai_roles?: Partial<Record<role, Update>> }
* POST /api/admin/settings/ai-roles/{role}/test -> probe (ok|failed|skipped; 404 on older APIs)
*
* Secrets: never echo GET into password fields; blank keep; clear_api_key clears.
* Legacy openai maps to processing when ai_roles.processing is absent.
*/
import { api, ApiError } from "./api";
import {
PLATFORM_SETTINGS_PATH,
maskHint,
savePlatformAdminSettings,
type PlatformAdminSettings,
type PlatformOpenAIPublic
} from "./admin-platform-settings";
export const AI_ROLES = ["processing", "vectorization", "docs_api", "support"] as const;
export type AIRole = (typeof AI_ROLES)[number];
export const AI_ROLE_META: Record<
AIRole,
{ label: string; description: string; modelPlaceholder: string }
> = {
processing: {
label: "Processing",
description: "Product pipeline chat/completions (titles, descriptions, enhance).",
modelPlaceholder: "gpt-5.6-luna"
},
vectorization: {
label: "Vectorization",
description: "Embeddings for search / Pinecone indexing (match index dimensions).",
modelPlaceholder: "text-embedding-3-small"
},
docs_api: {
label: "Docs / API",
description:
"Future docs/API assistant slot. /docs Ask stays rule-based and must never call this role.",
modelPlaceholder: "gpt-5.6-luna"
},
support: {
label: "Support",
description:
"Ticket auto-reply AI fallback. Configure the provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
modelPlaceholder: "gpt-5.6-luna"
}
};
/** Common OpenAI-compatible provider labels for the admin select. */
export const AI_PROVIDER_OPTIONS = [
{ value: "openai", label: "OpenAI" },
{ value: "openrouter", label: "OpenRouter" },
{ value: "azure", label: "Azure OpenAI" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom / other" }
] as const;
export type PlatformAIRolePublic = {
role?: AIRole | string;
provider?: string;
base_url?: string;
model?: string;
enabled?: boolean;
configured?: boolean;
has_api_key?: boolean;
api_key_last4?: string;
api_key_masked?: string;
source?: "db" | "env" | "none" | string;
/** Optional free-form string bag (dimensions, timeout, …). */
extras?: Record<string, string>;
};
export type PlatformAIRoleUpdate = {
provider?: string;
base_url?: string;
model?: string;
enabled?: boolean;
api_key?: string;
clear_api_key?: boolean;
extras?: Record<string, string | null>;
};
export type PlatformAIRolesMap = Partial<Record<AIRole, PlatformAIRolePublic>>;
export type PlatformAIRolesUpdate = Partial<Record<AIRole, PlatformAIRoleUpdate>>;
export type AIRoleFormState = {
provider: string;
baseURL: string;
model: string;
enabled: boolean;
apiKey: string;
hasKey: boolean;
keyMasked: string;
clearKey: boolean;
source: string;
/** Embeddings dimensions (vectorization extras.dimensions). */
dimensions: string;
};
export function emptyAIRoleForm(): AIRoleFormState {
return {
provider: "openai",
baseURL: "",
model: "",
enabled: false,
apiKey: "",
hasKey: false,
keyMasked: "",
clearKey: false,
source: "",
dimensions: ""
};
}
export function roleFromPublic(pub: PlatformAIRolePublic | undefined): AIRoleFormState {
const form = emptyAIRoleForm();
if (!pub) return form;
form.provider = pub.provider?.trim() || "openai";
form.baseURL = pub.base_url ?? "";
form.model = pub.model ?? "";
form.enabled = Boolean(pub.enabled ?? pub.configured ?? pub.has_api_key);
form.hasKey = Boolean(pub.has_api_key);
form.keyMasked = maskHint(form.hasKey, pub.api_key_masked, pub.api_key_last4);
form.source = pub.source ?? "";
form.apiKey = "";
form.clearKey = false;
form.dimensions = pub.extras?.dimensions ?? "";
return form;
}
/** Map legacy platform openai section into the processing role form. */
export function roleFromLegacyOpenAI(openai: PlatformOpenAIPublic | undefined): AIRoleFormState {
const form = emptyAIRoleForm();
if (!openai) return form;
form.provider = "openai";
form.baseURL = openai.base_url ?? "";
form.model = openai.model ?? "";
form.enabled = Boolean(openai.configured || openai.has_api_key);
form.hasKey = Boolean(openai.has_api_key);
form.keyMasked = maskHint(form.hasKey, openai.api_key_masked, openai.api_key_last4);
form.source = openai.source ?? "";
form.apiKey = "";
form.clearKey = false;
return form;
}
export function extractAIRoles(settings: PlatformAdminSettings): PlatformAIRolesMap {
const raw = settings.ai_roles;
if (!raw || typeof raw !== "object") return {};
const out: PlatformAIRolesMap = {};
for (const role of AI_ROLES) {
const entry = raw[role];
if (entry && typeof entry === "object") out[role] = entry;
}
return out;
}
export function buildRoleForms(settings: PlatformAdminSettings): Record<AIRole, AIRoleFormState> {
const roles = extractAIRoles(settings);
const forms = {} as Record<AIRole, AIRoleFormState>;
for (const role of AI_ROLES) {
if (roles[role]) {
forms[role] = roleFromPublic(roles[role]);
} else if (role === "processing") {
forms[role] = roleFromLegacyOpenAI(settings.openai);
} else {
forms[role] = emptyAIRoleForm();
}
}
return forms;
}
export function formToUpdate(form: AIRoleFormState, role: AIRole): PlatformAIRoleUpdate {
const update: PlatformAIRoleUpdate = {
provider: form.provider.trim() || "custom",
base_url: form.baseURL.trim(),
model: form.model.trim(),
enabled: form.enabled,
api_key: form.apiKey.trim() || undefined,
clear_api_key: form.clearKey
};
if (role === "vectorization") {
const dim = form.dimensions.trim();
update.extras = { dimensions: dim ? dim : null };
}
return update;
}
/**
* Save one or more AI roles via PUT /api/admin/settings { ai_roles }.
* When only processing is sent and the API ignores ai_roles, also mirror to openai
* so legacy backends keep working during cutover.
*/
export async function saveAIRoles(
roles: PlatformAIRolesUpdate,
opts?: { mirrorProcessingToOpenAI?: boolean }
): Promise<PlatformAdminSettings> {
const body: {
ai_roles: PlatformAIRolesUpdate;
openai?: {
base_url?: string;
model?: string;
api_key?: string;
clear_api_key?: boolean;
};
} = { ai_roles: roles };
if (opts?.mirrorProcessingToOpenAI !== false && roles.processing) {
const p = roles.processing;
body.openai = {
base_url: p.base_url,
model: p.model,
api_key: p.api_key,
clear_api_key: p.clear_api_key
};
}
return savePlatformAdminSettings(body);
}
export const AI_ROLE_TEST_PATH = (role: AIRole) =>
`${PLATFORM_SETTINGS_PATH}/ai-roles/${encodeURIComponent(role)}/test`;
export type PlatformAIRoleTestResult = {
status: "ok" | "failed" | "skipped" | string;
message: string;
role?: string;
};
export async function testAIRole(role: AIRole): Promise<PlatformAIRoleTestResult> {
try {
return await api<PlatformAIRoleTestResult>(AI_ROLE_TEST_PATH(role), {
method: "POST",
body: {}
});
} catch (err) {
if (err instanceof ApiError && (err.status === 404 || err.status === 501)) {
return {
status: "skipped",
message: "Connection test is not available on this API build.",
role
};
}
throw err;
}
}
export function roleConfigured(form: AIRoleFormState): boolean {
return form.hasKey || Boolean(form.baseURL.trim() && form.model.trim());
}