feat(platform): ship AI fallback stack and entity-scoped course planning
Unifies the new LangGraph-driven course-plan/media flow with robust provider fallbacks, admin AI provider settings, editable book-style materials, and strict entity isolation across LMS/course-plan APIs. Adds admin-only entity membership management in the Entities UI so users can switch linked entities directly from the platform. Made-with: Cursor
This commit is contained in:
82
frontend/src/services/aiSettings.service.ts
Normal file
82
frontend/src/services/aiSettings.service.ts
Normal file
@@ -0,0 +1,82 @@
|
||||
import { api } from "@/lib/api-client";
|
||||
|
||||
/**
|
||||
* AI provider & API-key settings client.
|
||||
*
|
||||
* Backed by `backend/custom_addons/encoach_ai/controllers/ai_settings_controller.py`
|
||||
* (`/api/ai/settings/providers`). API keys are write-only — the GET response
|
||||
* only ever exposes `<key>_set: boolean` markers, never the key itself.
|
||||
*
|
||||
* Provider switches take effect on the very next request (no caching),
|
||||
* so the LangGraph runtime instantly picks up the new selection.
|
||||
*/
|
||||
|
||||
export type CapabilityKey = "text" | "image" | "audio" | "video";
|
||||
export type ProviderKind = "paid" | "free" | "auto";
|
||||
|
||||
export interface ProviderOption {
|
||||
value: string;
|
||||
label: string;
|
||||
kind: ProviderKind;
|
||||
}
|
||||
|
||||
export interface CapabilityState {
|
||||
active: string;
|
||||
options: ProviderOption[];
|
||||
paid_with_credentials: string[];
|
||||
}
|
||||
|
||||
export interface AISettingsState {
|
||||
providers: Record<CapabilityKey, CapabilityState>;
|
||||
/** Boolean markers — `true` means a value is stored, never the value itself. */
|
||||
keys_set: Record<string, boolean>;
|
||||
/** Plain (non-secret) parameters, e.g. region, model name. */
|
||||
plain: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface ProviderTestResult {
|
||||
capability: CapabilityKey;
|
||||
active: string;
|
||||
chain: { provider: string; ok: boolean; note: string }[];
|
||||
}
|
||||
|
||||
export interface AISettingsPatchPayload {
|
||||
/** Active provider per capability. */
|
||||
providers?: Partial<Record<CapabilityKey, string>>;
|
||||
/**
|
||||
* API keys keyed by short name (`openai_api_key`, `aws_access_key`,
|
||||
* `aws_secret_key`, `aws_region`, `elevenlabs_api_key`,
|
||||
* `gptzero_api_key`, `paymob_api_key`, `paymob_integration_id`,
|
||||
* `paymob_iframe_id`, `paymob_hmac_secret`).
|
||||
*
|
||||
* - Sending an empty string clears the key.
|
||||
* - Omitting the field leaves it unchanged.
|
||||
*/
|
||||
keys?: Record<string, string>;
|
||||
/** Plain (non-secret) values like `aws_region`, `openai_model`. */
|
||||
plain?: Record<string, string>;
|
||||
}
|
||||
|
||||
export const aiSettingsService = {
|
||||
async get(): Promise<AISettingsState> {
|
||||
const resp = await api.get<{ data: AISettingsState }>(
|
||||
"/ai/settings/providers",
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
|
||||
async update(payload: AISettingsPatchPayload): Promise<AISettingsState> {
|
||||
const resp = await api.patch<{ data: AISettingsState }>(
|
||||
"/ai/settings/providers",
|
||||
payload,
|
||||
);
|
||||
return resp.data;
|
||||
},
|
||||
|
||||
async test(capability: CapabilityKey): Promise<ProviderTestResult> {
|
||||
return api.post<ProviderTestResult>(
|
||||
"/ai/settings/providers/test",
|
||||
{ capability },
|
||||
);
|
||||
},
|
||||
};
|
||||
@@ -54,6 +54,20 @@ export const coursePlanService = {
|
||||
return api.get(`/ai/course-plan/${planId}/weeks/${weekNumber}/materials`);
|
||||
},
|
||||
|
||||
async updateMaterial(
|
||||
materialId: number,
|
||||
payload: {
|
||||
title?: string;
|
||||
summary?: string;
|
||||
body?: Record<string, unknown>;
|
||||
body_text?: string;
|
||||
share_date?: string | null;
|
||||
is_static?: boolean;
|
||||
},
|
||||
): Promise<{ data: CoursePlanMaterial }> {
|
||||
return api.patch(`/ai/course-plan/material/${materialId}`, payload);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase A — Sources
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -101,6 +115,28 @@ export const coursePlanService = {
|
||||
return api.delete(`/ai/course-plan/${planId}/sources/${sourceId}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Attach existing items from /admin/resources to a course plan as RAG
|
||||
* sources. Returns the rows that were newly attached plus the ids
|
||||
* that were skipped (already linked) or missing (deleted from the
|
||||
* library) so the caller can show a clear toast — e.g. "Attached 2,
|
||||
* skipped 1 already-linked".
|
||||
*/
|
||||
async attachResources(
|
||||
planId: number,
|
||||
resourceIds: number[],
|
||||
): Promise<{
|
||||
attached: CoursePlanSource[];
|
||||
skipped_existing: number[];
|
||||
missing: number[];
|
||||
count: number;
|
||||
}> {
|
||||
return api.post(
|
||||
`/ai/course-plan/${planId}/sources/from-resources`,
|
||||
{ resource_ids: resourceIds },
|
||||
);
|
||||
},
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Phase B — Deliverables / progress
|
||||
// ---------------------------------------------------------------------
|
||||
@@ -191,6 +227,12 @@ export const coursePlanService = {
|
||||
student_user_ids: number[];
|
||||
due_date?: string | null;
|
||||
message?: string;
|
||||
}
|
||||
| {
|
||||
mode: "entities";
|
||||
entity_ids: number[];
|
||||
due_date?: string | null;
|
||||
message?: string;
|
||||
},
|
||||
): Promise<{ data: CoursePlanAssignment }> {
|
||||
return api.post(`/ai/course-plan/${planId}/assignments`, payload);
|
||||
|
||||
@@ -2,6 +2,14 @@ import { api } from "@/lib/api-client";
|
||||
import { asPaginated, asRecordData } from "@/lib/odoo-api";
|
||||
import type { Entity, EntityRole, PaginatedResponse, PaginationParams, ApiSuccessResponse } from "@/types";
|
||||
|
||||
export interface EntityUser {
|
||||
id: number;
|
||||
name: string;
|
||||
login: string;
|
||||
email: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export const entitiesService = {
|
||||
async list(params?: PaginationParams): Promise<PaginatedResponse<Entity>> {
|
||||
const raw = await api.get<unknown>("/entities", params as Record<string, string | number | boolean | undefined>);
|
||||
@@ -42,4 +50,20 @@ export const entitiesService = {
|
||||
async getPermissions(entityId: number): Promise<string[]> {
|
||||
return api.get<string[]>(`/permissions`, { entity_id: entityId });
|
||||
},
|
||||
|
||||
async listEntityUsers(entityId: number): Promise<EntityUser[]> {
|
||||
const raw = await api.get<unknown>(`/entities/${entityId}/users`);
|
||||
const out = asPaginated<EntityUser>(raw);
|
||||
return out.items;
|
||||
},
|
||||
|
||||
async updateEntityUsers(entityId: number, userIds: number[]): Promise<ApiSuccessResponse> {
|
||||
return api.patch<ApiSuccessResponse>(`/entities/${entityId}/users`, { user_ids: userIds });
|
||||
},
|
||||
|
||||
async listPlatformUsers(params?: PaginationParams): Promise<EntityUser[]> {
|
||||
const raw = await api.get<unknown>("/users/list", params as Record<string, string | number | boolean | undefined>);
|
||||
const out = asPaginated<EntityUser>(raw);
|
||||
return out.items;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -42,12 +42,27 @@ export const resourcesService = {
|
||||
return api.post<ApiSuccessResponse>(`/resources/${id}/rate`, { rating });
|
||||
},
|
||||
|
||||
async download(id: number): Promise<Blob> {
|
||||
/**
|
||||
* Downloads the binary and returns the blob alongside the filename
|
||||
* the server suggested via ``Content-Disposition``. Callers should
|
||||
* prefer that filename over the human ``name`` on the record so the
|
||||
* extension is preserved (".pdf" / ".mp3" / ".png" etc.) — the
|
||||
* legacy code dropped the extension because the human name was just
|
||||
* "test".
|
||||
*/
|
||||
async download(id: number): Promise<{ blob: Blob; filename: string }> {
|
||||
const res = await fetch(`${API_BASE_URL}/resources/${id}/download`, {
|
||||
headers: { Authorization: `Bearer ${localStorage.getItem("encoach_token") ?? ""}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
||||
return res.blob();
|
||||
const cd = res.headers.get("content-disposition") || "";
|
||||
let filename = "";
|
||||
// Match either ``filename*=UTF-8''…`` (RFC 5987) or ``filename="…"``
|
||||
const match =
|
||||
/filename\*\s*=\s*[^']*''([^;]+)/i.exec(cd) ||
|
||||
/filename\s*=\s*"?([^";]+)"?/i.exec(cd);
|
||||
if (match) filename = decodeURIComponent(match[1].trim());
|
||||
return { blob: await res.blob(), filename };
|
||||
},
|
||||
|
||||
// Tag management
|
||||
|
||||
Reference in New Issue
Block a user