Changeset 0.8.6 (#49)

This commit is contained in:
2026-02-22 16:52:19 +00:00
parent 633421708f
commit 15be26c516
17 changed files with 870 additions and 152 deletions

View File

@@ -341,6 +341,13 @@ const API = {
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); },

View File

@@ -11,6 +11,16 @@ const App = {
abortController: null,
serverSettings: {},
// Find model by composite ID, with fallback to bare model_id match
findModel(id) {
if (!id) return null;
// Exact match on composite ID (configId:modelId)
let m = App.models.find(m => m.id === id);
if (m) return m;
// Fallback: bare model_id (backward compat with stored settings)
return App.models.find(m => m.baseModelId === id) || null;
},
settings: {
model: '',
stream: true,
@@ -211,8 +221,12 @@ async function fetchModels() {
App.models = (data.models || []).map(m => {
const isPreset = !!m.is_preset;
const id = isPreset ? (m.preset_id || m.id) : (m.model_id || m.id);
const baseModelId = m.model_id || m.id;
// Presets use preset_id; base models use configId:modelId to avoid
// collisions when the same model exists across team/personal/global providers
const id = isPreset
? (m.preset_id || m.id)
: (m.config_id ? `${m.config_id}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
@@ -225,6 +239,8 @@ async function fetchModels() {
presetScope: m.preset_scope || null,
presetAvatar: m.preset_avatar || null,
presetTeamName: m.preset_team_name || null,
source: m.source || (isPreset ? 'preset' : 'global'),
teamName: m.preset_team_name || null,
hidden: !isPreset && App.hiddenModels.has(baseModelId),
};
});
@@ -337,7 +353,7 @@ async function sendMessage() {
input.style.height = 'auto';
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === selectedId);
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -430,7 +446,7 @@ async function regenerateMessage(messageId) {
if (App.isGenerating || !App.currentChatId) return;
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === selectedId);
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -501,7 +517,7 @@ async function submitEdit(messageId, newContent) {
if (!newContent.trim()) return;
const selectedId = UI.getModelValue();
const modelInfo = App.models.find(m => m.id === selectedId);
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
@@ -876,7 +892,7 @@ function initListeners() {
tab.addEventListener('click', () => UI.switchSettingsTab(tab.dataset.stab));
});
document.getElementById('settingsModel').addEventListener('change', function() {
const model = App.models.find(m => m.id === this.value);
const model = App.findModel(this.value);
const caps = model?.capabilities || lookupKnownCaps(this.value) || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
@@ -1030,6 +1046,14 @@ function initListeners() {
UI.toast(this.checked ? 'Private providers required' : 'Private provider policy removed');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminTeamAllowProviders')?.addEventListener('change', async function() {
try {
await API.adminUpdateTeam(UI._teamEditId, {
settings: JSON.stringify({ allow_team_providers: this.checked })
});
UI.toast(this.checked ? 'Team providers enabled' : 'Team providers disabled');
} catch (e) { UI.toast(e.message, 'error'); this.checked = !this.checked; }
});
document.getElementById('adminCancelMemberBtn')?.addEventListener('click', () => {
document.getElementById('adminAddMemberForm').style.display = 'none';
});
@@ -1100,6 +1124,86 @@ function initListeners() {
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
});
// Team — providers
const defaultEndpoints = {
openai: 'https://api.openai.com/v1',
anthropic: 'https://api.anthropic.com',
venice: 'https://api.venice.ai/api/v1',
openrouter: 'https://openrouter.ai/api/v1',
};
document.getElementById('settingsTeamAddProviderBtn')?.addEventListener('click', () => {
const form = document.getElementById('settingsTeamAddProvider');
form.style.display = form.style.display === 'none' ? '' : 'none';
// Populate provider type dropdown
const sel = document.getElementById('teamProviderType');
if (sel && sel.options.length === 0) {
['openai', 'anthropic', 'venice', 'openrouter'].forEach(p => {
const labels = { openai: 'OpenAI-compatible', anthropic: 'Anthropic', venice: 'Venice.ai', openrouter: 'OpenRouter' };
const opt = document.createElement('option');
opt.value = p;
opt.textContent = labels[p] || p;
sel.appendChild(opt);
});
}
});
document.getElementById('teamProviderType')?.addEventListener('change', function() {
const ep = document.getElementById('teamProviderEndpoint');
if (!ep.value || Object.values(defaultEndpoints).includes(ep.value)) {
ep.value = defaultEndpoints[this.value] || '';
}
});
document.getElementById('settingsTeamCancelProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddProvider').style.display = 'none';
});
document.getElementById('settingsTeamAddProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const name = document.getElementById('teamProviderName').value.trim();
const provider = document.getElementById('teamProviderType').value;
const endpoint = document.getElementById('teamProviderEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderKey').value;
const isPrivate = document.getElementById('teamProviderPrivate').checked;
if (!name) return UI.toast('Name required', 'error');
if (!endpoint) return UI.toast('Endpoint required', 'error');
try {
await API.teamCreateProvider(teamId, { name, provider, endpoint, api_key: apiKey, is_private: isPrivate });
document.getElementById('settingsTeamAddProvider').style.display = 'none';
document.getElementById('teamProviderName').value = '';
document.getElementById('teamProviderEndpoint').value = '';
document.getElementById('teamProviderKey').value = '';
document.getElementById('teamProviderPrivate').checked = false;
UI.toast('Provider added');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
});
// Team — edit provider
document.getElementById('settingsTeamCancelEditProvider')?.addEventListener('click', () => {
document.getElementById('settingsTeamEditProvider').style.display = 'none';
});
document.getElementById('settingsTeamEditProviderSubmit')?.addEventListener('click', async () => {
const teamId = UI._managingTeamId;
const provId = UI._editingTeamProviderId;
if (!provId) return;
const updates = {};
const name = document.getElementById('teamProviderEditName').value.trim();
const endpoint = document.getElementById('teamProviderEditEndpoint').value.trim();
const apiKey = document.getElementById('teamProviderEditKey').value;
const defaultModel = document.getElementById('teamProviderEditDefault').value.trim();
const isPrivate = document.getElementById('teamProviderEditPrivate').checked;
if (name) updates.name = name;
if (endpoint) updates.endpoint = endpoint;
if (apiKey) updates.api_key = apiKey;
if (defaultModel) updates.model_default = defaultModel;
updates.is_private = isPrivate;
try {
await API.teamUpdateProvider(teamId, provId, updates);
document.getElementById('settingsTeamEditProvider').style.display = 'none';
UI.toast('Provider updated');
await UI.loadTeamManageProviders(teamId);
} catch (e) { UI.toast(e.message, 'error'); }
});
// User — personal presets
var _userPresetForm = null;
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
@@ -1876,6 +1980,37 @@ async function settingsDeleteTeamPreset(teamId, presetId, name) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsToggleTeamProvider(teamId, providerId, currentlyActive) {
try {
await API.teamUpdateProvider(teamId, providerId, { is_active: !currentlyActive });
UI.toast(currentlyActive ? 'Provider deactivated' : 'Provider activated');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamProvider(teamId, providerId, name) {
if (!confirm(`Delete team provider "${name}"? This will remove all models from this provider for your team.`)) return;
try {
await API.teamDeleteProvider(teamId, providerId);
UI.toast('Provider deleted');
await UI.loadTeamManageProviders(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
function settingsEditTeamProvider(teamId, provId, name, endpoint, defaultModel, isPrivate) {
UI._editingTeamProviderId = provId;
document.getElementById('settingsTeamAddProvider').style.display = 'none';
const form = document.getElementById('settingsTeamEditProvider');
form.style.display = '';
document.getElementById('teamProviderEditName').value = name;
document.getElementById('teamProviderEditEndpoint').value = endpoint;
document.getElementById('teamProviderEditKey').value = '';
document.getElementById('teamProviderEditDefault').value = defaultModel;
document.getElementById('teamProviderEditPrivate').checked = isPrivate;
}
// ── Notes Panel ─────────────────────────────
var _editingNoteId = null;

View File

@@ -389,7 +389,7 @@ const UI = {
document.getElementById('typingIndicator')?.remove();
const label = modelName || 'Assistant';
const currentModel = App.models.find(m => m.id === App.settings.model);
const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.presetAvatar || null;
const div = document.createElement('div');
div.className = 'message assistant';
@@ -557,10 +557,14 @@ const UI = {
});
addGroup('🔧 My Presets', personalPresets);
addGroup('Models', models);
// Restore selection
const match = App.models.find(m => m.id === current);
// No team model groups — team providers are for preset building only.
// Team members access team models through curated presets.
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
// Restore selection (supports both composite and bare model IDs)
const match = App.findModel(current);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
} else if (App.models.length > 0) {
@@ -588,7 +592,8 @@ const UI = {
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();
@@ -619,7 +624,7 @@ const UI = {
getSelectedModelCaps() {
const modelId = UI.getModelValue();
if (!modelId) return {};
const model = App.models.find(m => m.id === modelId);
const model = App.findModel(modelId);
// Model in list has resolved caps; fallback to client-side lookup
if (model?.capabilities && Object.keys(model.capabilities).length > 0) {
return model.capabilities;
@@ -708,7 +713,7 @@ const UI = {
document.getElementById('sendBtn').disabled = on;
if (on) {
const container = document.getElementById('chatMessages');
const currentModel = App.models.find(m => m.id === App.settings.model);
const currentModel = App.findModel(App.settings.model);
const typingAvatar = currentModel?.presetAvatar || null;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
@@ -921,7 +926,13 @@ const UI = {
document.getElementById('settingsTeamManageName').textContent = teamName;
document.getElementById('settingsTeamAddMember').style.display = 'none';
document.getElementById('settingsTeamAddPreset').style.display = 'none';
await Promise.all([UI.loadTeamManageMembers(teamId), UI.loadTeamManagePresets(teamId)]);
document.getElementById('settingsTeamAddProvider').style.display = 'none';
document.getElementById('settingsTeamEditProvider').style.display = 'none';
await Promise.all([
UI.loadTeamManageMembers(teamId),
UI.loadTeamManageProviders(teamId),
UI.loadTeamManagePresets(teamId),
]);
},
async loadTeamManageMembers(teamId) {
@@ -948,6 +959,42 @@ const UI = {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManageProviders(teamId) {
const el = document.getElementById('settingsTeamProviders');
if (!el) return;
el.innerHTML = '<div class="loading">Loading...</div>';
const addBtn = document.getElementById('settingsTeamAddProviderBtn');
try {
const resp = await API.teamListProviders(teamId);
const provs = resp.providers || [];
const allowed = resp.allow_team_providers !== false;
// Show/hide add button and entire section based on policy
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
if (!allowed && provs.length === 0) {
el.innerHTML = '<div class="empty-hint">Team provider management is disabled by your administrator</div>';
return;
}
el.innerHTML = provs.map(p => {
const statusCls = p.is_active ? 'badge-admin' : 'badge-user';
const statusLabel = p.is_active ? 'active' : 'inactive';
const privateBadge = p.is_private ? ' <span class="badge-user" style="font-size:9px">🔒 private</span>' : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
<strong>${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.provider)} · ${p.has_key ? '🔑' : 'no key'} <span class="${statusCls}" style="font-size:10px">${statusLabel}</span>${privateBadge}</div>
</div>
<div class="preset-actions">
<button class="btn-small" onclick="settingsEditTeamProvider('${teamId}','${p.id}','${esc(p.name)}','${esc(p.endpoint || '')}','${esc(p.model_default || '')}',${!!p.is_private})" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="settingsToggleTeamProvider('${teamId}','${p.id}',${p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="settingsDeleteTeamProvider('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No team providers — add one to give your team access to additional models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
el.innerHTML = '<div class="loading">Loading...</div>';
@@ -975,13 +1022,27 @@ const UI = {
const resp = await API.teamListAvailableModels(teamId);
const models = resp.models || [];
sel.innerHTML = '<option value="">Select base model...</option>';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
sel.appendChild(opt);
});
// Group by source for clarity
const globalModels = models.filter(m => m.source !== 'team');
const teamModels = models.filter(m => m.source === 'team');
const addGroup = (label, items) => {
if (!items.length) return;
const group = document.createElement('optgroup');
group.label = label;
items.forEach(m => {
const opt = document.createElement('option');
opt.value = m.model_id || m.id;
const vis = m.visibility === 'team' ? ' [team-only]' : '';
opt.textContent = (m.model_id || m.id) + (m.provider_name ? ` (${m.provider_name})` : '') + vis;
group.appendChild(opt);
});
sel.appendChild(group);
};
addGroup('Global Models', globalModels);
addGroup('Team Provider Models', teamModels);
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = '<option value="">Select base model...</option>';
@@ -1350,12 +1411,14 @@ const UI = {
document.getElementById('adminTeamDetailName').textContent = teamName;
document.getElementById('adminAddMemberForm').style.display = 'none';
// Load team settings for policy checkbox
// Load team settings for policy checkboxes
try {
const team = await API.adminGetTeam(teamId);
const settings = typeof team.settings === 'string' ? JSON.parse(team.settings || '{}') : (team.settings || {});
document.getElementById('adminTeamPrivatePolicy').checked = !!settings.require_private_providers;
} catch (e) { /* proceed with unchecked default */ }
// allow_team_providers defaults to true if not explicitly set
document.getElementById('adminTeamAllowProviders').checked = settings.allow_team_providers !== false;
} catch (e) { /* proceed with defaults */ }
await this.loadTeamMembers(teamId);
},
@@ -1493,7 +1556,8 @@ const UI = {
if (caps.tool_calling) badges.push('<span class="cap-badge cap-accent">🔧</span>');
if (caps.vision) badges.push('<span class="cap-badge cap-accent">👁</span>');
if (caps.thinking) badges.push('<span class="cap-badge cap-accent">💭</span>');
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>' : '';
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(mid);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';