420 lines
19 KiB
JavaScript
420 lines
19 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;
|
||
}
|
||
} 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);
|
||
},
|
||
|
||
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 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();
|
||
},
|
||
|
||
// ── 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.api_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 err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||
}
|
||
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`);
|
||
},
|
||
|
||
// ── Completions ──────────────────────────
|
||
|
||
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId) {
|
||
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.api_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;
|
||
|
||
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 err = await resp.json().catch(() => ({}));
|
||
throw new Error(err.error || `HTTP ${resp.status}`);
|
||
}
|
||
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'); },
|
||
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}`); },
|
||
|
||
// ── 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`, { new_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`); },
|
||
|
||
// ── 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'); },
|
||
|
||
// ── HTTP Internals ───────────────────────
|
||
|
||
_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) {
|
||
const data = await resp.json().catch(() => ({}));
|
||
const err = new Error(data.error || `HTTP ${resp.status}`);
|
||
err.status = resp.status;
|
||
throw err;
|
||
}
|
||
return resp.json();
|
||
}
|
||
};
|