Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
/**
|
||||
* 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-4o-mini"
|
||||
},
|
||||
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-4o-mini"
|
||||
},
|
||||
support: {
|
||||
label: "Support",
|
||||
description:
|
||||
"Ticket auto-reply AI fallback — configure provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
|
||||
modelPlaceholder: "gpt-4o-mini"
|
||||
}
|
||||
};
|
||||
|
||||
/** 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());
|
||||
}
|
||||
Reference in New Issue
Block a user