2026-08-09 22:47:43 +02:00
|
|
|
/**
|
|
|
|
|
* 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).",
|
2026-08-23 12:40:59 +02:00
|
|
|
modelPlaceholder: "gpt-5.6-luna"
|
2026-08-09 22:47:43 +02:00
|
|
|
},
|
|
|
|
|
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.",
|
2026-08-23 12:40:59 +02:00
|
|
|
modelPlaceholder: "gpt-5.6-luna"
|
2026-08-09 22:47:43 +02:00
|
|
|
},
|
|
|
|
|
support: {
|
|
|
|
|
label: "Support",
|
|
|
|
|
description:
|
|
|
|
|
"Ticket auto-reply AI fallback — configure provider/key/model here; enable delivery in Support knowledge → Auto-reply.",
|
2026-08-23 12:40:59 +02:00
|
|
|
modelPlaceholder: "gpt-5.6-luna"
|
2026-08-09 22:47:43 +02:00
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
/** 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());
|
|
|
|
|
}
|