Changeset 0.12.0 (#63)
This commit is contained in:
636
api.js
Normal file
636
api.js
Normal file
@@ -0,0 +1,636 @@
|
||||
// ==========================================
|
||||
// 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) {
|
||||
const body = {};
|
||||
if (model) body.model = model;
|
||||
if (presetId) body.preset_id = presetId;
|
||||
if (apiConfigId) body.provider_config_id = apiConfigId;
|
||||
|
||||
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) {
|
||||
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;
|
||||
|
||||
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;
|
||||
},
|
||||
|
||||
// ── 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}`);
|
||||
},
|
||||
|
||||
// ── 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);
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user