692 lines
32 KiB
JavaScript
692 lines
32 KiB
JavaScript
// ==========================================
|
||
// Chat Switchboard – API Client
|
||
// ==========================================
|
||
// Backend-only mode. Handles auth tokens and
|
||
// all HTTP calls. No offline fallback.
|
||
//
|
||
// BASE_PATH: injected via index.html <script>
|
||
// at container startup. All API paths are
|
||
// prefixed automatically.
|
||
// ==========================================
|
||
|
||
const BASE = window.__BASE__ || '';
|
||
const _storageKey = BASE ? `sb_auth_${BASE.replace(/\//g, '')}` : 'sb_auth';
|
||
|
||
const API = {
|
||
accessToken: null,
|
||
refreshToken: null,
|
||
user: null,
|
||
_refreshing: null,
|
||
|
||
// ── Bootstrap ────────────────────────────
|
||
|
||
loadTokens() {
|
||
try {
|
||
const saved = JSON.parse(localStorage.getItem(_storageKey) || 'null');
|
||
if (saved) {
|
||
this.accessToken = saved.accessToken || null;
|
||
this.refreshToken = saved.refreshToken || null;
|
||
this.user = saved.user || null;
|
||
// Unknown age — refresh soon to get a fresh token
|
||
if (this.refreshToken) this._scheduleRefresh(60);
|
||
}
|
||
} catch (e) { /* corrupt storage */ }
|
||
},
|
||
|
||
saveTokens() {
|
||
localStorage.setItem(_storageKey, JSON.stringify({
|
||
accessToken: this.accessToken,
|
||
refreshToken: this.refreshToken,
|
||
user: this.user
|
||
}));
|
||
},
|
||
|
||
clearTokens() {
|
||
this.accessToken = null;
|
||
this.refreshToken = null;
|
||
this.user = null;
|
||
localStorage.removeItem(_storageKey);
|
||
if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; }
|
||
},
|
||
|
||
get isAuthed() { return !!this.accessToken; },
|
||
get isAdmin() { return this.user?.role === 'admin'; },
|
||
|
||
// ── Auth ─────────────────────────────────
|
||
|
||
async health() {
|
||
const resp = await fetch(BASE + '/api/v1/health', { signal: AbortSignal.timeout(8000) });
|
||
if (!resp.ok) throw new Error(`Health: ${resp.status}`);
|
||
return this._parseJSON(resp, '/api/v1/health');
|
||
},
|
||
|
||
// Detect proxy interception: HTTP 200 but HTML instead of JSON
|
||
async _parseJSON(resp, context) {
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
// Proxy returned an HTML page (block page, auth wall, etc.)
|
||
let title = 'unknown';
|
||
try {
|
||
const html = await resp.clone().text();
|
||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||
if (m) title = m[1].trim();
|
||
} catch (e) { /* best effort */ }
|
||
const err = new Error(`Proxy interception detected on ${context}: "${title}"`);
|
||
err.proxyBlocked = true;
|
||
err.proxyTitle = title;
|
||
throw err;
|
||
}
|
||
return resp.json();
|
||
},
|
||
|
||
async login(login, password) {
|
||
const data = await this._post('/api/v1/auth/login', { login, password }, true);
|
||
this._setAuth(data);
|
||
return data;
|
||
},
|
||
|
||
async register(username, email, password) {
|
||
const data = await this._post('/api/v1/auth/register', { username, email, password }, true);
|
||
if (!data.pending) this._setAuth(data);
|
||
return data;
|
||
},
|
||
|
||
async logout() {
|
||
try { await this._post('/api/v1/auth/logout', { refresh_token: this.refreshToken }, true); }
|
||
catch (e) { /* best effort */ }
|
||
this.clearTokens();
|
||
},
|
||
|
||
async refresh() {
|
||
if (this._refreshing) return this._refreshing;
|
||
this._refreshing = (async () => {
|
||
try {
|
||
const data = await this._post('/api/v1/auth/refresh', { refresh_token: this.refreshToken }, true);
|
||
this._setAuth(data);
|
||
return true;
|
||
} catch (e) {
|
||
this.clearTokens();
|
||
return false;
|
||
} finally {
|
||
this._refreshing = null;
|
||
}
|
||
})();
|
||
return this._refreshing;
|
||
},
|
||
|
||
_setAuth(data) {
|
||
this.accessToken = data.access_token;
|
||
this.refreshToken = data.refresh_token;
|
||
this.user = data.user;
|
||
this.saveTokens();
|
||
this._scheduleRefresh(data.expires_in || 900);
|
||
},
|
||
|
||
_refreshTimer: null,
|
||
|
||
_scheduleRefresh(expiresIn) {
|
||
if (this._refreshTimer) clearTimeout(this._refreshTimer);
|
||
// Refresh at 80% of expiry (e.g., 12min for 15min token)
|
||
const ms = Math.max((expiresIn * 0.8) * 1000, 30000);
|
||
this._refreshTimer = setTimeout(async () => {
|
||
if (!this.refreshToken) return;
|
||
console.debug('🔄 Proactive token refresh');
|
||
await this.refresh();
|
||
}, ms);
|
||
},
|
||
|
||
// ── Channels ──────────────────────────────
|
||
|
||
listChannels(page = 1, perPage = 100, type = '') {
|
||
let url = `/api/v1/channels?page=${page}&per_page=${perPage}`;
|
||
if (type) url += `&type=${type}`;
|
||
return this._get(url);
|
||
},
|
||
createChannel(title, model, systemPrompt, type = 'direct') {
|
||
const body = { title, type };
|
||
if (model) body.model = model;
|
||
if (systemPrompt) body.system_prompt = systemPrompt;
|
||
return this._post('/api/v1/channels', body);
|
||
},
|
||
getChannel(id) { return this._get(`/api/v1/channels/${id}`); },
|
||
updateChannel(id, updates) { return this._put(`/api/v1/channels/${id}`, updates); },
|
||
deleteChannel(id) { return this._del(`/api/v1/channels/${id}`); },
|
||
|
||
// ── Messages ─────────────────────────────
|
||
|
||
listMessages(channelId, page = 1, perPage = 200) {
|
||
return this._get(`/api/v1/channels/${channelId}/messages?page=${page}&per_page=${perPage}`);
|
||
},
|
||
|
||
// ── Message Tree (forking) ───────────────
|
||
|
||
getActivePath(channelId) {
|
||
return this._get(`/api/v1/channels/${channelId}/path`);
|
||
},
|
||
|
||
editMessage(channelId, messageId, content) {
|
||
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
|
||
},
|
||
|
||
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId, disabledTools) {
|
||
const body = {};
|
||
if (model) body.model = model;
|
||
if (presetId) body.preset_id = presetId;
|
||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||
|
||
let resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||
method: 'POST',
|
||
headers: this._authHeaders(),
|
||
body: JSON.stringify(body),
|
||
signal
|
||
});
|
||
|
||
if (resp.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) {
|
||
resp = await fetch(BASE + `/api/v1/channels/${channelId}/messages/${messageId}/regenerate`, {
|
||
method: 'POST',
|
||
headers: this._authHeaders(),
|
||
body: JSON.stringify(body),
|
||
signal
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!resp.ok) {
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
const err = new Error('Proxy blocked the regeneration request');
|
||
err.proxyBlocked = true;
|
||
throw err;
|
||
}
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||
}
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
const err = new Error('Proxy intercepted the regeneration stream');
|
||
err.proxyBlocked = true;
|
||
throw err;
|
||
}
|
||
return resp;
|
||
},
|
||
|
||
updateCursor(channelId, activeLeafId) {
|
||
return this._put(`/api/v1/channels/${channelId}/cursor`, { active_leaf_id: activeLeafId });
|
||
},
|
||
|
||
listSiblings(channelId, messageId) {
|
||
return this._get(`/api/v1/channels/${channelId}/messages/${messageId}/siblings`);
|
||
},
|
||
|
||
// ── Summarize & Continue ────────────────
|
||
summarizeChannel(channelId) {
|
||
return this._post(`/api/v1/channels/${channelId}/summarize`, {});
|
||
},
|
||
|
||
// ── Completions ──────────────────────────
|
||
|
||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds, disabledTools) {
|
||
const body = { channel_id: channelId, content, stream: true };
|
||
if (presetId) {
|
||
body.preset_id = presetId;
|
||
// Preset resolves the model on backend; still pass model for channel metadata
|
||
if (model) body.model = model;
|
||
} else {
|
||
if (model) body.model = model;
|
||
}
|
||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||
// Only send max_tokens if user explicitly set it (non-zero = override)
|
||
if (App.settings.maxTokens > 0) body.max_tokens = App.settings.maxTokens;
|
||
if (attachmentIds?.length) body.attachment_ids = attachmentIds;
|
||
if (disabledTools?.length) body.disabled_tools = disabledTools;
|
||
|
||
let resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: this._authHeaders(),
|
||
body: JSON.stringify(body),
|
||
signal
|
||
});
|
||
|
||
if (resp.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) {
|
||
resp = await fetch(BASE + '/api/v1/chat/completions', {
|
||
method: 'POST',
|
||
headers: this._authHeaders(),
|
||
body: JSON.stringify(body),
|
||
signal
|
||
});
|
||
}
|
||
}
|
||
|
||
if (!resp.ok) {
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
const err = new Error('Proxy blocked the completion request');
|
||
err.proxyBlocked = true;
|
||
throw err;
|
||
}
|
||
const err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||
}
|
||
// Also check 200 OK but HTML (proxy returning block page with 200)
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
const err = new Error('Proxy intercepted the completion stream');
|
||
err.proxyBlocked = true;
|
||
throw err;
|
||
}
|
||
return resp;
|
||
},
|
||
|
||
// ── Tools ────────────────────────────────
|
||
|
||
getTools() { return this._get('/api/v1/tools'); },
|
||
|
||
// ── Models ───────────────────────────────
|
||
|
||
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
|
||
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
|
||
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
|
||
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
|
||
|
||
// User presets
|
||
listUserPresets() { return this._get('/api/v1/presets'); },
|
||
createUserPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||
updateUserPreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||
deleteUserPreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||
listAllModels() { return this._get('/api/v1/models'); },
|
||
|
||
// ── API Configs (user providers) ─────────
|
||
|
||
listConfigs() { return this._get('/api/v1/api-configs'); },
|
||
getConfig(id) { return this._get(`/api/v1/api-configs/${id}`); },
|
||
createConfig(name, provider, endpoint, apiKey, modelDefault) {
|
||
return this._post('/api/v1/api-configs', {
|
||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault
|
||
});
|
||
},
|
||
deleteConfig(id) { return this._del(`/api/v1/api-configs/${id}`); },
|
||
updateConfig(id, patch) { return this._put(`/api/v1/api-configs/${id}`, patch); },
|
||
listProviderModels(id) { return this._get(`/api/v1/api-configs/${id}/models`); },
|
||
fetchProviderModels(id) { return this._post(`/api/v1/api-configs/${id}/models/fetch`); },
|
||
|
||
// ── Profile & Settings ───────────────────
|
||
|
||
async getProfile() {
|
||
const data = await this._get('/api/v1/profile');
|
||
// Sync user object with profile data (including avatar)
|
||
if (data && this.user) {
|
||
this.user.avatar = data.avatar || null;
|
||
this.user.display_name = data.display_name;
|
||
this.saveTokens();
|
||
}
|
||
return data;
|
||
},
|
||
updateProfile(updates) { return this._put('/api/v1/profile', updates); },
|
||
uploadAvatar(base64Image) { return this._post('/api/v1/profile/avatar', { image: base64Image }); },
|
||
deleteAvatar() { return this._del('/api/v1/profile/avatar'); },
|
||
changePassword(current, newPw) {
|
||
return this._post('/api/v1/profile/password', { current_password: current, new_password: newPw });
|
||
},
|
||
getSettings() { return this._get('/api/v1/settings'); },
|
||
updateSettings(settings) { return this._put('/api/v1/settings', settings); },
|
||
|
||
// ── Admin ────────────────────────────────
|
||
|
||
adminListUsers(p, pp) { return this._get(`/api/v1/admin/users?page=${p||1}&per_page=${pp||50}`); },
|
||
adminCreateUser(username, email, password, role) {
|
||
return this._post('/api/v1/admin/users', { username, email, password, role });
|
||
},
|
||
adminResetPassword(id, pw) { return this._post(`/api/v1/admin/users/${id}/reset-password`, { password: pw }); },
|
||
adminUpdateRole(id, role) { return this._put(`/api/v1/admin/users/${id}/role`, { role }); },
|
||
adminToggleActive(id, active, teamIds, teamRole) {
|
||
const body = { is_active: active };
|
||
if (teamIds?.length) body.team_ids = teamIds;
|
||
if (teamRole) body.team_role = teamRole;
|
||
return this._put(`/api/v1/admin/users/${id}/active`, body);
|
||
},
|
||
adminDeleteUser(id) { return this._del(`/api/v1/admin/users/${id}`); },
|
||
adminGetSettings() { return this._get('/api/v1/admin/settings'); },
|
||
getPublicSettings() { return this._get('/api/v1/settings/public'); },
|
||
adminUpdateSetting(key, value) { return this._put(`/api/v1/admin/settings/${key}`, value); },
|
||
adminGetStats() { return this._get('/api/v1/admin/stats'); },
|
||
|
||
adminListGlobalConfigs() { return this._get('/api/v1/admin/configs'); },
|
||
adminCreateGlobalConfig(name, provider, endpoint, apiKey, modelDefault, isPrivate) {
|
||
return this._post('/api/v1/admin/configs', {
|
||
name, provider, endpoint, api_key: apiKey, model_default: modelDefault, is_private: !!isPrivate
|
||
});
|
||
},
|
||
adminDeleteGlobalConfig(id) { return this._del(`/api/v1/admin/configs/${id}`); },
|
||
adminUpdateGlobalConfig(id, updates) { return this._put(`/api/v1/admin/configs/${id}`, updates); },
|
||
|
||
adminListModels() { return this._get('/api/v1/admin/models'); },
|
||
adminFetchModels() { return this._post('/api/v1/admin/models/fetch', {}); },
|
||
adminUpdateModel(id, updates) { return this._put(`/api/v1/admin/models/${id}`, updates); },
|
||
adminBulkUpdateModels(visibility) {
|
||
// Send both for backward compat: old BE reads is_enabled, new BE reads visibility
|
||
return this._put('/api/v1/admin/models/bulk', {
|
||
visibility,
|
||
is_enabled: visibility === 'enabled'
|
||
});
|
||
},
|
||
adminDeleteModel(id) { return this._del(`/api/v1/admin/models/${id}`); },
|
||
|
||
// ── Admin Presets ────────────────────────
|
||
adminListPresets() { return this._get('/api/v1/admin/presets'); },
|
||
adminCreatePreset(preset) { return this._post('/api/v1/admin/presets', preset); },
|
||
adminUpdatePreset(id, updates) { return this._put(`/api/v1/admin/presets/${id}`, updates); },
|
||
adminDeletePreset(id) { return this._del(`/api/v1/admin/presets/${id}`); },
|
||
adminUploadPresetAvatar(id, base64Image) { return this._post(`/api/v1/admin/presets/${id}/avatar`, { image: base64Image }); },
|
||
adminDeletePresetAvatar(id) { return this._del(`/api/v1/admin/presets/${id}/avatar`); },
|
||
|
||
// ── Admin Teams ─────────────────────────
|
||
adminListTeams() { return this._get('/api/v1/admin/teams'); },
|
||
|
||
// ── Admin Audit ─────────────────────────
|
||
adminListAudit(params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.page) q.set('page', params.page);
|
||
if (params?.per_page) q.set('per_page', params.per_page);
|
||
if (params?.action) q.set('action', params.action);
|
||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||
return this._get(`/api/v1/admin/audit?${q}`);
|
||
},
|
||
adminListAuditActions() { return this._get('/api/v1/admin/audit/actions'); },
|
||
adminCreateTeam(name, description) { return this._post('/api/v1/admin/teams', { name, description }); },
|
||
adminGetTeam(id) { return this._get(`/api/v1/admin/teams/${id}`); },
|
||
adminUpdateTeam(id, updates) { return this._put(`/api/v1/admin/teams/${id}`, updates); },
|
||
adminDeleteTeam(id) { return this._del(`/api/v1/admin/teams/${id}`); },
|
||
adminListMembers(teamId) { return this._get(`/api/v1/admin/teams/${teamId}/members`); },
|
||
adminAddMember(teamId, userId, role) { return this._post(`/api/v1/admin/teams/${teamId}/members`, { user_id: userId, role }); },
|
||
adminUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/admin/teams/${teamId}/members/${memberId}`, { role }); },
|
||
adminRemoveMember(teamId, memberId) { return this._del(`/api/v1/admin/teams/${teamId}/members/${memberId}`); },
|
||
|
||
// ── User Teams ──────────────────────────
|
||
listMyTeams() { return this._get('/api/v1/teams/mine'); },
|
||
|
||
// ── Team Admin Self-Service ─────────────
|
||
teamListMembers(teamId) { return this._get(`/api/v1/teams/${teamId}/members`); },
|
||
teamAddMember(teamId, userId, role) { return this._post(`/api/v1/teams/${teamId}/members`, { user_id: userId, role }); },
|
||
teamUpdateMember(teamId, memberId, role) { return this._put(`/api/v1/teams/${teamId}/members/${memberId}`, { role }); },
|
||
teamRemoveMember(teamId, memberId) { return this._del(`/api/v1/teams/${teamId}/members/${memberId}`); },
|
||
teamListPresets(teamId) { return this._get(`/api/v1/teams/${teamId}/presets`); },
|
||
teamCreatePreset(teamId, preset) { return this._post(`/api/v1/teams/${teamId}/presets`, preset); },
|
||
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
|
||
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
|
||
|
||
// ── Team Providers ──────────────────────
|
||
teamListProviders(teamId) { return this._get(`/api/v1/teams/${teamId}/providers`); },
|
||
teamCreateProvider(teamId, provider) { return this._post(`/api/v1/teams/${teamId}/providers`, provider); },
|
||
teamUpdateProvider(teamId, providerId, updates) { return this._put(`/api/v1/teams/${teamId}/providers/${providerId}`, updates); },
|
||
teamDeleteProvider(teamId, providerId) { return this._del(`/api/v1/teams/${teamId}/providers/${providerId}`); },
|
||
teamListProviderModels(teamId, providerId) { return this._get(`/api/v1/teams/${teamId}/providers/${providerId}/models`); },
|
||
teamListAudit(teamId, params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.page) q.set('page', params.page);
|
||
if (params?.per_page) q.set('per_page', params.per_page);
|
||
if (params?.action) q.set('action', params.action);
|
||
if (params?.actor_id) q.set('actor_id', params.actor_id);
|
||
if (params?.resource_type) q.set('resource_type', params.resource_type);
|
||
return this._get(`/api/v1/teams/${teamId}/audit?${q}`);
|
||
},
|
||
teamListAuditActions(teamId) { return this._get(`/api/v1/teams/${teamId}/audit/actions`); },
|
||
|
||
// ── User Presets ─────────────────────────
|
||
listPresets() { return this._get('/api/v1/presets'); },
|
||
createPreset(preset) { return this._post('/api/v1/presets', preset); },
|
||
updatePreset(id, updates) { return this._put(`/api/v1/presets/${id}`, updates); },
|
||
deletePreset(id) { return this._del(`/api/v1/presets/${id}`); },
|
||
|
||
// ── Notes ────────────────────────────────
|
||
listNotes(limit = 50, offset = 0, folder, tag, sort) {
|
||
let url = `/api/v1/notes?limit=${limit}&offset=${offset}`;
|
||
if (folder) url += `&folder=${encodeURIComponent(folder)}`;
|
||
if (tag) url += `&tag=${encodeURIComponent(tag)}`;
|
||
if (sort) url += `&sort=${encodeURIComponent(sort)}`;
|
||
return this._get(url);
|
||
},
|
||
createNote(title, content, folderPath, tags) {
|
||
const body = { title, content };
|
||
if (folderPath) body.folder_path = folderPath;
|
||
if (tags?.length) body.tags = tags;
|
||
return this._post('/api/v1/notes', body);
|
||
},
|
||
getNote(id) { return this._get(`/api/v1/notes/${id}`); },
|
||
updateNote(id, updates) { return this._put(`/api/v1/notes/${id}`, updates); },
|
||
deleteNote(id) { return this._del(`/api/v1/notes/${id}`); },
|
||
bulkDeleteNotes(ids) { return this._post('/api/v1/notes/bulk-delete', { ids }); },
|
||
searchNotes(query, limit = 20) { return this._get(`/api/v1/notes/search?q=${encodeURIComponent(query)}&limit=${limit}`); },
|
||
listNoteFolders() { return this._get('/api/v1/notes/folders'); },
|
||
|
||
// ── Attachments ─────────────────────────
|
||
|
||
/**
|
||
* Upload a file to a channel. Uses multipart/form-data (not JSON).
|
||
* Returns the created attachment object with id, metadata, etc.
|
||
*/
|
||
async uploadAttachment(channelId, file) {
|
||
const form = new FormData();
|
||
form.append('file', file, file.name);
|
||
|
||
const doFetch = () => fetch(BASE + `/api/v1/channels/${channelId}/attachments`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||
// No Content-Type — browser sets multipart boundary automatically
|
||
body: form,
|
||
});
|
||
|
||
let resp = await doFetch();
|
||
if (resp.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) resp = await doFetch();
|
||
}
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return this._parseJSON(resp, `/api/v1/channels/${channelId}/attachments`);
|
||
},
|
||
|
||
getAttachment(id) { return this._get(`/api/v1/attachments/${id}`); },
|
||
|
||
async downloadAttachmentBlob(id) {
|
||
const doFetch = () => fetch(BASE + `/api/v1/attachments/${id}/download`, {
|
||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||
});
|
||
|
||
let resp = await doFetch();
|
||
if (resp.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) resp = await doFetch();
|
||
}
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
throw new Error(data.error || `Download failed: HTTP ${resp.status}`);
|
||
}
|
||
return resp.blob();
|
||
},
|
||
|
||
deleteAttachment(id) { return this._del(`/api/v1/attachments/${id}`); },
|
||
listChannelAttachments(channelId) { return this._get(`/api/v1/channels/${channelId}/attachments`); },
|
||
|
||
// ── Admin Storage ───────────────────────
|
||
adminGetStorageStatus() { return this._get('/api/v1/admin/storage/status'); },
|
||
adminGetOrphanCount() { return this._get('/api/v1/admin/storage/orphans'); },
|
||
adminRunStorageCleanup() { return this._post('/api/v1/admin/storage/cleanup', {}); },
|
||
adminGetExtractionStatus() { return this._get('/api/v1/admin/storage/extraction'); },
|
||
|
||
// Vault
|
||
adminGetVaultStatus() { return this._get('/api/v1/admin/vault/status'); },
|
||
|
||
// ── Admin Roles ─────────────────────────
|
||
adminListRoles() { return this._get('/api/v1/admin/roles'); },
|
||
adminGetRole(role) { return this._get(`/api/v1/admin/roles/${role}`); },
|
||
adminUpdateRole(role, config) { return this._put(`/api/v1/admin/roles/${role}`, config); },
|
||
adminTestRole(role) { return this._post(`/api/v1/admin/roles/${role}/test`, {}); },
|
||
|
||
// ── Admin Usage & Pricing ───────────────
|
||
adminGetUsage(params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.period) q.set('period', params.period);
|
||
if (params?.since) q.set('since', params.since);
|
||
if (params?.until) q.set('until', params.until);
|
||
if (params?.group_by) q.set('group_by', params.group_by);
|
||
return this._get(`/api/v1/admin/usage?${q}`);
|
||
},
|
||
adminGetUserUsage(userId, params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.period) q.set('period', params.period);
|
||
if (params?.group_by) q.set('group_by', params.group_by);
|
||
return this._get(`/api/v1/admin/usage/users/${userId}?${q}`);
|
||
},
|
||
adminGetTeamUsage(teamId, params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.period) q.set('period', params.period);
|
||
if (params?.group_by) q.set('group_by', params.group_by);
|
||
return this._get(`/api/v1/admin/usage/teams/${teamId}?${q}`);
|
||
},
|
||
adminListPricing() { return this._get('/api/v1/admin/pricing'); },
|
||
adminUpsertPricing(entry) { return this._put('/api/v1/admin/pricing', entry); },
|
||
adminDeletePricing(providerConfigId, modelId) {
|
||
return this._del(`/api/v1/admin/pricing/${providerConfigId}/${encodeURIComponent(modelId)}`);
|
||
},
|
||
|
||
// ── User Usage ──────────────────────────
|
||
getMyUsage(params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.period) q.set('period', params.period);
|
||
if (params?.group_by) q.set('group_by', params.group_by);
|
||
return this._get(`/api/v1/usage?${q}`);
|
||
},
|
||
|
||
// ── Team Roles ──────────────────────────
|
||
teamListRoles(teamId) { return this._get(`/api/v1/teams/${teamId}/roles`); },
|
||
teamUpdateRole(teamId, role, config) { return this._put(`/api/v1/teams/${teamId}/roles/${role}`, config); },
|
||
teamDeleteRole(teamId, role) { return this._del(`/api/v1/teams/${teamId}/roles/${role}`); },
|
||
|
||
// ── Team Usage ──────────────────────────
|
||
teamGetUsage(teamId, params) {
|
||
const q = new URLSearchParams();
|
||
if (params?.period) q.set('period', params.period);
|
||
if (params?.group_by) q.set('group_by', params.group_by);
|
||
return this._get(`/api/v1/teams/${teamId}/usage?${q}`);
|
||
},
|
||
|
||
// ── Knowledge Bases ──────────────────────
|
||
listKnowledgeBases() { return this._get('/api/v1/knowledge-bases'); },
|
||
createKnowledgeBase(name, description, scope, teamId) {
|
||
const body = { name, description: description || '' };
|
||
if (scope) body.scope = scope;
|
||
if (teamId) body.team_id = teamId;
|
||
return this._post('/api/v1/knowledge-bases', body);
|
||
},
|
||
getKnowledgeBase(id) { return this._get(`/api/v1/knowledge-bases/${id}`); },
|
||
updateKnowledgeBase(id, updates) { return this._put(`/api/v1/knowledge-bases/${id}`, updates); },
|
||
deleteKnowledgeBase(id) { return this._del(`/api/v1/knowledge-bases/${id}`); },
|
||
searchKnowledgeBase(id, query, limit) {
|
||
const body = { query };
|
||
if (limit) body.limit = limit;
|
||
return this._post(`/api/v1/knowledge-bases/${id}/search`, body);
|
||
},
|
||
|
||
// KB Documents
|
||
listKBDocuments(kbId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents`); },
|
||
getKBDocumentStatus(kbId, docId) { return this._get(`/api/v1/knowledge-bases/${kbId}/documents/${docId}/status`); },
|
||
deleteKBDocument(kbId, docId) { return this._del(`/api/v1/knowledge-bases/${kbId}/documents/${docId}`); },
|
||
|
||
async uploadKBDocument(kbId, file) {
|
||
const form = new FormData();
|
||
form.append('file', file, file.name);
|
||
|
||
const doFetch = () => fetch(BASE + `/api/v1/knowledge-bases/${kbId}/documents`, {
|
||
method: 'POST',
|
||
headers: { 'Authorization': `Bearer ${this.accessToken}` },
|
||
body: form,
|
||
});
|
||
|
||
let resp = await doFetch();
|
||
if (resp.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) resp = await doFetch();
|
||
}
|
||
if (!resp.ok) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
const err = new Error(data.error || `Upload failed: HTTP ${resp.status}`);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return this._parseJSON(resp, `/api/v1/knowledge-bases/${kbId}/documents`);
|
||
},
|
||
|
||
// Channel ↔ KB linking
|
||
getChannelKBs(channelId) { return this._get(`/api/v1/channels/${channelId}/knowledge-bases`); },
|
||
setChannelKBs(channelId, kbIds) { return this._put(`/api/v1/channels/${channelId}/knowledge-bases`, { kb_ids: kbIds }); },
|
||
|
||
// ── HTTP Internals ───────────────────────
|
||
|
||
_esc(s) {
|
||
if (!s) return '';
|
||
const d = document.createElement('div');
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
},
|
||
|
||
_authHeaders() {
|
||
return {
|
||
'Content-Type': 'application/json',
|
||
'Authorization': `Bearer ${this.accessToken}`
|
||
};
|
||
},
|
||
|
||
async _get(path) { return this._authed(path); },
|
||
async _post(path, body, skipAuth) { return skipAuth ? this._raw(path, 'POST', body) : this._authed(path, 'POST', body); },
|
||
async _put(path, body) { return this._authed(path, 'PUT', body); },
|
||
async _del(path) { return this._authed(path, 'DELETE'); },
|
||
|
||
async _authed(path, method = 'GET', body) {
|
||
try {
|
||
return await this._raw(path, method, body, true);
|
||
} catch (e) {
|
||
if (e.status === 401 && this.refreshToken) {
|
||
if (await this.refresh()) {
|
||
return this._raw(path, method, body, true);
|
||
}
|
||
}
|
||
throw e;
|
||
}
|
||
},
|
||
|
||
async _raw(path, method = 'GET', body, auth = false) {
|
||
const opts = { method, headers: { 'Content-Type': 'application/json' } };
|
||
if (auth) opts.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
||
if (body) opts.body = JSON.stringify(body);
|
||
|
||
const resp = await fetch(BASE + path, opts);
|
||
if (!resp.ok) {
|
||
// Check if the error response is also a proxy page
|
||
const ct = (resp.headers.get('content-type') || '').toLowerCase();
|
||
if (ct.includes('text/html')) {
|
||
let title = 'unknown';
|
||
try {
|
||
const html = await resp.text();
|
||
const m = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||
if (m) title = m[1].trim();
|
||
} catch (e) { /* */ }
|
||
const err = new Error(`Proxy blocked ${method} ${path}: "${title}"`);
|
||
err.status = resp.status;
|
||
err.proxyBlocked = true;
|
||
err.proxyTitle = title;
|
||
throw err;
|
||
}
|
||
const data = await resp.json().catch(() => ({}));
|
||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return this._parseJSON(resp, path);
|
||
}
|
||
};
|