Changeset 0.22.7 (#149)

This commit is contained in:
2026-03-04 10:44:42 +00:00
parent d8e0664fa3
commit 389e47b0f9
62 changed files with 6820 additions and 1476 deletions

View File

@@ -116,71 +116,61 @@ describe('GET /models/enabled response contract', () => {
});
});
// ── /models/enabled with preset response ────
// ── /models/enabled with persona response ────
describe('GET /models/enabled preset contract', () => {
// ACTUAL backend UserModel for a persona includes both canonical and alias fields.
const presetModel = {
id: 'preset-uuid-123',
describe('GET /models/enabled persona contract', () => {
// ACTUAL backend UserModel for a persona (v0.22.8+: unified naming, no aliases).
const personaModel = {
id: 'persona-uuid-123',
model_id: 'gpt-4o',
display_name: 'Code Helper',
source: 'persona',
// Backend canonical fields
// Backend canonical fields (v0.22.8: all use persona_ prefix)
provider_config_id: 'cfg1',
persona_id: 'preset-uuid-123',
scope: 'global',
avatar: null,
// Frontend alias fields (populated by resolver)
is_preset: true,
preset_id: 'preset-uuid-123',
preset_scope: 'global',
preset_avatar: null,
is_persona: true,
persona_id: 'persona-uuid-123',
persona_scope: 'global',
persona_avatar: null,
persona_team_name: null,
config_id: 'cfg1',
provider_name: 'OpenAI',
capabilities: { streaming: true },
};
it('preset has is_preset = true', () => {
assert.equal(presetModel.is_preset, true);
it('persona has is_persona = true', () => {
assert.equal(personaModel.is_persona, true);
});
it('preset has preset_id matching persona_id', () => {
assert.ok(presetModel.preset_id);
assert.equal(presetModel.preset_id, presetModel.persona_id,
'preset_id alias must match persona_id');
it('persona has persona_id', () => {
assert.ok(personaModel.persona_id);
});
it('preset has preset_scope matching scope', () => {
assert.ok(['global', 'team', 'personal'].includes(presetModel.preset_scope));
assert.equal(presetModel.preset_scope, presetModel.scope,
'preset_scope alias must match scope');
it('persona has persona_scope', () => {
assert.ok(['global', 'team', 'personal'].includes(personaModel.persona_scope));
});
it('preset ID uses preset_id not composite', () => {
// Frontend: id = isPreset ? (m.preset_id || m.persona_id || m.id) : composite
const id = presetModel.is_preset
? (presetModel.preset_id || presetModel.persona_id || presetModel.id)
: `${presetModel.config_id || presetModel.provider_config_id}:${presetModel.model_id}`;
assert.equal(id, 'preset-uuid-123');
it('persona ID uses persona_id not composite', () => {
// Frontend: id = isPersona ? (m.persona_id || m.id) : composite
const id = personaModel.is_persona
? (personaModel.persona_id || personaModel.id)
: `${personaModel.config_id || personaModel.provider_config_id}:${personaModel.model_id}`;
assert.equal(id, 'persona-uuid-123');
});
it('frontend works with ONLY canonical fields (no aliases)', () => {
// If someone breaks the alias population in resolver.go,
// the frontend fallback chain must still produce correct IDs.
// v0.22.8: no more dual-key aliases — backend sends is_persona directly.
const rawBackend = {
id: 'preset-uuid-123',
id: 'persona-uuid-123',
model_id: 'gpt-4o',
source: 'persona',
provider_config_id: 'cfg1',
persona_id: 'preset-uuid-123',
scope: 'global',
// NO is_preset, NO preset_id, NO config_id aliases
is_persona: true,
persona_id: 'persona-uuid-123',
persona_scope: 'global',
};
// is_preset would be undefined/falsy → treated as catalog model
// This test documents the FAILURE MODE if aliases break
const isPreset = !!rawBackend.is_preset; // false!
assert.equal(isPreset, false,
'Without is_preset alias, persona is misidentified as catalog model');
const isPersona = !!rawBackend.is_persona;
assert.equal(isPersona, true,
'is_persona field must be present for correct identification');
});
});
@@ -441,8 +431,8 @@ describe('Response shape consistency', () => {
assert.ok('models' in shape, 'admin/models must use "models" key');
});
it('presets uses {presets:[]}', () => {
const shape = { presets: [] };
assert.ok('presets' in shape);
it('personas uses {personas:[]}', () => {
const shape = { personas: [] };
assert.ok('personas' in shape);
});
});

View File

@@ -116,22 +116,24 @@ function loadSource(sandbox, filename) {
*/
function loadAppModules(overrides = {}) {
const sandbox = createBrowserContext(overrides);
loadSource(sandbox, 'app-state.js');
loadSource(sandbox, 'api.js');
loadSource(sandbox, 'app.js');
return { API: sandbox.API, App: sandbox.App, sandbox };
}
/**
* Extract the model-processing transform from fetchModels.
* Extract the model-processing transform from fetchModels (app-state.js).
* This is the core mapping logic that converts API response → App.models.
* v0.22.8: unified persona naming — no more preset aliases.
*/
function processModelsResponse(data, hiddenModels = new Set()) {
return (data.models || []).map(m => {
const isPreset = !!m.is_preset;
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id;
const id = isPreset
? (m.preset_id || m.persona_id || m.id)
const id = isPersona
? (m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
return {
id,
@@ -139,14 +141,14 @@ function processModelsResponse(data, hiddenModels = new Set()) {
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: cfgId || null,
isPreset,
presetId: m.preset_id || m.persona_id || null,
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
source: m.source || (isPreset ? 'preset' : 'global'),
teamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
hidden: !isPreset && hiddenModels.has(baseModelId),
isPersona,
personaId: m.persona_id || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null,
hidden: !isPersona && hiddenModels.has(baseModelId),
};
});
}

View File

@@ -75,9 +75,9 @@ describe('processModelsResponse — catalog models', () => {
assert.equal(models[0].name, 'claude-3-opus');
});
it('marks catalog models as NOT presets', () => {
it('marks catalog models as NOT personas', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].isPreset, false);
assert.equal(models[0].isPersona, false);
});
it('preserves configId from config_id || provider_config_id', () => {
@@ -93,7 +93,7 @@ describe('processModelsResponse — catalog models', () => {
// ── Preset transform ─────────────────────────
describe('processModelsResponse — presets', () => {
describe('processModelsResponse — personas', () => {
const apiResponse = {
models: [
{
@@ -107,47 +107,47 @@ describe('processModelsResponse — presets', () => {
avatar: '/avatars/code.png',
source: 'persona',
// Frontend alias fields
is_preset: true,
preset_id: 'preset-uuid',
preset_scope: 'global',
preset_avatar: '/avatars/code.png',
preset_team_name: null,
is_persona: true,
persona_id: 'preset-uuid',
persona_scope: 'global',
persona_avatar: '/avatars/code.png',
persona_team_name: null,
config_id: 'cfg-uuid',
provider_name: 'OpenAI',
},
],
};
it('uses preset_id as ID (not composite)', () => {
it('uses persona_id as ID (not composite)', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].id, 'preset-uuid',
'preset ID must use preset_id, not config_id:model_id');
'persona ID must use persona_id, not config_id:model_id');
});
it('falls back to persona_id when preset_id is missing', () => {
it('falls back to persona_id when not present', () => {
const resp = {
models: [{
id: 'p-uuid', model_id: 'gpt-4o',
is_preset: true, persona_id: 'p-uuid',
is_persona: true, persona_id: 'p-uuid',
// NO preset_id — simulates broken alias
source: 'persona',
}],
};
const models = processModelsResponse(resp);
assert.equal(models[0].id, 'p-uuid',
'must fall back to persona_id when preset_id is missing');
'must fall back to persona_id');
});
it('marks as preset', () => {
it('marks as persona', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].isPreset, true);
assert.equal(models[0].isPersona, true);
});
it('preserves preset metadata', () => {
it('preserves persona metadata', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].presetScope, 'global');
assert.equal(models[0].presetAvatar, '/avatars/code.png');
assert.equal(models[0].presetId, 'preset-uuid');
assert.equal(models[0].personaScope, 'global');
assert.equal(models[0].personaAvatar, '/avatars/code.png');
assert.equal(models[0].personaId, 'preset-uuid');
});
it('baseModelId is the underlying model', () => {
@@ -215,16 +215,16 @@ describe('processModelsResponse — hidden models', () => {
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
});
it('presets are never hidden via model ID', () => {
it('personas are never hidden via model ID', () => {
const resp = {
models: [
{ model_id: 'gpt-4o', is_preset: true, preset_id: 'p1', display_name: 'Preset' },
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
],
};
const hidden = new Set(['gpt-4o']);
const models = processModelsResponse(resp, hidden);
assert.equal(models[0].hidden, false,
'presets must NOT be hidden by base model hidden pref');
'personas must NOT be hidden by base model hidden pref');
});
});
@@ -270,44 +270,44 @@ describe('Model sorting', () => {
function sortModels(models) {
const scopeOrder = { global: 0, team: 1, personal: 2 };
return [...models].sort((a, b) => {
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
if (a.isPreset && b.isPreset) {
const sa = scopeOrder[a.presetScope] ?? 9;
const sb = scopeOrder[b.presetScope] ?? 9;
if (a.isPersona && !b.isPersona) return -1;
if (!a.isPersona && b.isPersona) return 1;
if (a.isPersona && b.isPersona) {
const sa = scopeOrder[a.personaScope] ?? 9;
const sb = scopeOrder[b.personaScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
}
it('presets sort before regular models', () => {
it('personas sort before regular models', () => {
const models = [
{ name: 'GPT-4o', isPreset: false },
{ name: 'Code Helper', isPreset: true, presetScope: 'global' },
{ name: 'GPT-4o', isPersona: false },
{ name: 'Code Helper', isPersona: true, personaScope: 'global' },
];
const sorted = sortModels(models);
assert.equal(sorted[0].name, 'Code Helper');
assert.equal(sorted[1].name, 'GPT-4o');
});
it('global presets sort before team presets', () => {
it('global personas sort before team personas', () => {
const models = [
{ name: 'Team Bot', isPreset: true, presetScope: 'team' },
{ name: 'Global Bot', isPreset: true, presetScope: 'global' },
{ name: 'My Bot', isPreset: true, presetScope: 'personal' },
{ name: 'Team Bot', isPersona: true, personaScope: 'team' },
{ name: 'Global Bot', isPersona: true, personaScope: 'global' },
{ name: 'My Bot', isPersona: true, personaScope: 'personal' },
];
const sorted = sortModels(models);
assert.equal(sorted[0].presetScope, 'global');
assert.equal(sorted[1].presetScope, 'team');
assert.equal(sorted[2].presetScope, 'personal');
assert.equal(sorted[0].personaScope, 'global');
assert.equal(sorted[1].personaScope, 'team');
assert.equal(sorted[2].personaScope, 'personal');
});
it('regular models sort alphabetically', () => {
const models = [
{ name: 'Zephyr', isPreset: false },
{ name: 'Claude', isPreset: false },
{ name: 'GPT-4o', isPreset: false },
{ name: 'Zephyr', isPersona: false },
{ name: 'Claude', isPersona: false },
{ name: 'GPT-4o', isPersona: false },
];
const sorted = sortModels(models);
assert.equal(sorted[0].name, 'Claude');

View File

@@ -65,12 +65,12 @@ describe('Policy wiring audit — source code', () => {
// ── allow_user_personas ──
it('UI has checkUserPresetsAllowed function', () => {
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed — preset button will show when policy is off');
it('UI has checkUserPersonasAllowed function', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed — persona button will show when policy is off');
});
it('checkUserPresetsAllowed reads App.policies.allow_user_personas', () => {
it('checkUserPersonasAllowed reads App.policies.allow_user_personas', () => {
assert.ok(uiSrc.includes('allow_user_personas'),
'MISSING: allow_user_personas check in UI');
});
@@ -104,10 +104,10 @@ describe('Policy wiring audit — source code', () => {
'MISSING: checkUserProvidersAllowed call after admin save');
});
it('admin save calls checkUserPresetsAllowed', () => {
it('admin save calls checkUserPersonasAllowed', () => {
const saveFunc = appSrc.slice(appSrc.indexOf('handleSaveAdminSettings'));
assert.ok(saveFunc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed call after admin save');
assert.ok(saveFunc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call after admin save');
});
it('admin save calls fetchModels to refresh model list', () => {
@@ -118,9 +118,9 @@ describe('Policy wiring audit — source code', () => {
// ── Models tab calls preset check ──
it('models tab switch calls checkUserPresetsAllowed', () => {
assert.ok(uiSrc.includes('checkUserPresetsAllowed'),
'MISSING: checkUserPresetsAllowed call on models tab switch');
it('models tab switch calls checkUserPersonasAllowed', () => {
assert.ok(uiSrc.includes('checkUserPersonasAllowed'),
'MISSING: checkUserPersonasAllowed call on models tab switch');
});
});
@@ -283,9 +283,9 @@ describe('Critical HTML elements exist in server templates', () => {
const requiredElements = [
// Settings surface — provider section scaffold
'userPresetList', // User preset list container
'userAddPresetBtn', // New preset button (policy-gated)
'userAddPresetForm', // Preset form container
'userPersonaList', // User persona list container
'userAddPersonaBtn', // New persona button (policy-gated)
'userAddPersonaForm', // Persona form container
'userProvidersDisabled', // BYOK disabled notice
'providerShowAddBtn', // Add provider button (policy-gated)
// Admin settings — policy toggles

View File

@@ -31,7 +31,7 @@ function globalModel(modelId, configId, providerName) {
provider_type: 'openai',
source: 'catalog',
scope: 'global',
is_preset: false,
is_persona: false,
capabilities: { streaming: true },
hidden: false,
};
@@ -48,7 +48,7 @@ function teamModel(modelId, configId, providerName, teamName) {
provider_type: 'venice',
source: 'catalog',
scope: 'team',
is_preset: false,
is_persona: false,
capabilities: { streaming: true },
hidden: false,
team_name: teamName,
@@ -66,21 +66,21 @@ function personalModel(modelId, configId, providerName) {
provider_type: 'venice',
source: 'catalog',
scope: 'personal',
is_preset: false,
is_persona: false,
capabilities: { streaming: true },
hidden: false,
};
}
function presetModel(presetId, baseModelId, scope, teamName) {
function personaModel(personaId, baseModelId, scope, teamName) {
return {
id: presetId,
id: personaId,
model_id: baseModelId,
display_name: `Preset: ${baseModelId}`,
preset_id: presetId,
preset_scope: scope,
preset_team_name: teamName || '',
is_preset: true,
display_name: `Persona: ${baseModelId}`,
persona_id: personaId,
persona_scope: scope,
persona_team_name: teamName || '',
is_persona: true,
source: 'persona',
scope: scope,
capabilities: { streaming: true },
@@ -144,9 +144,9 @@ describe('User Journey: Platform Admin (no team, no BYOK)', () => {
}
});
it('source is catalog (not preset)', () => {
it('source is catalog (not persona)', () => {
for (const m of models) {
assert.equal(m.isPreset, false);
assert.equal(m.isPersona, false);
assert.equal(m.source, 'catalog');
}
});
@@ -322,42 +322,42 @@ describe('Edge: Hidden model preferences', () => {
assert.equal(main.hidden, false, 'gpt-4o should not be hidden');
});
it('presets are never hidden', () => {
const preset = presetModel('preset-1', 'gpt-4o', 'global');
it('personas are never hidden', () => {
const persona = personaModel('preset-1', 'gpt-4o', 'global');
const hidden = new Set(['gpt-4o']);
const models = processModelsResponse({ models: [preset] }, hidden);
assert.equal(models[0].hidden, false, 'presets must never be hidden');
const models = processModelsResponse({ models: [persona] }, hidden);
assert.equal(models[0].hidden, false, 'personas must never be hidden');
});
});
describe('Edge: Presets mixed with catalog models', () => {
describe('Edge: Personas mixed with catalog models', () => {
const apiResponse = {
models: [
...GLOBAL_MODELS,
presetModel('preset-code', 'gpt-4o', 'global'),
presetModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
personaModel('preset-code', 'gpt-4o', 'global'),
personaModel('preset-team', 'llama-3.3-70b', 'team', 'Engineering'),
],
};
const models = processModelsResponse(apiResponse);
it('presets use preset_id as ID, not composite', () => {
const preset = models.find(m => m.presetId === 'preset-code');
assert.ok(preset, 'preset-code should be present');
assert.equal(preset.id, 'preset-code');
assert.equal(preset.isPreset, true);
it('personas use persona_id as ID, not composite', () => {
const persona = models.find(m => m.personaId === 'preset-code');
assert.ok(persona, 'preset-code should be present');
assert.equal(persona.id, 'preset-code');
assert.equal(persona.isPersona, true);
});
it('preset and catalog model with same model_id have different IDs', () => {
const catalog = models.find(m => !m.isPreset && m.baseModelId === 'gpt-4o');
const preset = models.find(m => m.isPreset && m.baseModelId === 'gpt-4o');
it('persona and catalog model with same model_id have different IDs', () => {
const catalog = models.find(m => !m.isPersona && m.baseModelId === 'gpt-4o');
const persona = models.find(m => m.isPersona && m.baseModelId === 'gpt-4o');
assert.ok(catalog, 'catalog gpt-4o should exist');
assert.ok(preset, 'preset gpt-4o should exist');
assert.notEqual(catalog.id, preset.id);
assert.ok(persona, 'preset gpt-4o should exist');
assert.notEqual(catalog.id, persona.id);
});
it('preset team name flows through', () => {
const teamPreset = models.find(m => m.presetId === 'preset-team');
assert.equal(teamPreset.presetTeamName, 'Engineering');
it('persona team name flows through', () => {
const teamPersona = models.find(m => m.personaId === 'preset-team');
assert.equal(teamPersona.personaTeamName, 'Engineering');
});
});
@@ -412,10 +412,10 @@ describe('Full Matrix: 4 actors × backend response → frontend model count', (
`${tc.actor}: duplicate IDs: ${JSON.stringify(ids)}`);
});
it(`${tc.actor}: all models have configId or are presets`, () => {
it(`${tc.actor}: all models have configId or are personas`, () => {
const result = processModelsResponse({ models: tc.models });
for (const m of result) {
if (!m.isPreset) {
if (!m.isPersona) {
assert.ok(m.configId,
`${tc.actor}: model ${m.baseModelId} missing configId — ` +
`frontend will fail to route completions`);

View File

@@ -2,7 +2,7 @@
// Chat Switchboard Admin Handlers
// ==========================================
// Admin actions: user management, roles, model visibility,
// presets, team management, global providers.
// personas, team management, global providers.
// ── Admin Actions ────────────────────────────
@@ -155,7 +155,7 @@ async function toggleUserModelVisibility(modelId, currentlyHidden) {
}
async function bulkSetUserModelVisibility(show) {
const models = App.models.filter(m => !m.isPreset);
const models = App.models.filter(m => !m.isPersona);
if (!models.length) return;
const shouldHide = !show;
// Only update models not already in the desired state
@@ -172,41 +172,41 @@ async function bulkSetUserModelVisibility(show) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteUserPreset(id, name) {
if (!await showConfirm(`Delete preset "${name}"?`)) return;
async function deleteUserPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.deleteUserPreset(id);
UI.toast('Preset deleted');
await UI.loadUserPresets();
await API.deleteUserPersona(id);
UI.toast('Persona deleted');
await UI.loadUserPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
// ── Admin Presets ────────────────────────────
// ── Admin Personas ────────────────────────────
var _adminPresetForm = null;
function ensureAdminPresetForm() {
if (_adminPresetForm) return _adminPresetForm;
const container = document.getElementById('adminAddPresetForm');
var _adminPersonaForm = null;
function ensureAdminPersonaForm() {
if (_adminPersonaForm) return _adminPersonaForm;
const container = document.getElementById('adminAddPersonaForm');
if (!container) return null;
_adminPresetForm = renderPresetForm(container, {
prefix: 'adminPreset',
_adminPersonaForm = renderPersonaForm(container, {
prefix: 'adminPersona',
showAvatar: true,
showProviderConfig: true,
showKBPicker: true,
kbScope: 'admin',
onSubmit: (vals) => createAdminPreset(vals),
onSubmit: (vals) => createAdminPersona(vals),
onCancel: () => {
container.style.display = 'none';
_editingPresetId = null;
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
_editingPersonaId = null;
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
});
// Override avatar handlers for edit-mode (upload immediately for existing presets)
const fileInput = document.getElementById('adminPreset_avatarFileInput');
// Override avatar handlers for edit-mode (upload immediately for existing personas)
const fileInput = document.getElementById('adminPersona_avatarFileInput');
if (fileInput) {
// Remove default handler set by renderPresetForm, add edit-aware one
// Remove default handler set by renderPersonaForm, add edit-aware one
const newInput = fileInput.cloneNode(true);
fileInput.parentNode.replaceChild(newInput, fileInput);
newInput.addEventListener('change', async function() {
@@ -214,55 +214,55 @@ function ensureAdminPresetForm() {
if (!file) return;
const reader = new FileReader();
reader.onload = async () => {
if (_editingPresetId) {
if (_editingPersonaId) {
try {
const data = await API.adminUploadPresetAvatar(_editingPresetId, reader.result);
const data = await API.adminUploadPersonaAvatar(_editingPersonaId, reader.result);
if (data.avatar) {
_adminPresetForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPresets(true);
_adminPersonaForm.updateAvatarPreview(data.avatar);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Preset avatar updated');
UI.toast('Persona avatar updated');
}
} catch (e) { UI.toast(e.message || 'Upload failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(reader.result);
_adminPersonaForm.updateAvatarPreview(reader.result);
}
};
reader.readAsDataURL(file);
this.value = '';
});
document.getElementById('adminPreset_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
document.getElementById('adminPersona_avatarUploadBtn')?.addEventListener('click', () => newInput.click());
}
const removeBtn = document.getElementById('adminPreset_avatarRemoveBtn');
const removeBtn = document.getElementById('adminPersona_avatarRemoveBtn');
if (removeBtn) {
const newBtn = removeBtn.cloneNode(true);
removeBtn.parentNode.replaceChild(newBtn, removeBtn);
newBtn.addEventListener('click', async () => {
if (_editingPresetId) {
if (_editingPersonaId) {
try {
await API.adminDeletePresetAvatar(_editingPresetId);
_adminPresetForm.updateAvatarPreview(null);
await UI.loadAdminPresets(true);
await API.adminDeletePersonaAvatar(_editingPersonaId);
_adminPersonaForm.updateAvatarPreview(null);
await UI.loadAdminPersonas(true);
await fetchModels();
UI.toast('Preset avatar removed');
UI.toast('Persona avatar removed');
} catch (e) { UI.toast(e.message || 'Remove failed', 'error'); }
} else {
_adminPresetForm.updateAvatarPreview(null);
_adminPersonaForm.updateAvatarPreview(null);
}
});
}
return _adminPresetForm;
return _adminPersonaForm;
}
var _editingPresetId = null;
function editAdminPreset(id) {
const p = UI._presetCache?.[id];
var _editingPersonaId = null;
function editAdminPersona(id) {
const p = UI._personaCache?.[id];
if (!p) return;
_editingPresetId = id;
ensureAdminPresetForm();
const form = _adminPresetForm;
_editingPersonaId = id;
ensureAdminPersonaForm();
const form = _adminPersonaForm;
if (!form) return;
document.getElementById('adminAddPresetForm').style.display = '';
document.getElementById('adminAddPersonaForm').style.display = '';
form.setValues(p);
form.setSubmitLabel('Update');
// Load KB bindings (v0.17.0)
@@ -271,73 +271,73 @@ function editAdminPreset(id) {
}
}
async function createAdminPreset(vals) {
async function createAdminPersona(vals) {
if (!vals) {
if (_adminPresetForm) vals = _adminPresetForm.getValues();
if (_adminPersonaForm) vals = _adminPersonaForm.getValues();
else return;
}
if (!vals.name || !vals.base_model_id) { UI.toast('Name and base model are required', 'warning'); return; }
const preset = {
const persona = {
name: vals.name,
base_model_id: vals.base_model_id,
description: vals.description || '',
system_prompt: vals.system_prompt || '',
};
if (vals.provider_config_id) preset.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) preset.temperature = vals.temperature;
if (vals.max_tokens != null) preset.max_tokens = vals.max_tokens;
if (vals.provider_config_id) persona.provider_config_id = vals.provider_config_id;
if (vals.temperature != null) persona.temperature = vals.temperature;
if (vals.max_tokens != null) persona.max_tokens = vals.max_tokens;
try {
let presetId = _editingPresetId;
if (_editingPresetId) {
await API.adminUpdatePreset(_editingPresetId, preset);
UI.toast('Preset updated', 'success');
let personaId = _editingPersonaId;
if (_editingPersonaId) {
await API.adminUpdatePersona(_editingPersonaId, preset);
UI.toast('Persona updated', 'success');
} else {
const result = await API.adminCreatePreset(preset);
presetId = result?.id || result?.preset?.id;
UI.toast('Preset created', 'success');
const result = await API.adminCreatePersona(preset);
personaId = result?.id || result?.preset?.id;
UI.toast('Persona created', 'success');
}
// Upload pending avatar for new presets
if (!_editingPresetId && presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Preset avatar upload failed:', e.message); }
// Upload pending avatar for new personas
if (!_editingPersonaId && personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('Persona avatar upload failed:', e.message); }
}
// Save KB bindings (v0.17.0)
if (presetId && vals._kbValues && _adminPresetForm?.kbPicker) {
try { await API.adminSetPresetKBs(presetId, vals._kbValues); }
catch (e) { console.warn('Preset KB binding failed:', e.message); }
if (personaId && vals._kbValues && _adminPersonaForm?.kbPicker) {
try { await API.adminSetPresetKBs(personaId, vals._kbValues); }
catch (e) { console.warn('Persona KB binding failed:', e.message); }
}
_editingPresetId = null;
document.getElementById('adminAddPresetForm').style.display = 'none';
if (_adminPresetForm) {
_adminPresetForm.setSubmitLabel('Create');
_adminPresetForm.clearForm();
_editingPersonaId = null;
document.getElementById('adminAddPersonaForm').style.display = 'none';
if (_adminPersonaForm) {
_adminPersonaForm.setSubmitLabel('Create');
_adminPersonaForm.clearForm();
}
await UI.loadAdminPresets();
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function toggleAdminPreset(id, active) {
async function toggleAdminPersona(id, active) {
try {
const el = _adminScroll(), pos = el?.scrollTop || 0;
await API.adminUpdatePreset(id, { is_active: active });
await UI.loadAdminPresets(true);
await API.adminUpdatePersona(id, { is_active: active });
await UI.loadAdminPersonas(true);
_restoreScroll(el, pos);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
async function deleteAdminPreset(id, name) {
if (!await showConfirm(`Delete preset "${name}"?`)) return;
async function deleteAdminPersona(id, name) {
if (!await showConfirm(`Delete persona "${name}"?`)) return;
try {
await API.adminDeletePreset(id);
UI.toast('Preset deleted', 'success');
await UI.loadAdminPresets();
await API.adminDeletePersona(id);
UI.toast('Persona deleted', 'success');
await UI.loadAdminPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -400,12 +400,12 @@ async function settingsRemoveTeamMember(teamId, memberId, email) {
} catch (e) { UI.toast(e.message, 'error'); }
}
async function settingsDeleteTeamPreset(teamId, presetId, name) {
if (!await showConfirm(`Delete team preset "${name}"?`)) return;
async function settingsDeleteTeamPersona(teamId, personaId, name) {
if (!await showConfirm(`Delete team persona "${name}"?`)) return;
try {
await API.teamDeletePreset(teamId, presetId);
UI.toast('Preset deleted');
await UI.loadTeamManagePresets(teamId);
await API.teamDeletePreset(teamId, personaId);
UI.toast('Persona deleted');
await UI.loadTeamManagePersonas(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -483,14 +483,14 @@ function _initAdminListeners() {
// Admin — models
document.getElementById('adminFetchModelsBtn')?.addEventListener('click', fetchAdminModels);
// Admin — presets (shared form)
document.getElementById('adminAddPresetBtn')?.addEventListener('click', () => {
const form = ensureAdminPresetForm();
// Admin — personas (shared form)
document.getElementById('adminAddPersonaBtn')?.addEventListener('click', () => {
const form = ensureAdminPersonaForm();
if (!form) return;
_editingPresetId = null;
_editingPersonaId = null;
form.setSubmitLabel('Create');
form.clearForm();
const f = document.getElementById('adminAddPresetForm');
const f = document.getElementById('adminAddPersonaForm');
f.style.display = f.style.display === 'none' ? '' : 'none';
});

372
src/js/admin-scaffold.js Normal file
View File

@@ -0,0 +1,372 @@
// ==========================================
// Chat Switchboard - Admin Surface Scaffold
// ==========================================
// Injects section-specific DOM into adminDynamic, wires listeners,
// and calls the appropriate ADMIN_LOADERS[section]() from ui-admin.js.
// Loaded only on the admin surface (/admin/:section).
(function() {
'use strict';
// Each admin section loader (ui-admin.js) expects specific container
// elements. SCAFFOLDING maps section name -> HTML to inject before
// the loader runs.
var SCAFFOLDING = {};
// -- People -----------------------------------------------------------
SCAFFOLDING.users =
'<div id="adminUserList" class="admin-list"></div>' +
'<div id="adminAddUserForm" class="admin-inline-form" style="display:none">' +
'<h4>Create User</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>Username</label><input type="text" id="adminNewUsername" placeholder="username"></div>' +
'<div class="form-group"><label>Email</label><input type="text" id="adminNewEmail" placeholder="email@example.com"></div>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Password</label><input type="password" id="adminNewPassword" placeholder="min 8 chars"></div>' +
'<div class="form-group"><label>Role</label><select id="adminNewRole"><option value="user">User</option><option value="admin">Admin</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateUserBtn">Create User</button>' +
'<button class="btn-small" id="adminCancelUserBtn">Cancel</button>' +
'</div>' +
'</div>';
SCAFFOLDING.teams =
'<div id="adminTeamList" class="admin-list"></div>' +
'<div id="adminAddTeamForm" class="admin-inline-form" style="display:none">' +
'<h4>Create Team</h4>' +
'<div class="form-group"><label>Name</label><input type="text" id="adminTeamName" placeholder="Team name"></div>' +
'<div class="form-group"><label>Description</label><input type="text" id="adminTeamDesc" placeholder="Optional description"></div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateTeamBtn">Create Team</button>' +
'<button class="btn-small" id="adminCancelTeamBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminTeamDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminTeamBackBtn">&larr; Back</button>' +
'<h4 id="adminTeamDetailName" style="margin:0"></h4>' +
'</div>' +
'<div style="margin-bottom:12px">' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamPrivatePolicy"> Require private providers (BYOK)</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminTeamAllowProviders" checked> Allow team providers</label>' +
'</div>' +
'<div id="adminMemberList" class="admin-list"></div>' +
'<div id="adminAddMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' +
'<div class="form-group"><label>User</label><select id="adminMemberUser"><option value="">Select user...</option></select></div>' +
'<div class="form-group"><label>Role</label><select id="adminMemberRole"><option value="member">Member</option><option value="admin">Admin</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminAddMemberSubmit">Add Member</button>' +
'<button class="btn-small" id="adminCancelMemberBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<button class="btn-small" id="adminAddMemberBtn" style="margin-top:8px">+ Add Member</button>' +
'</div>';
SCAFFOLDING.groups =
'<div id="adminGroupList" class="admin-list"></div>' +
'<div id="adminAddGroupForm" class="admin-inline-form" style="display:none">' +
'<h4>Create Group</h4>' +
'<div class="form-group"><label>Name</label><input type="text" id="adminGroupName" placeholder="Group name"></div>' +
'<div class="form-group"><label>Description</label><input type="text" id="adminGroupDesc" placeholder="Optional description"></div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Scope</label><select id="adminGroupScope"><option value="global">Global</option><option value="team">Team</option></select></div>' +
'<div class="form-group" id="adminGroupTeamRow" style="display:none"><label>Team</label><select id="adminGroupTeam"></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminCreateGroupBtn">Create Group</button>' +
'<button class="btn-small" id="adminCancelGroupBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<div id="adminGroupDetail" style="display:none">' +
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small" id="adminGroupBackBtn">&larr; Back</button>' +
'<h4 id="adminGroupDetailName" style="margin:0"></h4>' +
'<span id="adminGroupDetailMeta" class="text-muted"></span>' +
'</div>' +
'<div id="adminGroupMemberList" class="admin-list"></div>' +
'<div id="adminAddGroupMemberForm" class="admin-inline-form" style="display:none">' +
'<div class="form-row">' +
'<div class="form-group"><label>User</label><select id="adminGroupMemberUser"><option value="">Select user...</option></select></div>' +
'</div>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminAddGroupMemberSubmit">Add Member</button>' +
'<button class="btn-small" id="adminCancelGroupMemberBtn">Cancel</button>' +
'</div>' +
'</div>' +
'<button class="btn-small" id="adminAddGroupMemberBtn" style="margin-top:8px">+ Add Member</button>' +
'</div>';
// -- AI ---------------------------------------------------------------
SCAFFOLDING.providers =
'<div id="adminProviderList" class="admin-list"></div>' +
'<div id="adminAddProviderForm" class="admin-inline-form" style="display:none"></div>';
SCAFFOLDING.models =
'<div style="display:flex;align-items:center;gap:8px;margin-bottom:12px">' +
'<button class="btn-small btn-primary" id="adminFetchModelsBtn">Fetch Models</button>' +
'<span id="adminModelsHint" class="text-muted" style="font-size:12px"></span>' +
'</div>' +
'<div id="adminModelList" class="admin-list"></div>';
SCAFFOLDING.personas =
'<div id="adminPersonaList" class="admin-list"></div>' +
'<div id="adminAddPersonaForm" class="admin-inline-form" style="display:none"></div>';
SCAFFOLDING.roles = '<div id="adminRolesContent"></div>';
SCAFFOLDING.knowledgeBases = '<div id="adminKBContent"></div>';
SCAFFOLDING.memory = '<div id="adminMemoryContent"></div>';
// -- System -----------------------------------------------------------
SCAFFOLDING.settings =
'<div class="admin-settings-form">' +
'<div class="settings-section"><h4>Registration</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminRegToggle"> Allow new user registration</label>' +
'<div class="form-group" style="margin-top:8px"><label>Default state for new users</label>' +
'<select id="adminRegDefaultState"><option value="pending">Pending (require approval)</option><option value="active">Active (auto-approve)</option></select>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>System Prompt</h4>' +
'<textarea id="adminSystemPrompt" rows="4" placeholder="Global system prompt prepended to all chats..."></textarea>' +
'</div>' +
'<div class="settings-section"><h4>Default Model</h4>' +
'<select id="adminDefaultModel"><option value="">-- None (first visible) --</option></select>' +
'</div>' +
'<div class="settings-section"><h4>Policies</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserProvidersToggle"> Allow BYOK (user API keys)</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminUserPersonasToggle"> Allow user personas</label>' +
'<label class="checkbox-label"><input type="checkbox" id="adminKBDirectAccessToggle"> Allow direct KB access (non-persona)</label>' +
'</div>' +
'<div class="settings-section"><h4>Environment Banner</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminBannerEnabled"> Show environment banner</label>' +
'<div id="bannerConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-group"><label>Preset</label><select id="adminBannerPreset"><option value="">Custom</option></select></div>' +
'<div class="form-group"><label>Text</label><input type="text" id="adminBannerText" placeholder="DEVELOPMENT"></div>' +
'<div class="form-group"><label>Position</label>' +
'<select id="adminBannerPosition"><option value="both">Top &amp; Bottom</option><option value="top">Top</option><option value="bottom">Bottom</option></select>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Background</label><input type="color" id="adminBannerBg" value="#007a33"><input type="text" id="adminBannerBgHex" value="#007a33" style="width:80px;margin-left:4px"></div>' +
'<div class="form-group"><label>Foreground</label><input type="color" id="adminBannerFg" value="#ffffff"><input type="text" id="adminBannerFgHex" value="#ffffff" style="width:80px;margin-left:4px"></div>' +
'</div>' +
'<div id="bannerPreview" class="banner-preview" style="margin-top:8px;padding:4px 12px;text-align:center;font-size:12px;font-weight:600;border-radius:4px">PREVIEW</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Web Search</h4>' +
'<div class="form-group"><label>Provider</label>' +
'<select id="adminSearchProvider"><option value="duckduckgo">DuckDuckGo</option><option value="searxng">SearXNG</option></select>' +
'</div>' +
'<div id="searxngConfigFields" style="display:none">' +
'<div class="form-group"><label>Endpoint</label><input type="text" id="adminSearchEndpoint" placeholder="https://searxng.example.com"></div>' +
'<div class="form-group"><label>API Key</label><input type="text" id="adminSearchApiKey" placeholder="Optional"></div>' +
'</div>' +
'<div class="form-group"><label>Max Results</label><input type="number" id="adminSearchMaxResults" value="5" min="1" max="20"></div>' +
'</div>' +
'<div class="settings-section"><h4>Auto-Compaction</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminCompactionEnabled"> Enable auto-compaction</label>' +
'<div id="compactionConfigFields" style="display:none;margin-top:8px">' +
'<div class="form-row">' +
'<div class="form-group"><label>Threshold (%)</label><input type="number" id="adminCompactionThreshold" value="70" min="50" max="95"></div>' +
'<div class="form-group"><label>Cooldown (min)</label><input type="number" id="adminCompactionCooldown" value="30" min="5" max="1440"></div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Memory Extraction</h4>' +
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryExtractionEnabled"> Enable memory extraction</label>' +
'<div id="memoryExtractionConfigFields" style="display:none;margin-top:8px">' +
'<label class="checkbox-label"><input type="checkbox" id="adminMemoryAutoApprove"> Auto-approve extracted memories</label>' +
'</div>' +
'</div>' +
'<div class="settings-section"><h4>Encryption Vault</h4>' +
'<div id="adminVaultStatus"><span class="text-muted">Loading...</span></div>' +
'</div>' +
'<div id="roleFallbackBanner"></div>' +
'<button class="btn-md btn-primary" id="adminSaveSettings" style="margin-top:16px">Save Settings</button>' +
'</div>';
SCAFFOLDING.storage = '<div id="adminStorageContent"></div>';
SCAFFOLDING.extensions =
'<div id="adminExtensionsList" class="admin-list"></div>' +
'<div id="adminInstallExtForm" class="admin-inline-form" style="display:none">' +
'<h4>Install Extension</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>ID</label><input type="text" id="extInstallId" placeholder="my-extension"></div>' +
'<div class="form-group"><label>Name</label><input type="text" id="extInstallName" placeholder="My Extension"></div>' +
'<div class="form-group"><label>Version</label><input type="text" id="extInstallVersion" placeholder="1.0.0"></div>' +
'</div>' +
'<div class="form-row">' +
'<div class="form-group"><label>Author</label><input type="text" id="extInstallAuthor" placeholder="Author"></div>' +
'<div class="form-group"><label>System</label>' +
'<select id="extInstallSystem"><option value="browser">Browser JS</option><option value="starlark">Starlark</option><option value="sidecar">Sidecar</option></select>' +
'</div>' +
'</div>' +
'<div class="form-group"><label>Description</label><textarea id="extInstallDesc" rows="2" placeholder="What it does..."></textarea></div>' +
'<div class="form-group"><label>Manifest JSON</label><textarea id="extInstallManifest" rows="4"></textarea></div>' +
'<div class="form-group"><label>Script Source</label><textarea id="extInstallScript" rows="6"></textarea></div>' +
'<label class="checkbox-label"><input type="checkbox" id="extInstallEnabled" checked> Enabled</label>' +
'<div class="form-row" style="margin-top:8px">' +
'<button class="btn-small btn-primary" id="adminInstallExtSubmit">Install</button>' +
'<button class="btn-small" id="adminInstallExtCancelBtn">Cancel</button>' +
'</div>' +
'</div>';
// -- Routing ----------------------------------------------------------
SCAFFOLDING.health =
'<div style="margin-bottom:12px"><button class="btn-small" id="adminHealthRefreshBtn">Refresh</button></div>' +
'<div id="adminHealthContent"><div class="loading">Loading...</div></div>';
SCAFFOLDING.routing =
'<div style="margin-bottom:12px"><button class="btn-small btn-primary" id="adminAddRoutingPolicyBtn">+ New Policy</button></div>' +
'<div id="adminRoutingPolicyList" class="admin-list"></div>' +
'<div id="adminRoutingForm" class="admin-inline-form" style="display:none"></div>' +
'<div class="settings-section" style="margin-top:24px"><h4>Test Routing</h4>' +
'<div class="form-row">' +
'<div class="form-group"><label>Model</label><input type="text" id="routingTestModel" placeholder="gpt-4o"></div>' +
'<div class="form-group"><label>User ID</label><input type="text" id="routingTestUser" placeholder="optional"></div>' +
'<button class="btn-small btn-primary" id="routingTestBtn" style="align-self:flex-end">Test</button>' +
'</div>' +
'<pre id="routingTestResult" class="code-block" style="display:none;margin-top:8px;font-size:12px;max-height:200px;overflow:auto"></pre>' +
'</div>';
SCAFFOLDING.capabilities =
'<div id="adminCapabilityList" class="admin-list"><div class="loading">Loading...</div></div>';
// -- Monitoring -------------------------------------------------------
SCAFFOLDING.usage =
'<div id="adminUsageTotals"></div>' +
'<div id="adminUsageResults" style="margin-top:12px"></div>' +
'<div id="adminPricingTable" style="margin-top:24px"></div>';
SCAFFOLDING.audit =
'<div class="form-row" style="margin-bottom:12px">' +
'<div class="form-group"><label>Action</label><select id="auditFilterAction"><option value="">All</option></select></div>' +
'<div class="form-group"><label>Resource</label><select id="auditFilterResource"><option value="">All</option></select></div>' +
'</div>' +
'<div id="adminAuditList" class="admin-list"><div class="loading">Loading...</div></div>' +
'<div id="auditPagination" style="margin-top:12px;display:flex;align-items:center;gap:8px">' +
'<button class="btn-small" id="auditPrevBtn">&larr; Prev</button>' +
'<span id="auditPageInfo" class="text-muted" style="font-size:12px"></span>' +
'<button class="btn-small" id="auditNextBtn">Next &rarr;</button>' +
'</div>';
SCAFFOLDING.stats =
'<div id="adminStats"><div class="loading">Loading...</div></div>';
// === Add button actions ==============================================
var ADD_ACTIONS = {
users: function() {
var f = document.getElementById('adminAddUserForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
teams: function() {
var f = document.getElementById('adminAddTeamForm');
if (f) f.style.display = '';
var n = document.getElementById('adminTeamName');
if (n) n.focus();
},
providers: function() {
var f = document.getElementById('adminAddProviderForm');
if (typeof UI !== 'undefined' && UI._adminProvForm) UI._adminProvForm.setCreateMode();
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
personas: function() {
if (typeof ensureAdminPersonaForm === 'function') {
var form = ensureAdminPersonaForm();
if (form) { form.setSubmitLabel('Create'); form.clearForm(); }
}
var f = document.getElementById('adminAddPersonaForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
groups: function() {
var f = document.getElementById('adminAddGroupForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
extensions: function() {
var f = document.getElementById('adminInstallExtForm');
if (f) f.style.display = f.style.display === 'none' ? '' : 'none';
},
};
// === Init on DOMContentLoaded ========================================
document.addEventListener('DOMContentLoaded', function() {
var el = document.getElementById('adminContentBody');
if (!el) return;
var section = el.dataset.section;
if (!section) return;
// -- Category / nav resolution -----------
var catMap = {
users:'people', teams:'people', groups:'people',
providers:'ai', models:'ai', personas:'ai', roles:'ai', knowledgeBases:'ai', memory:'ai',
health:'routing', routing:'routing', capabilities:'routing',
settings:'system', storage:'system', extensions:'system',
usage:'monitoring', audit:'monitoring', stats:'monitoring',
};
var cat = catMap[section] || 'people';
var secMap = {
people: [{id:'users',l:'Users'},{id:'teams',l:'Teams'},{id:'groups',l:'Groups'}],
ai: [{id:'providers',l:'Providers'},{id:'models',l:'Models'},{id:'personas',l:'Personas'},{id:'roles',l:'Roles'},{id:'knowledgeBases',l:'Knowledge'},{id:'memory',l:'Memory'}],
routing: [{id:'health',l:'Health'},{id:'routing',l:'Routing'},{id:'capabilities',l:'Capabilities'}],
system: [{id:'settings',l:'Settings'},{id:'storage',l:'Storage'},{id:'extensions',l:'Extensions'}],
monitoring: [{id:'usage',l:'Usage'},{id:'audit',l:'Audit'},{id:'stats',l:'Stats'}],
};
// Rebuild left nav for current category
var navEl = document.getElementById('adminNav');
var base = window.__BASE__ || '';
if (navEl && secMap[cat]) {
navEl.innerHTML = secMap[cat].map(function(s) {
return '<a href="' + base + '/admin/' + s.id + '" class="admin-nav-link' + (s.id === section ? ' active' : '') + '">' + s.l + '</a>';
}).join('');
}
// Update section title
var titleEl = document.getElementById('adminSectionTitle');
var curSec = secMap[cat] && secMap[cat].find(function(s) { return s.id === section; });
if (titleEl && curSec) titleEl.textContent = curSec.l;
// -- Inject scaffolding ------------------
var target = document.getElementById('adminDynamic');
if (target && SCAFFOLDING[section]) {
target.innerHTML = SCAFFOLDING[section];
} else if (target) {
target.innerHTML = '<div class="empty-hint">Section: ' + section + '</div>';
}
// -- Show/wire Add button for sections that have one --
var addBtn = document.getElementById('adminAddBtn');
if (addBtn && ADD_ACTIONS[section]) {
addBtn.style.display = '';
addBtn.addEventListener('click', ADD_ACTIONS[section]);
}
// -- Wire install extension cancel -------
var extCancel = document.getElementById('adminInstallExtCancelBtn');
if (extCancel) {
extCancel.addEventListener('click', function() {
document.getElementById('adminInstallExtForm').style.display = 'none';
});
}
// -- Wire admin-handlers.js listeners (uses ?. so missing elements are safe) --
if (typeof _initAdminListeners === 'function') _initAdminListeners();
// -- Call the section data loader --------
if (typeof ADMIN_LOADERS !== 'undefined' && ADMIN_LOADERS[section]) {
ADMIN_LOADERS[section]();
}
});
})();

View File

@@ -311,10 +311,10 @@ const API = {
return this._post(`/api/v1/channels/${channelId}/messages/${messageId}/edit`, { content });
},
async streamRegenerate(channelId, messageId, signal, model, presetId, apiConfigId, disabledTools) {
async streamRegenerate(channelId, messageId, signal, model, personaId, apiConfigId, disabledTools) {
const body = {};
if (model) body.model = model;
if (presetId) body.preset_id = presetId;
if (personaId) body.persona_id = personaId;
if (apiConfigId) body.provider_config_id = apiConfigId;
if (disabledTools?.length) body.disabled_tools = disabledTools;
@@ -370,11 +370,11 @@ const API = {
// ── Completions ──────────────────────────
async streamCompletion(channelId, content, model, signal, apiConfigId, presetId, attachmentIds, disabledTools) {
async streamCompletion(channelId, content, model, signal, apiConfigId, personaId, 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 (personaId) {
body.persona_id = personaId;
// Persona resolves the model on backend; still pass model for channel metadata
if (model) body.model = model;
} else {
if (model) body.model = model;
@@ -434,14 +434,14 @@ const API = {
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); },
getUserPresetKBs(presetId) { return this._get(`/api/v1/presets/${presetId}/knowledge-bases`); },
setUserPresetKBs(presetId, data) { return this._put(`/api/v1/presets/${presetId}/knowledge-bases`, data); },
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'); },
// User personas
listUserPersonas() { return this._get('/api/v1/personas'); },
createUserPersona(persona) { return this._post('/api/v1/personas', persona); },
getUserPersonaKBs(personaId) { return this._get(`/api/v1/personas/${personaId}/knowledge-bases`); },
setUserPersonaKBs(personaId, data) { return this._put(`/api/v1/personas/${personaId}/knowledge-bases`, data); },
updateUserPersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deleteUserPersona(id) { return this._del(`/api/v1/personas/${id}`); },
listAllModels() { return this._get('/api/v1/models/enabled'); },
// ── API Configs (user providers) ─────────
@@ -519,15 +519,15 @@ const API = {
},
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`); },
adminGetPresetKBs(presetId) { return this._get(`/api/v1/admin/presets/${presetId}/knowledge-bases`); },
adminSetPresetKBs(presetId, data) { return this._put(`/api/v1/admin/presets/${presetId}/knowledge-bases`, data); },
// ── Admin Personas ────────────────────────
adminListPersonas() { return this._get('/api/v1/admin/personas'); },
adminCreatePersona(persona) { return this._post('/api/v1/admin/personas', persona); },
adminUpdatePersona(id, updates) { return this._put(`/api/v1/admin/personas/${id}`, updates); },
adminDeletePersona(id) { return this._del(`/api/v1/admin/personas/${id}`); },
adminUploadPersonaAvatar(id, base64Image) { return this._post(`/api/v1/admin/personas/${id}/avatar`, { image: base64Image }); },
adminDeletePersonaAvatar(id) { return this._del(`/api/v1/admin/personas/${id}/avatar`); },
adminPersonaKBs(personaId) { return this._get(`/api/v1/admin/personas/${personaId}/knowledge-bases`); },
adminSetPersonaKBs(personaId, data) { return this._put(`/api/v1/admin/personas/${personaId}/knowledge-bases`, data); },
// ── Admin Teams ─────────────────────────
adminListTeams() { return this._get('/api/v1/admin/teams'); },
@@ -591,11 +591,11 @@ const API = {
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); },
teamGetPresetKBs(teamId, presetId) { return this._get(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`); },
teamSetPresetKBs(teamId, presetId, data) { return this._put(`/api/v1/teams/${teamId}/presets/${presetId}/knowledge-bases`, data); },
teamDeletePreset(teamId, presetId) { return this._del(`/api/v1/teams/${teamId}/presets/${presetId}`); },
teamListPersonas(teamId) { return this._get(`/api/v1/teams/${teamId}/personas`); },
teamCreatePersona(teamId, persona) { return this._post(`/api/v1/teams/${teamId}/personas`, persona); },
teamGetPersonaKBs(teamId, personaId) { return this._get(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`); },
teamSetPersonaKBs(teamId, personaId, data) { return this._put(`/api/v1/teams/${teamId}/personas/${personaId}/knowledge-bases`, data); },
teamDeletePersona(teamId, personaId) { return this._del(`/api/v1/teams/${teamId}/personas/${personaId}`); },
teamListAvailableModels(teamId) { return this._get(`/api/v1/teams/${teamId}/models`); },
teamListGroups(teamId) { return this._get(`/api/v1/teams/${teamId}/groups`); },
@@ -616,11 +616,11 @@ const API = {
},
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}`); },
// ── User Personas ─────────────────────────
listPersonas() { return this._get('/api/v1/personas'); },
createPersona(persona) { return this._post('/api/v1/personas', persona); },
updatePersona(id, updates) { return this._put(`/api/v1/personas/${id}`, updates); },
deletePersona(id) { return this._del(`/api/v1/personas/${id}`); },
// ── Notes ────────────────────────────────
listNotes(limit = 50, offset = 0, folder, tag, sort) {

101
src/js/app-state.js Normal file
View File

@@ -0,0 +1,101 @@
// ==========================================
// Chat Switchboard Shared Application State
// ==========================================
// Loaded by base.html for ALL surfaces. Contains the App state
// object and universal data-loading functions (models, settings).
// Surface-specific logic lives in app.js (chat), admin-scaffold.js, etc.
const App = {
chats: [],
currentChatId: null,
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
stagedAttachments: [],
channelAttachments: {},
storageConfigured: false,
projects: [],
collapsedProjects: {},
activeProjectId: null,
showArchivedProjects: false,
findModel(id) {
if (!id) return null;
let m = App.models.find(m => m.id === id);
if (m) return m;
return App.models.find(m => m.baseModelId === id) || null;
},
settings: {
model: '',
stream: true,
showThinking: false,
systemPrompt: '',
maxTokens: 0,
temperature: 0.7,
},
};
// ── Capability Resolution ──────────────────
// Backend is source of truth. Frontend passes through as-is.
function resolveCapabilities(backendCaps, modelId) {
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
}
// ── Model Loading ──────────────────────────
async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.defaultModel = data.default_model || '';
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
}
App.models = (data.models || []).map(m => {
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const id = isPersona
? (m.persona_id || m.id)
: ((m.config_id || m.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: m.config_id || m.provider_config_id || null,
model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPersona,
personaId: m.persona_id || null,
personaScope: m.persona_scope || (isPersona ? m.scope : null) || null,
personaAvatar: m.persona_avatar || (isPersona ? m.avatar : null) || null,
personaTeamName: m.persona_team_name || (isPersona ? m.team_name : null) || null,
source: m.source || (isPersona ? 'persona' : 'global'),
teamName: m.persona_team_name || null,
hidden: !isPersona && App.hiddenModels.has(baseModelId),
};
});
const scopeOrder = { global: 0, team: 1, personal: 2 };
App.models.sort((a, b) => {
if (a.isPersona && !b.isPersona) return -1;
if (!a.isPersona && b.isPersona) return 1;
if (a.isPersona && b.isPersona) {
const sa = scopeOrder[a.personaScope] ?? 9;
const sb = scopeOrder[b.personaScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPersona).length} personas, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
}

View File

@@ -1,119 +1,14 @@
// ==========================================
// Chat Switchboard Application
// Chat Switchboard Chat Surface Application
// ==========================================
// State, init, boot, auth, listener dispatch.
// Domain logic lives in chat.js, notes.js, settings-handlers.js,
// admin-handlers.js. UI rendering in ui-*.js files.
const App = {
chats: [],
currentChatId: null,
models: [],
hiddenModels: new Set(),
isGenerating: false,
abortController: null,
serverSettings: {},
policies: {},
stagedAttachments: [],
channelAttachments: {}, // messageId → [attachment, ...] — populated on chat load
storageConfigured: false,
projects: [], // v0.19.0: loaded from /api/v1/projects
collapsedProjects: {}, // projectId → bool — sidebar collapse state
activeProjectId: null, // v0.19.1: new chats auto-assign to this project
showArchivedProjects: false, // v0.19.2: toggle archived projects in sidebar
// 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,
showThinking: false,
systemPrompt: '',
maxTokens: 0, // 0 = auto from model capabilities
temperature: 0.7,
},
};
// ── Models ───────────────────────────────────
// ── Capability Resolution ───────────────────────────────────────
// The backend is the source of truth for model capabilities.
// Resolution chain on the server: catalog (provider API sync) → heuristic.
// The frontend does NOT maintain a static model table — the same model
// can have different capabilities on different providers (e.g. DeepSeek
// on Venice has no tool_calling, same model on OpenRouter does).
// resolveCapabilities returns backend caps as-is. No client-side override.
function resolveCapabilities(backendCaps, modelId) {
return backendCaps && Object.keys(backendCaps).length > 0 ? { ...backendCaps } : {};
}
async function fetchModels() {
try {
const data = await API.listEnabledModels();
App.defaultModel = data.default_model || '';
// Load user model preferences
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
);
} catch (e) {
// Keep existing preferences on failure (auth dead, network issue).
// Only init to empty if there's nothing to preserve.
if (!App.hiddenModels) App.hiddenModels = new Set();
}
App.models = (data.models || []).map(m => {
const isPreset = !!m.is_preset;
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.provider_config_id) ? `${m.config_id || m.provider_config_id}:${baseModelId}` : baseModelId);
return {
id,
baseModelId,
name: m.display_name || baseModelId,
provider: m.provider_name || m.provider || '',
configId: m.config_id || m.provider_config_id || null,
model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPreset,
presetId: m.preset_id || m.persona_id || null,
presetScope: m.preset_scope || (isPreset ? m.scope : null) || null,
presetAvatar: m.preset_avatar || (isPreset ? m.avatar : null) || null,
presetTeamName: m.preset_team_name || (isPreset ? m.team_name : null) || null,
source: m.source || (isPreset ? 'preset' : 'global'),
teamName: m.preset_team_name || null,
hidden: !isPreset && App.hiddenModels.has(baseModelId),
};
});
// Sort: presets first (global > team > personal), then regular models
const scopeOrder = { global: 0, team: 1, personal: 2 };
App.models.sort((a, b) => {
if (a.isPreset && !b.isPreset) return -1;
if (!a.isPreset && b.isPreset) return 1;
if (a.isPreset && b.isPreset) {
const sa = scopeOrder[a.presetScope] ?? 9;
const sb = scopeOrder[b.presetScope] ?? 9;
if (sa !== sb) return sa - sb;
}
return a.name.localeCompare(b.name);
});
console.log(`📋 Loaded ${App.models.length} models (${App.models.filter(m => m.isPreset).length} presets, ${App.models.filter(m => m.hidden).length} hidden)`);
} catch (e) { console.warn('Model fetch failed:', e.message); }
// Chat-specific init, boot, auth, listener dispatch.
// App state, fetchModels, resolveCapabilities defined in app-state.js.
// Domain logic in chat.js, notes.js, settings-handlers.js, admin-handlers.js.
// ── Chat Surface Model Load ────────────────
// Wraps shared fetchModels() with chat-specific UI updates.
async function fetchModelsAndUpdateUI() {
await fetchModels();
UI.updateModelSelector();
UI.updateCapabilityBadges();
}
@@ -185,7 +80,7 @@ async function startApp() {
await loadChats();
await loadProjects();
await loadProjectChannelPositions(); // v0.19.2: load channel order per project
await fetchModels();
await fetchModelsAndUpdateUI();
// Guard: if token was invalidated during startup (401 → refresh fail → clearTokens),
// kick back to login instead of rendering a broken UI.

View File

@@ -106,7 +106,7 @@ const ChannelModels = {
// Filter out models already in the roster
const rosterModelIds = new Set(this._roster.map(m => m.model_id));
const available = App.models.filter(m => !m.isPreset && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
const available = App.models.filter(m => !m.isPersona && !m.hidden && !rosterModelIds.has(m.baseModelId || m.id));
if (available.length === 0) {
UI.toast('All available models are already added to this channel', 'info');

162
src/js/chat-pane.js Normal file
View File

@@ -0,0 +1,162 @@
// ==========================================
// Chat Switchboard - ChatPane Component
// ==========================================
// v0.22.7: Mountable, self-contained chat pane instances.
// Replaces the hardcoded singleton pattern.
// ChatPane.primary is the backward-compat bridge.
const ChatPane = {
primary: null,
_instances: new Map(),
create(opts) {
const id = opts.id || 'pane-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6);
const instance = {
id,
messagesEl: opts.messagesEl,
inputEl: opts.inputEl,
sendBtnEl: opts.sendBtnEl || null,
modelSelEl: opts.modelSelEl || null,
toolbarEl: opts.toolbarEl || null,
channelId: opts.channelId || null,
standalone: opts.standalone || false,
_cmView: null,
_typing: null,
_listeners: [],
renderMessages(messages) {
if (!this.messagesEl) return;
if (typeof UI !== 'undefined' && UI._renderMessages) {
UI._renderMessages(messages, this);
} else {
this.messagesEl.innerHTML = '';
(messages || []).forEach(m => {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--' + m.role;
div.innerHTML = typeof formatMessage === 'function'
? formatMessage(m) : _cpEsc(m.content || '');
this.messagesEl.appendChild(div);
});
}
this.scrollToBottom();
},
appendChunk(html) {
if (!this.messagesEl) return;
const last = this.messagesEl.querySelector('.chat-msg--streaming');
if (last) {
const content = last.querySelector('.chat-msg__content');
if (content) content.innerHTML = html;
} else {
const div = document.createElement('div');
div.className = 'chat-msg chat-msg--assistant chat-msg--streaming';
div.innerHTML = '<div class="chat-msg__content">' + html + '</div>';
this.messagesEl.appendChild(div);
}
this.scrollToBottom();
},
finalizeStream() {
if (!this.messagesEl) return;
const el = this.messagesEl.querySelector('.chat-msg--streaming');
if (el) el.classList.remove('chat-msg--streaming');
},
appendTyping() {
if (!this.messagesEl || this._typing) return;
this._typing = document.createElement('div');
this._typing.className = 'chat-msg chat-msg--typing';
this._typing.innerHTML = '<div class="typing-dots"><span></span><span></span><span></span></div>';
this.messagesEl.appendChild(this._typing);
this.scrollToBottom();
},
removeTyping() {
if (this._typing) { this._typing.remove(); this._typing = null; }
},
scrollToBottom(smooth) {
if (!this.messagesEl) return;
const el = this.messagesEl;
if (smooth) el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
else el.scrollTop = el.scrollHeight;
},
isScrolledToBottom() {
if (!this.messagesEl) return true;
const el = this.messagesEl;
return (el.scrollHeight - el.scrollTop - el.clientHeight) < 60;
},
setChannel(channelId) { this.channelId = channelId; },
getChannel() { return this.channelId; },
showWelcome(html) {
if (!this.messagesEl) return;
this.messagesEl.innerHTML = html || '<div class="chat-welcome">' +
'<p class="chat-welcome__title">Start a conversation</p>' +
'<p class="chat-welcome__hint">Type a message below to get started.</p></div>';
},
clear() {
if (this.messagesEl) this.messagesEl.innerHTML = '';
this.removeTyping();
},
getInputValue() {
if (this._cmView) return this._cmView.state.doc.toString();
const ta = this.inputEl && this.inputEl.querySelector('textarea');
return ta ? ta.value : '';
},
setInputValue(val) {
if (this._cmView) {
this._cmView.dispatch({
changes: { from: 0, to: this._cmView.state.doc.length, insert: val }
});
return;
}
const ta = this.inputEl && this.inputEl.querySelector('textarea');
if (ta) ta.value = val;
},
clearInput() { this.setInputValue(''); },
focusInput() {
if (this._cmView) this._cmView.focus();
else { const ta = this.inputEl && this.inputEl.querySelector('textarea'); if (ta) ta.focus(); }
},
on(el, event, handler) {
if (!el) return;
el.addEventListener(event, handler);
this._listeners.push({ el, event, handler });
},
destroy() {
this._listeners.forEach(({ el, event, handler }) => el.removeEventListener(event, handler));
this._listeners = [];
if (this._cmView) { this._cmView.destroy(); this._cmView = null; }
this.removeTyping();
ChatPane._instances.delete(this.id);
if (ChatPane.primary === this) ChatPane.primary = null;
},
};
ChatPane._instances.set(id, instance);
return instance;
},
get(id) { return this._instances.get(id) || null; },
forChannel(channelId) {
for (const [, inst] of this._instances) {
if (inst.channelId === channelId) return inst;
}
return null;
},
active() { return this.primary; },
};
function _cpEsc(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }

View File

@@ -77,7 +77,7 @@ const ChatInput = {
// Hide the textarea, create CM6 editor in its place
this._textarea.style.display = 'none';
this._editor = CM.chatInput(wrap, {
placeholder: 'Send a message...',
placeholder: 'Type a message\u2026',
onSubmit: () => sendMessage(),
onChange: () => {
updateInputTokens();
@@ -240,7 +240,7 @@ async function selectChat(chatId) {
}
}
// Restore the last-used model/preset for this chat
// Restore the last-used model/persona for this chat
_restoreChatModel(chatId, chat);
// Load multi-model roster for @mention routing (v0.20.0)
@@ -286,7 +286,7 @@ function updateChatTokenCount() {
}
}
// ── Per-Chat Model/Preset Persistence ────────
// ── Per-Chat Model/Persona Persistence ────────
// Server-side: saved in channel.settings.last_selector_id (JSONB merge).
// localStorage used as write-through cache for instant restore without
// waiting for the channel list API call.
@@ -345,7 +345,7 @@ function _restoreChatModel(chatId, chat) {
// 4. Fall back to channel's base model ID (match by baseModelId)
if (chat?.model) {
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPreset && !m.hidden);
const match = App.models.find(m => m.baseModelId === chat.model && !m.isPersona && !m.hidden);
if (match) {
UI.setModelValue(match.id, match.name + (match.provider ? ` (${match.provider})` : ''));
return;
@@ -513,9 +513,9 @@ async function sendMessage() {
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
// For presets, send the base model ID; for regular models, send the model ID
// For personas, send the base model ID; for regular models, send the model ID
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
if (!App.currentChatId) {
try {
@@ -554,7 +554,7 @@ async function sendMessage() {
UI.setGenerating(true);
try {
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, presetId, attachmentIds,
const resp = await API.streamCompletion(App.currentChatId, text, model, App.abortController.signal, modelInfo?.configId, personaId, attachmentIds,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []);
await UI.streamResponse(resp, chat.messages, modelInfo?.name);
@@ -562,7 +562,7 @@ async function sendMessage() {
await reloadActivePath();
UI.showRegenerate(true);
// Persist the model/preset selection for this chat
// Persist the model/persona selection for this chat
_saveChatModel(App.currentChatId);
// Auto-name: title the chat after first assistant response (v0.17.0)
@@ -636,7 +636,7 @@ async function regenerateMessage(messageId) {
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
// Truncate display to the parent of the message being regenerated.
// This makes the stream appear in the correct position — as a fresh
@@ -657,7 +657,7 @@ async function regenerateMessage(messageId) {
try {
const resp = await API.streamRegenerate(
App.currentChatId, messageId, App.abortController.signal,
model, presetId, modelInfo?.configId,
model, personaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
@@ -721,7 +721,7 @@ async function submitEdit(messageId, newContent) {
const selectedId = UI.getModelValue();
const modelInfo = App.findModel(selectedId);
const model = modelInfo?.baseModelId || selectedId;
const presetId = modelInfo?.isPreset ? modelInfo.presetId : null;
const personaId = modelInfo?.isPersona ? modelInfo.personaId : null;
App.isGenerating = true;
App.abortController = new AbortController();
@@ -742,7 +742,7 @@ async function submitEdit(messageId, newContent) {
// message that streamCompletion would create.
const resp = await API.streamRegenerate(
App.currentChatId, editResp.id, App.abortController.signal,
model, presetId, modelInfo?.configId,
model, personaId, modelInfo?.configId,
typeof ToolsToggle !== 'undefined' ? ToolsToggle.getDisabledTools() : []
);
await UI.streamResponse(resp, chat?.messages || [], modelInfo?.name);
@@ -875,7 +875,7 @@ function _initChatListeners() {
localStorage.setItem('sb_sidebar', '1');
});
document.getElementById('fetchModelsBtn')?.addEventListener('click', async () => {
await fetchModels();
await fetchModelsAndUpdateUI();
const visible = App.models.filter(m => !m.hidden).length;
UI.toast(`Loaded ${visible} model${visible !== 1 ? 's' : ''}`, 'success');
});

View File

@@ -309,7 +309,7 @@ const MemoryUI = {
// Persona Form Memory Fields
// ═════════════════════════════════════════
// Call this after renderPresetForm to append memory fields.
// Call this after renderPersonaForm to append memory fields.
// container: the form container element
// prefix: the form prefix (e.g. 'pf', 'apf')
appendMemoryFields(container, prefix) {

View File

@@ -744,15 +744,157 @@ function _showSaveToNoteModal(opts) {
// ── Notes Listeners (extracted from initListeners) ──
function _registerNotesPanel() {
const el = document.getElementById('sidePanelNotes');
if (!el) return;
const body = document.getElementById('sidePanelBody');
if (!body || typeof PanelRegistry === 'undefined') return;
// ── Create the panel element dynamically ──
const el = document.createElement('div');
el.className = 'side-panel-page';
el.id = 'sidePanelNotes';
el.style.display = 'none';
el.innerHTML = `
<!-- List view -->
<div id="notesListView">
<div class="notes-toolbar">
<button id="notesNewBtn" class="btn-small" title="New note">+ New</button>
<button id="notesTodayBtn" class="btn-small" title="Open daily note">📅 Today</button>
<button id="notesGraphBtn" class="btn-small" title="Note graph">🕸 Graph</button>
<button id="notesSelectModeBtn" class="btn-small" title="Select mode">☑ Select</button>
</div>
<div class="notes-search-row">
<input type="text" id="notesSearchInput" class="notes-search-input" placeholder="Search notes…" autocomplete="off">
</div>
<div class="notes-filter-row">
<select id="notesFolderFilter" class="notes-filter-select"><option value="">All folders</option></select>
<select id="notesSortSelect" class="notes-filter-select">
<option value="updated_desc">Last updated</option>
<option value="created_desc">Created</option>
<option value="alpha">Alphabetical</option>
</select>
</div>
<div id="notesList" class="notes-list"></div>
<div id="notesSelectionBar" class="notes-selection-bar" style="display:none;">
<label class="notes-select-all"><input type="checkbox" id="notesSelectAll"> All</label>
<span id="notesSelectedCount">0</span> selected
<button id="notesDeleteSelectedBtn" class="btn-small btn-danger">Delete</button>
<button id="notesCancelSelectBtn" class="btn-small">Cancel</button>
</div>
</div>
<!-- Editor view -->
<div id="notesEditorView" style="display:none;">
<div class="notes-editor-header">
<button id="notesBackBtn" class="btn-small" title="Back to list">← Back</button>
<div style="flex:1"></div>
<button id="noteEditBtn" class="btn-small" style="display:none;">Edit</button>
<button id="notePreviewBtn" class="btn-small" style="display:none;">Preview</button>
<button id="noteCancelEditBtn" class="btn-small" style="display:none;">Cancel</button>
<button id="noteDeleteBtn" class="btn-small btn-danger" style="display:none;">Delete</button>
<button id="noteSaveBtn" class="btn-small btn-primary">Save</button>
</div>
<!-- Edit mode -->
<div id="noteEditMode" class="notes-editor">
<input type="text" id="noteEditorTitle" class="notes-title-input" placeholder="Note title">
<div class="notes-meta-row">
<input type="text" id="noteEditorFolder" class="notes-folder-input" placeholder="Folder (e.g. work/project)">
<input type="text" id="noteEditorTags" class="notes-tags-input" placeholder="Tags (comma-separated)">
</div>
<div id="noteEditorContentContainer" class="notes-content-container">
<textarea id="noteEditorContent" rows="12" class="notes-content-input" placeholder="Write your note in markdown…"></textarea>
</div>
</div>
<!-- Read mode -->
<div id="noteReadMode" style="display:none;">
<div class="notes-read-view">
<h3 id="noteReadTitle" class="note-read-title"></h3>
<div id="noteReadMeta" class="note-read-meta"></div>
<div id="noteReadContent" class="note-read-content msg-content"></div>
<button id="noteDeleteBtn2" class="btn-small btn-danger" style="display:none;margin-top:8px;">Delete</button>
</div>
</div>
<!-- Backlinks -->
<div id="noteBacklinks" class="note-backlinks" style="display:none;">
<div class="note-backlinks-header" onclick="toggleBacklinks()">
Backlinks <span id="noteBacklinksCount" class="badge">0</span>
</div>
<div id="noteBacklinksList" class="note-backlinks-list"></div>
</div>
</div>
<!-- Graph view -->
<div id="notesGraphView" style="display:none;"></div>`;
body.appendChild(el);
// ── Wire listeners ──
// Sidebar trigger (lives outside this panel)
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
// Toolbar buttons
el.querySelector('#notesNewBtn').addEventListener('click', () => openNoteEditor(null));
el.querySelector('#notesGraphBtn').addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
el.querySelector('#notesTodayBtn').addEventListener('click', openDailyNote);
el.querySelector('#notesSelectModeBtn').addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
// Editor header buttons
el.querySelector('#notesBackBtn').addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
el.querySelector('#noteSaveBtn').addEventListener('click', saveNote);
el.querySelector('#noteDeleteBtn').addEventListener('click', deleteNote);
el.querySelector('#noteDeleteBtn2').addEventListener('click', deleteNote);
el.querySelector('#noteEditBtn').addEventListener('click', _showNoteEditMode);
el.querySelector('#notePreviewBtn').addEventListener('click', _showNotePreview);
el.querySelector('#noteCancelEditBtn').addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
// Filters
el.querySelector('#notesFolderFilter').addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
el.querySelector('#notesSortSelect').addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
// Multi-select controls
el.querySelector('#notesSelectAll').addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
el.querySelector('#notesDeleteSelectedBtn').addEventListener('click', _bulkDeleteSelected);
el.querySelector('#notesCancelSelectBtn').addEventListener('click', _exitSelectMode);
// Search with debounce
let _notesSearchTimer;
el.querySelector('#notesSearchInput').addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// ── Register with PanelRegistry ──
PanelRegistry.register('notes', {
element: el,
label: 'Notes',
onOpen() {
// Loading is handled by openNotes() which calls loadNotesList
},
onClose() {
_destroyNoteEditor();
},
saveState() {
const listView = document.getElementById('notesListView');
const editorView = document.getElementById('notesEditorView');
@@ -775,56 +917,8 @@ function _registerNotesPanel() {
});
}
/** @deprecated — listeners are now wired in _registerNotesPanel() */
function _initNotesListeners() {
document.getElementById('notesBtn')?.addEventListener('click', openNotes);
document.getElementById('notesNewBtn')?.addEventListener('click', () => openNoteEditor(null));
document.getElementById('notesGraphBtn')?.addEventListener('click', () => {
if (typeof openNoteGraph === 'function') openNoteGraph();
});
document.getElementById('notesTodayBtn')?.addEventListener('click', openDailyNote);
document.getElementById('notesSelectModeBtn')?.addEventListener('click', () => {
if (_notesSelectMode) _exitSelectMode();
else _enterSelectMode();
});
document.getElementById('notesBackBtn')?.addEventListener('click', () => { showNotesList(); loadNotesList(); loadNoteFolders(); });
document.getElementById('noteSaveBtn')?.addEventListener('click', saveNote);
document.getElementById('noteDeleteBtn')?.addEventListener('click', deleteNote);
document.getElementById('noteDeleteBtn2')?.addEventListener('click', deleteNote);
document.getElementById('noteEditBtn')?.addEventListener('click', _showNoteEditMode);
document.getElementById('notePreviewBtn')?.addEventListener('click', _showNotePreview);
document.getElementById('noteCancelEditBtn')?.addEventListener('click', () => {
if (_currentNote) { _populateEditFields(_currentNote); _showNoteReadMode(); }
else showNotesList();
});
document.getElementById('notesFolderFilter')?.addEventListener('change', (e) => {
document.getElementById('notesSearchInput').value = '';
_exitSelectMode();
loadNotesList(e.target.value);
});
document.getElementById('notesSortSelect')?.addEventListener('change', (e) => {
_notesSort = e.target.value;
_exitSelectMode();
loadNotesList();
});
document.getElementById('notesSelectAll')?.addEventListener('change', (e) => {
_toggleSelectAll(e.target.checked);
});
document.getElementById('notesDeleteSelectedBtn')?.addEventListener('click', _bulkDeleteSelected);
document.getElementById('notesCancelSelectBtn')?.addEventListener('click', _exitSelectMode);
// Notes search with debounce
let _notesSearchTimer;
document.getElementById('notesSearchInput')?.addEventListener('input', (e) => {
clearTimeout(_notesSearchTimer);
const q = e.target.value.trim();
_notesSearchTimer = setTimeout(() => {
_exitSelectMode();
if (q.length >= 2) {
document.getElementById('notesFolderFilter').value = '';
loadNotesList(null, q);
} else if (q.length === 0) {
loadNotesList();
}
}, 300);
});
// No-op: kept for backward compatibility with app.js calling sequence.
// All wiring moved into _registerNotesPanel().
}

189
src/js/pages-splash.js Normal file
View File

@@ -0,0 +1,189 @@
// ==========================================
// Chat Switchboard - Splash Surface (pages-splash.js)
// ==========================================
// v0.22.7: Splash init, login, register, animated grid.
(function() {
'use strict';
var base = window.__BASE__ || '';
Pages.initSplash = function() {
_initAuthTabs();
_initLoginForm();
_initRegisterForm();
_initGridCanvas();
if (typeof Theme !== 'undefined') Theme.init();
};
// -- Save tokens via API singleton (sets sb_token cookie) --
function _saveAuth(data) {
if (typeof API !== 'undefined') {
API.accessToken = data.access_token || null;
API.refreshToken = data.refresh_token || null;
API.user = data.user || null;
API.saveTokens();
} else {
// Fallback: at minimum set the cookie so page auth works
if (data.access_token) {
localStorage.setItem('token', data.access_token);
document.cookie = 'sb_token=' + data.access_token + '; path=/; max-age=900; SameSite=Strict';
}
}
}
// -- Redirect after auth --
function _redirectAfterAuth() {
// Check for saved redirect (e.g. deep link that bounced to /login)
var cookies = document.cookie.split(';');
for (var i = 0; i < cookies.length; i++) {
var c = cookies[i].trim();
if (c.startsWith('redirect_after_login=')) {
var dest = decodeURIComponent(c.substring('redirect_after_login='.length));
document.cookie = 'redirect_after_login=; path=/; max-age=0';
if (dest && dest !== '/login' && dest !== base + '/login') {
window.location.href = dest;
return;
}
}
}
window.location.href = base + '/';
}
function _initAuthTabs() {
var tabs = document.querySelectorAll('.splash-tab');
var loginForm = document.getElementById('authLogin');
var regForm = document.getElementById('authRegister');
if (!tabs.length) return;
tabs.forEach(function(tab) {
tab.addEventListener('click', function() {
tabs.forEach(function(t) { t.classList.remove('active'); });
tab.classList.add('active');
var target = tab.dataset.tab;
if (loginForm) loginForm.style.display = target === 'login' ? '' : 'none';
if (regForm) regForm.style.display = target === 'register' ? '' : 'none';
});
});
}
function _initLoginForm() {
var btn = document.getElementById('loginBtn');
if (!btn) return;
var form = document.getElementById('authLogin');
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
btn.addEventListener('click', async function() {
var username = _val('loginUsername');
var password = _val('loginPassword');
var errEl = document.getElementById('loginError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
btn.disabled = true;
btn.textContent = 'Logging in\u2026';
try {
var resp = await fetch(base + '/api/v1/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ login: username, password: password }),
});
if (!resp.ok) {
var errData = await resp.json().catch(function() { return {}; });
throw new Error(errData.error || 'Invalid credentials');
}
var data = await resp.json();
_saveAuth(data);
_redirectAfterAuth();
} catch (e) {
_showErr(errEl, e.message || 'Login failed');
btn.disabled = false;
btn.textContent = 'Log In';
}
});
}
function _initRegisterForm() {
var btn = document.getElementById('regBtn');
if (!btn) return;
var avatarEl = document.getElementById('avatarUpload');
var avatarFile = null;
if (avatarEl && typeof mountAvatarUpload === 'function') {
mountAvatarUpload(avatarEl, { onUpload: function(file) { avatarFile = file; } });
}
var form = document.getElementById('authRegister');
if (form) form.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); btn.click(); } });
btn.addEventListener('click', async function() {
var username = _val('regUsername');
var displayName = _val('regDisplayName');
var email = _val('regEmail');
var password = _val('regPassword');
var confirm = _val('regConfirm');
var errEl = document.getElementById('regError');
if (errEl) { errEl.style.display = 'none'; errEl.textContent = ''; }
if (!username || !password) { _showErr(errEl, 'Username and password are required.'); return; }
if (password !== confirm) { _showErr(errEl, 'Passwords do not match.'); return; }
btn.disabled = true;
btn.textContent = 'Creating account\u2026';
try {
var body = { username: username, password: password, display_name: displayName, email: email };
var resp = await fetch(base + '/api/v1/auth/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!resp.ok) {
var errData = await resp.json().catch(function() { return {}; });
throw new Error(errData.error || 'Registration failed');
}
var data = await resp.json();
// Upload avatar before redirect (token needed for auth)
if (avatarFile && data.access_token) {
try {
var fd = new FormData();
fd.append('avatar', avatarFile);
await fetch(base + '/api/v1/users/me/avatar', {
method: 'PUT',
headers: { 'Authorization': 'Bearer ' + data.access_token },
body: fd,
});
} catch (e) { console.warn('Avatar upload failed:', e); }
}
_saveAuth(data);
_redirectAfterAuth();
} catch (e) {
_showErr(errEl, e.message || 'Registration failed');
btn.disabled = false;
btn.textContent = 'Create Account';
}
});
}
function _initGridCanvas() {
var canvas = document.getElementById('gridCanvas');
if (!canvas) return;
var ctx = canvas.getContext('2d');
var w, h, offset = 0;
function resize() {
var rect = canvas.parentElement.getBoundingClientRect();
w = canvas.width = rect.width;
h = canvas.height = rect.height;
}
function draw() {
ctx.clearRect(0, 0, w, h);
var gap = 40;
ctx.strokeStyle = getComputedStyle(document.documentElement).getPropertyValue('--border').trim() || '#2e2e35';
ctx.lineWidth = 0.5;
for (var x = (offset % gap); x < w; x += gap) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
for (var y = (offset % gap); y < h; y += gap) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
offset += 0.15;
requestAnimationFrame(draw);
}
window.addEventListener('resize', resize);
resize();
draw();
}
function _val(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; }
function _showErr(el, msg) { if (!el) return; el.textContent = msg; el.style.display = ''; }
})();

View File

@@ -76,11 +76,20 @@ const PanelRegistry = {
return;
}
// Hide current active
// Hide current active (saves state + fires onClose)
if (this._active && this._active !== name) {
this._hide(this._active);
}
// Hide ALL panel pages in the body, including unregistered ones
// (e.g. sidePanelPreview which is a static placeholder)
const body = this._body();
if (body) {
body.querySelectorAll('.side-panel-page').forEach(el => {
if (el !== panel.element) el.style.display = 'none';
});
}
// Show the secondary pane
container.classList.add('open');
this._showHandle(true);

View File

@@ -658,7 +658,7 @@ async function _renderPersonaPicker(currentPersonaId) {
if (!select) return;
select.innerHTML = '<option value="">None (use model selector)</option>';
try {
const resp = await API.listPresets();
const resp = await API.listPersonas();
const personas = resp.data || resp || [];
personas.forEach(p => {
const opt = document.createElement('option');

View File

@@ -200,9 +200,9 @@ async function handleSaveAdminSettings() {
const userProviders = document.getElementById('adminUserProvidersToggle').checked;
await API.adminUpdateSetting('allow_user_byok', { value: userProviders ? 'true' : 'false' });
// User presets → allow_user_personas policy
const userPresets = document.getElementById('adminUserPresetsToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPresets ? 'true' : 'false' });
// User personas → allow_user_personas policy
const userPersonas = document.getElementById('adminUserPersonasToggle').checked;
await API.adminUpdateSetting('allow_user_personas', { value: userPersonas ? 'true' : 'false' });
// Default model → default_model policy
const defaultModel = document.getElementById('adminDefaultModel').value;
@@ -266,7 +266,7 @@ async function handleSaveAdminSettings() {
// Live-apply: refresh policies and dependent UI
await initBanners();
UI.checkUserProvidersAllowed();
UI.checkUserPresetsAllowed();
UI.checkUserPersonasAllowed();
fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
}
@@ -544,13 +544,13 @@ function _initSettingsListeners() {
document.getElementById('settingsTeamCancelMember')?.addEventListener('click', () => {
document.getElementById('settingsTeamAddMember').style.display = 'none';
});
var _teamPresetForm = null;
document.getElementById('settingsTeamAddPresetBtn')?.addEventListener('click', () => {
const container = document.getElementById('settingsTeamAddPreset');
var _teamPersonaForm = null;
document.getElementById('settingsTeamAddPersonaBtn')?.addEventListener('click', () => {
const container = document.getElementById('settingsTeamAddPersona');
container.style.display = '';
if (!_teamPresetForm) {
_teamPresetForm = renderPresetForm(container, {
prefix: 'teamPreset',
if (!_teamPersonaForm) {
_teamPersonaForm = renderPersonaForm(container, {
prefix: 'teamPersona',
showAvatar: true,
showProviderConfig: false,
showKBPicker: true,
@@ -561,29 +561,29 @@ function _initSettingsListeners() {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.teamCreatePreset(teamId, vals);
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('Team preset avatar upload failed:', e.message); }
const result = await API.teamCreatePersona(teamId, vals);
const personaId = result?.id;
if (personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('Team persona avatar upload failed:', e.message); }
}
if (presetId && vals._kbValues) {
try { await API.teamSetPresetKBs(teamId, presetId, vals._kbValues); }
catch (e) { console.warn('Team preset KB binding failed:', e.message); }
if (personaId && vals._kbValues) {
try { await API.teamSetPresetKBs(teamId, personaId, vals._kbValues); }
catch (e) { console.warn('Team persona KB binding failed:', e.message); }
}
container.style.display = 'none';
_teamPresetForm.clearForm();
UI.toast('Team preset created');
await UI.loadTeamManagePresets(teamId);
_teamPersonaForm.clearForm();
UI.toast('Team persona created');
await UI.loadTeamManagePersonas(teamId);
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _teamPresetForm.clearForm(); }
onCancel: () => { container.style.display = 'none'; _teamPersonaForm.clearForm(); }
});
} else {
_teamPresetForm.clearForm();
_teamPersonaForm.clearForm();
}
UI.loadTeamPresetModelDropdown(UI._managingTeamId);
UI.loadTeamPersonaModelDropdown(UI._managingTeamId);
});
// Team — providers (primitive-based, initialized lazily via UI.loadTeamManageProviders)
@@ -595,13 +595,13 @@ function _initSettingsListeners() {
});
// User — personal presets
var _userPresetForm = null;
document.getElementById('userAddPresetBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPresetForm');
var _userPersonaForm = null;
document.getElementById('userAddPersonaBtn')?.addEventListener('click', () => {
const container = document.getElementById('userAddPersonaForm');
container.style.display = container.style.display === 'none' ? '' : 'none';
if (!_userPresetForm) {
_userPresetForm = renderPresetForm(container, {
prefix: 'userPreset',
if (!_userPersonaForm) {
_userPersonaForm = renderPersonaForm(container, {
prefix: 'userPersona',
showAvatar: true,
showProviderConfig: false,
showKBPicker: true,
@@ -610,30 +610,30 @@ function _initSettingsListeners() {
if (!vals.name) return UI.toast('Name required', 'error');
if (!vals.base_model_id) return UI.toast('Select a base model', 'error');
try {
const result = await API.createUserPreset(vals);
const presetId = result?.id;
if (presetId && vals._pendingAvatar) {
try { await API.adminUploadPresetAvatar(presetId, vals._pendingAvatar); }
catch (e) { console.warn('User preset avatar upload failed:', e.message); }
const result = await API.createUserPersona(vals);
const personaId = result?.id;
if (personaId && vals._pendingAvatar) {
try { await API.adminUploadPersonaAvatar(personaId, vals._pendingAvatar); }
catch (e) { console.warn('User persona avatar upload failed:', e.message); }
}
if (presetId && vals._kbValues) {
try { await API.setUserPresetKBs(presetId, vals._kbValues); }
catch (e) { console.warn('User preset KB binding failed:', e.message); }
if (personaId && vals._kbValues) {
try { await API.setUserPresetKBs(personaId, vals._kbValues); }
catch (e) { console.warn('User persona KB binding failed:', e.message); }
}
container.style.display = 'none';
_userPresetForm.clearForm();
UI.toast('Preset created');
await UI.loadUserPresets();
_userPersonaForm.clearForm();
UI.toast('Persona created');
await UI.loadUserPersonas();
await fetchModels();
} catch (e) { UI.toast(e.message, 'error'); }
},
onCancel: () => { container.style.display = 'none'; _userPresetForm.clearForm(); }
onCancel: () => { container.style.display = 'none'; _userPersonaForm.clearForm(); }
});
}
const sel = _userPresetForm.getModelSelect();
const sel = _userPersonaForm.getModelSelect();
if (sel) {
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPreset).forEach(m => {
App.models.filter(m => !m.isPersona).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
@@ -648,7 +648,7 @@ function _initSettingsListeners() {
});
document.getElementById('adminBannerPreset')?.addEventListener('change', (e) => {
const preset = UI._bannerPresets[e.target.value];
if (preset) {
if (persona) {
document.getElementById('adminBannerText').value = preset.text || '';
document.getElementById('adminBannerBg').value = preset.bg || '#007a33';
document.getElementById('adminBannerBgHex').value = preset.bg || '#007a33';

43
src/js/surface-nav.js Normal file
View File

@@ -0,0 +1,43 @@
/**
* surface-nav.js — Navigation overrides for server-rendered surfaces.
*
* Replaces SPA modal-based openSettings/openAdmin with page navigation
* to the dedicated /settings and /admin surface routes.
*
* Load AFTER ui-core.js and ui-admin.js so it overrides their methods.
*
* v0.22.8 — FE refactor: surfaces replace modals
*/
(function() {
'use strict';
const base = window.__BASE__ || '';
// ── Settings: navigate to /settings surface ──────────
if (typeof UI !== 'undefined') {
UI.openSettings = function(section) {
const sec = section || 'general';
window.location.href = base + '/settings/' + sec;
};
UI.closeSettings = function() {
// Navigate back to chat if called from settings surface
window.location.href = base + '/';
};
}
// ── Admin: navigate to /admin surface ────────────────
if (typeof UI !== 'undefined') {
UI.openAdmin = function(cat, section) {
const sec = section || 'users';
window.location.href = base + '/admin/' + sec;
};
UI.closeAdmin = function() {
window.location.href = base + '/';
};
UI.openAdminSection = function(section) {
window.location.href = base + '/admin/' + (section || 'users');
};
}
console.log('[surface-nav] Navigation overrides active — settings/admin use server surfaces');
})();

View File

@@ -7,7 +7,7 @@
// ── Category → Section mapping ────────────
const ADMIN_SECTIONS = {
people: ['users', 'teams', 'groups'],
ai: ['providers', 'models', 'presets', 'roles', 'knowledgeBases', 'memory'],
ai: ['providers', 'models', 'personas', 'roles', 'knowledgeBases', 'memory'],
routing: ['health', 'routing', 'capabilities'],
system: ['settings', 'storage', 'extensions'],
monitoring: ['usage', 'audit', 'stats'],
@@ -29,7 +29,7 @@ const ADMIN_LOADERS = {
roles: () => UI.loadAdminRoles(),
providers: () => UI.loadAdminProviders(),
models: () => UI.loadAdminModels(),
presets: () => UI.loadAdminPresets(),
personas: () => UI.loadAdminPersonas(),
knowledgeBases: () => {
if (typeof KnowledgeUI === 'undefined') { console.error('[Admin] KnowledgeUI not loaded — check js/knowledge-ui.js'); return; }
KnowledgeUI.openAdminPanel();
@@ -185,7 +185,7 @@ Object.assign(UI, {
const teamId = UI._managingTeamId;
if (tab === 'members') UI.loadTeamManageMembers(teamId);
if (tab === 'providers') UI.loadTeamManageProviders(teamId);
if (tab === 'presets') UI.loadTeamManagePresets(teamId);
if (tab === 'presets') UI.loadTeamManagePersonas(teamId);
if (tab === 'usage') UI.loadTeamUsage();
if (tab === 'activity') UI.loadTeamAuditLog(1);
if (tab === 'knowledge') {
@@ -563,18 +563,18 @@ Object.assign(UI, {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadAdminPresets(quiet) {
const el = document.getElementById('adminPresetList');
async loadAdminPersonas(quiet) {
const el = document.getElementById('adminPersonaList');
if (!quiet) el.innerHTML = '<div class="loading">Loading...</div>';
try {
const data = await API.adminListPresets();
const list = data.presets || [];
const data = await API.adminListPersonas();
const list = data.personas || [];
// Ensure form is initialized for dropdown population
ensureAdminPresetForm();
ensureAdminPersonaForm();
// Populate model dropdown from admin model list (all synced models)
const modelSel = _adminPresetForm?.getModelSelect();
const modelSel = _adminPersonaForm?.getModelSelect();
if (modelSel) {
modelSel.innerHTML = '<option value="">Select base model...</option>';
try {
@@ -588,11 +588,11 @@ Object.assign(UI, {
opt.textContent = mid + (m.provider_name ? ` (${m.provider_name})` : '');
modelSel.appendChild(opt);
});
} catch (e) { console.warn('Failed to load models for preset form:', e.message); }
} catch (e) { console.warn('Failed to load models for persona form:', e.message); }
}
// Populate config dropdown
const cfgSel = _adminPresetForm?.getConfigSelect();
const cfgSel = _adminPersonaForm?.getConfigSelect();
if (cfgSel && cfgSel.options.length <= 1) {
try {
const cfgData = await API.adminListGlobalConfigs();
@@ -606,11 +606,11 @@ Object.assign(UI, {
} catch (e) { /* optional */ }
}
UI._presetCache = {};
UI._personaCache = {};
el.innerHTML = list.map(p => {
UI._presetCache[p.id] = p;
UI._personaCache[p.id] = p;
const avatarEl = p.avatar
? `<img src="${p.avatar}" class="preset-row-avatar" alt="">`
? `<img src="${p.avatar}" class="persona-row-avatar" alt="">`
: '';
// Scope badge with attribution
let scopeBadge = '';
@@ -623,20 +623,20 @@ Object.assign(UI, {
}
const creator = p.creator_name ? `<span class="text-muted" style="font-size:11px">by ${esc(p.creator_name)}</span>` : '';
const status = p.is_active ? '' : '<span class="badge-pending">inactive</span>';
return `<div class="admin-preset-row" data-grant-persona="${p.id}">
<div class="preset-info">
return `<div class="admin-persona-row" data-grant-persona="${p.id}">
<div class="persona-info">
<strong>${avatarEl}${esc(p.name)}</strong> ${scopeBadge} ${creator} ${status}
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="preset-desc">${esc(p.description)}</div>` : ''}
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
${p.description ? `<div class="persona-desc">${esc(p.description)}</div>` : ''}
</div>
<div class="preset-actions">
<div class="persona-actions">
<button class="btn-small" onclick="UI.openGrantPicker('persona', '${p.id}', '${esc(p.name)}', '[data-grant-persona=&quot;${p.id}&quot;]')" title="Manage access">🔒 Access</button>
<button class="btn-edit" onclick="editAdminPreset('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPreset('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
<button class="btn-edit" onclick="editAdminPersona('${p.id}')" title="Edit">✎</button>
<button class="admin-model-toggle ${p.is_active ? 'enabled' : ''}" onclick="toggleAdminPersona('${p.id}', ${!p.is_active})">${p.is_active ? '✓ Active' : 'Inactive'}</button>
<button class="btn-delete" onclick="deleteAdminPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('') || '<div class="empty-hint">No presets — create one to give users preconfigured model experiences</div>';
}).join('') || '<div class="empty-hint">No personas — create one to give users preconfigured model experiences</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -1185,8 +1185,8 @@ Object.assign(UI, {
// User providers / BYOK (policy: allow_user_byok)
document.getElementById('adminUserProvidersToggle').checked = policies.allow_user_byok === 'true';
// User presets / personas (policy: allow_user_personas)
document.getElementById('adminUserPresetsToggle').checked = policies.allow_user_personas === 'true';
// User personas / personas (policy: allow_user_personas)
document.getElementById('adminUserPersonasToggle').checked = policies.allow_user_personas === 'true';
// KB direct access (policy: kb_direct_access — v0.17.0)
const kbDirectEl = document.getElementById('adminKBDirectAccessToggle');
@@ -1196,7 +1196,21 @@ Object.assign(UI, {
const defModelSel = document.getElementById('adminDefaultModel');
if (defModelSel) {
defModelSel.innerHTML = '<option value="">— None (first visible) —</option>';
App.models.filter(m => !m.hidden).forEach(m => {
let modelList = typeof App !== 'undefined' && App.models ? App.models : [];
// On admin surface, App isn't loaded — fetch from admin API
if (modelList.length === 0 && typeof API !== 'undefined' && API.adminListModels) {
try {
const mr = await API.adminListModels();
modelList = (mr.models || mr.data || []).map(m => ({
id: m.model_id || m.id,
baseModelId: m.model_id || m.id,
name: m.display_name || m.model_id || m.id,
provider: m.provider_name || '',
hidden: !!m.hidden,
}));
} catch (e) { /* non-critical */ }
}
modelList.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
@@ -1218,7 +1232,7 @@ Object.assign(UI, {
UI.updateBannerPreview();
// Load banner presets (global_settings)
const presets = getSetting('banner_presets', {}) || {};
const personas = getSetting('banner_presets', {}) || {};
const sel = document.getElementById('adminBannerPreset');
sel.innerHTML = '<option value="">Custom</option>';
Object.entries(presets).forEach(([key, p]) => {

View File

@@ -15,22 +15,22 @@ function avatarHTML(role, avatarDataURI) {
return role === 'user' ? '👤' : '🤖';
}
// Look up the current model/preset avatar for assistant messages.
// Look up the current model/persona avatar for assistant messages.
function assistantAvatarURI(msg) {
// Try to find preset avatar from the model that generated this message
const modelId = msg?.model || msg?.preset_id;
// Try to find persona avatar from the model that generated this message
const modelId = msg?.model || msg?.persona_id;
if (modelId) {
const m = App.models.find(x => x.id === modelId || x.presetId === modelId);
if (m?.presetAvatar) return m.presetAvatar;
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
if (m?.personaAvatar) return m.personaAvatar;
}
return null;
}
// ── Shared Preset Form Component ────────────
// Renders a preset creation/edit form into a container element.
// ── Shared Persona Form Component ────────────
// Renders a persona creation/edit form into a container element.
// Options: { prefix, showAvatar, showProviderConfig, onSubmit, onCancel }
// Returns: { getValues(), setValues(preset), clearForm(), container }
function renderPresetForm(containerEl, options = {}) {
// Returns: { getValues(), setValues(persona), clearForm(), container }
function renderPersonaForm(containerEl, options = {}) {
const pfx = options.prefix || 'pf';
const showAvatar = options.showAvatar !== false;
const showConfig = !!options.showProviderConfig;
@@ -164,7 +164,7 @@ function renderPresetForm(containerEl, options = {}) {
if (existing) { existing.src = dataURI; }
else {
const img = document.createElement('img');
img.src = dataURI; img.alt = 'Preset avatar';
img.src = dataURI; img.alt = 'Persona avatar';
preview.insertBefore(img, placeholder);
}
if (removeBtn) removeBtn.style.display = '';
@@ -597,7 +597,7 @@ const UI = {
const label = modelName || 'Assistant';
const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.presetAvatar || null;
const streamAvatar = currentModel?.personaAvatar || null;
// Multi-model state: when model_start events arrive, we create
// separate message bubbles per model. Without them, single-bubble.
@@ -836,10 +836,10 @@ const UI = {
menu.innerHTML = '<div class="model-dropdown-item" style="color:var(--text-3);cursor:default">No models loaded</div>';
UI.setModelValue('', 'No models loaded');
} else {
const globalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'global');
const teamPresets = App.models.filter(m => m.isPreset && m.presetScope === 'team');
const personalPresets = App.models.filter(m => m.isPreset && m.presetScope === 'personal');
const models = App.models.filter(m => !m.isPreset && !m.hidden);
const globalPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'global');
const teamPersonas = App.models.filter(m => m.isPersona && m.personaScope === 'team');
const personalPresets = App.models.filter(m => m.isPersona && m.personaScope === 'personal');
const models = App.models.filter(m => !m.isPersona && !m.hidden);
const addGroup = (label, items) => {
if (items.length === 0) return;
@@ -850,22 +850,22 @@ const UI = {
items.forEach(m => menu.appendChild(UI._createDropdownItem(m)));
};
addGroup('⚡ Presets', globalPresets);
addGroup('⚡ Personas', globalPersonas);
// Group team presets by team name
// Group team personas by team name
const teamGroups = {};
teamPresets.forEach(m => {
const tn = m.presetTeamName || 'Team';
teamPersonas.forEach(m => {
const tn = m.personaTeamName || 'Team';
(teamGroups[tn] = teamGroups[tn] || []).push(m);
});
Object.keys(teamGroups).sort().forEach(tn => {
addGroup(`👥 ${tn}`, teamGroups[tn]);
});
addGroup('🔧 My Presets', personalPresets);
addGroup('🔧 My Personas', personalPresets);
// No team model groups — team providers are for preset building only.
// Team members access team models through curated presets.
// No team model groups — team providers are for persona building only.
// Team members access team models through curated personas.
addGroup('Models', models.filter(m => m.source !== 'personal'));
addGroup('🔑 My Providers', models.filter(m => m.source === 'personal'));
@@ -909,7 +909,7 @@ const UI = {
const div = document.createElement('div');
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.presetAvatar ? `<img src="${m.presetAvatar}" class="dropdown-avatar" alt="">` : '';
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
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', () => {
@@ -1027,7 +1027,7 @@ const UI = {
if (on) {
const container = document.getElementById('chatMessages');
const currentModel = App.findModel(App.settings.model);
const typingAvatar = currentModel?.presetAvatar || null;
const typingAvatar = currentModel?.personaAvatar || null;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
@@ -1084,7 +1084,7 @@ const UI = {
UI.loadProfileIntoSettings();
UI.loadMyTeams();
UI.checkUserProvidersAllowed();
UI.checkUserPresetsAllowed();
UI.checkUserPersonasAllowed();
UI.switchSettingsTab('general');
openModal('settingsModal');
},
@@ -1101,7 +1101,7 @@ const UI = {
UI.checkUserProvidersAllowed();
}
if (tab === 'models') UI.loadUserModels();
if (tab === 'personas') { UI.loadUserPresets(); UI.checkUserPresetsAllowed(); }
if (tab === 'personas') { UI.loadUserPersonas(); UI.checkUserPersonasAllowed(); }
if (tab === 'usage') UI.loadMyUsage();
if (tab === 'roles') UI.loadUserRoles();
if (tab === 'knowledgeBases') {

View File

@@ -0,0 +1,199 @@
// ==========================================
// Chat Switchboard - UI Primitives Additions
// ==========================================
// v0.22.7: Toast, Badge, IconBtn, Theme toggle, confirm dialog, avatar upload.
// Load AFTER ui-primitives.js in base.html.
// -- Toast ------------------------------------------------
var Toast = {
_container: null,
_init: function() {
if (this._container) return;
this._container = document.getElementById('toastContainer');
if (!this._container) {
this._container = document.createElement('div');
this._container.id = 'toastContainer';
this._container.className = 'toast-stack';
document.body.appendChild(this._container);
}
},
show: function(msg, type) {
type = type || 'info';
this._init();
var el = document.createElement('div');
el.className = 'toast toast-' + type;
el.innerHTML = '<span class="toast-icon"></span><span class="toast-msg">' + (typeof esc === 'function' ? esc(msg) : msg) + '</span>';
this._container.appendChild(el);
setTimeout(function() {
el.classList.add('toast-exit');
setTimeout(function() { el.remove(); }, 300);
}, 3000);
},
success: function(msg) { this.show(msg, 'success'); },
error: function(msg) { this.show(msg, 'error'); },
warning: function(msg) { this.show(msg, 'warning'); },
info: function(msg) { this.show(msg, 'info'); },
};
// -- Badge ------------------------------------------------
function renderBadge(text, color) {
color = color || 'muted';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<span class="badge badge-' + e(color) + '">' + e(text) + '</span>';
}
// -- Icon SVG ---------------------------------------------
var _ICONS = {
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
back: '<line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7"/><path d="M18.5 2.5a2.12 2.12 0 013 3L12 15l-4 1 1-4 9.5-9.5z"/>',
trash: '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2"/>',
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
send: '<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>',
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2"/><circle cx="12" cy="7" r="4"/>',
users: '<path d="M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 00-3-3.87"/><path d="M16 3.13a4 4 0 010 7.75"/>',
key: '<path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 11-7.778 7.778 5.5 5.5 0 010-7.777L12 4l3.5 3.5L18 5l3-3"/>',
settings:'<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 00.33 1.82l.06.06a2 2 0 010 2.83 2 2 0 01-2.83 0l-.06-.06a1.65 1.65 0 00-1.82-.33 1.65 1.65 0 00-1 1.51V21a2 2 0 01-4 0v-.09A1.65 1.65 0 009 19.4a1.65 1.65 0 00-1.82.33l-.06.06a2 2 0 01-2.83-2.83l.06-.06A1.65 1.65 0 004.68 15a1.65 1.65 0 00-1.51-1H3a2 2 0 010-4h.09A1.65 1.65 0 004.6 9a1.65 1.65 0 00-.33-1.82l-.06-.06a2 2 0 012.83-2.83l.06.06A1.65 1.65 0 009 4.68a1.65 1.65 0 001-1.51V3a2 2 0 014 0v.09a1.65 1.65 0 001 1.51 1.65 1.65 0 001.82-.33l.06-.06a2 2 0 012.83 2.83l-.06.06A1.65 1.65 0 0019.4 9c.2.65.7 1.15 1.33 1.34H21a2 2 0 010 4h-.09c-.63.2-1.13.7-1.33 1.34z"/>',
cpu: '<rect x="4" y="4" width="16" height="16" rx="2"/><rect x="9" y="9" width="6" height="6"/><line x1="9" y1="1" x2="9" y2="4"/><line x1="15" y1="1" x2="15" y2="4"/><line x1="9" y1="20" x2="9" y2="23"/><line x1="15" y1="20" x2="15" y2="23"/><line x1="20" y1="9" x2="23" y2="9"/><line x1="20" y1="14" x2="23" y2="14"/><line x1="1" y1="9" x2="4" y2="9"/><line x1="1" y1="14" x2="4" y2="14"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
chart: '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
msg: '<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z"/>',
globe: '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z"/>',
};
function renderIcon(name, size) {
size = size || 16;
var d = _ICONS[name];
if (!d) return '';
return '<svg width="' + size + '" height="' + size + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">' + d + '</svg>';
}
function renderIconBtn(icon, opts) {
opts = opts || {};
var title = opts.title || '';
var onclick = opts.onclick || '';
var cls = opts.cls || '';
var size = opts.size || 16;
var activeClass = opts.active ? ' active' : '';
var e = typeof esc === 'function' ? esc : function(s) { return s; };
return '<button class="icon-btn' + activeClass + ' ' + cls + '" title="' + e(title) + '"' +
(onclick ? ' onclick="' + onclick + '"' : '') + '>' + renderIcon(icon, size) + '</button>';
}
// -- Theme ------------------------------------------------
var Theme = {
_key: 'switchboard_theme',
init: function() {
var saved = localStorage.getItem(this._key) || 'system';
this.set(saved);
},
set: function(mode) {
localStorage.setItem(this._key, mode);
if (mode === 'system') document.documentElement.removeAttribute('data-theme');
else document.documentElement.setAttribute('data-theme', mode);
},
get: function() { return localStorage.getItem(this._key) || 'system'; },
resolved: function() {
var mode = this.get();
if (mode !== 'system') return mode;
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
},
renderToggle: function(containerId) {
var el = document.getElementById(containerId);
if (!el) return;
var current = this.get();
var options = [
{ id: 'light', label: 'Light', icon: '\u2600' },
{ id: 'dark', label: 'Dark', icon: '\u263E' },
{ id: 'system',label: 'System',icon: '\u229E' },
];
var self = this;
el.innerHTML = '<div class="theme-toggle">' + options.map(function(o) {
return '<button class="theme-toggle__btn' + (current === o.id ? ' active' : '') + '" data-theme="' + o.id + '" title="' + o.label + '">' + o.icon + '</button>';
}).join('') + '</div>';
el.querySelectorAll('.theme-toggle__btn').forEach(function(btn) {
btn.addEventListener('click', function() {
self.set(btn.dataset.theme);
self.renderToggle(containerId);
});
});
},
};
// -- Confirm Dialog (enhanced) ----------------------------
function showConfirmDialog(opts) {
opts = opts || {};
var title = opts.title || 'Confirm';
var message = opts.message || 'Are you sure?';
var confirmLabel = opts.confirmLabel || 'Confirm';
var cancelLabel = opts.cancelLabel || 'Cancel';
var variant = opts.variant || 'primary';
var onConfirm = opts.onConfirm;
var onCancel = opts.onCancel;
var e = typeof esc === 'function' ? esc : function(s) { return s; };
var overlay = document.getElementById('confirmOverlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'confirmOverlay';
overlay.className = 'confirm-overlay';
overlay.style.display = 'none';
document.body.appendChild(overlay);
}
overlay.innerHTML =
'<div class="confirm-dialog">' +
'<div class="confirm-dialog__title">' + e(title) + '</div>' +
'<div class="confirm-dialog__msg">' + e(message) + '</div>' +
'<div class="confirm-dialog__actions">' +
'<button class="btn btn-ghost" id="confirmCancelBtn">' + e(cancelLabel) + '</button>' +
'<button class="btn btn-' + variant + '" id="confirmOkBtn">' + e(confirmLabel) + '</button>' +
'</div>' +
'</div>';
overlay.style.display = 'flex';
function close() { overlay.style.display = 'none'; overlay.innerHTML = ''; }
document.getElementById('confirmCancelBtn').addEventListener('click', function() { close(); if (onCancel) onCancel(); });
document.getElementById('confirmOkBtn').addEventListener('click', function() { close(); if (onConfirm) onConfirm(); });
overlay.addEventListener('click', function(ev) { if (ev.target === overlay) { close(); if (onCancel) onCancel(); } });
}
// -- Avatar Upload Component ------------------------------
function mountAvatarUpload(containerEl, opts) {
opts = opts || {};
var current = opts.current;
var size = opts.size || 80;
var onUpload = opts.onUpload;
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.style.display = 'none';
containerEl.className = 'avatar-upload';
containerEl.style.width = size + 'px';
containerEl.style.height = size + 'px';
function render(src) {
var e = typeof esc === 'function' ? esc : function(s) { return s; };
if (src) containerEl.innerHTML = '<img src="' + e(src) + '" alt="Avatar">';
else containerEl.innerHTML = '<div class="avatar-upload__placeholder">Upload<br>Photo</div>';
containerEl.appendChild(input);
}
render(current || null);
containerEl.addEventListener('click', function() { input.click(); });
input.addEventListener('change', function() {
var file = input.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(ev) {
render(ev.target.result);
if (onUpload) onUpload(file, ev.target.result);
};
reader.readAsDataURL(file);
});
return { setImage: render };
}

View File

@@ -1,7 +1,7 @@
// ── Chat Switchboard — UI Primitives ────────────────────────────────────────
// Single source of truth for constants + reusable rendering functions.
// Loaded after ui-format.js, before ui-core.js.
// Follows the renderPresetForm() pattern: (container, options) → control object.
// Follows the renderPersonaForm() pattern: (container, options) → control object.
// Designed for extension hooks: registries are open for .add(), primitives
// accept options for customization, and return handles for programmatic control.
// ─────────────────────────────────────────────────────────────────────────────

View File

@@ -5,8 +5,138 @@
// providers, usage, model roles, and user preferences.
Object.assign(UI, {
// ── General Settings (Settings Surface) ──
async loadGeneralSettings() {
// Fetch settings from API into App.settings
try {
const remote = await API.getSettings();
if (remote && typeof remote === 'object') {
if (remote.model) App.settings.model = remote.model;
if (remote.system_prompt !== undefined) App.settings.systemPrompt = remote.system_prompt;
if (remote.max_tokens) App.settings.maxTokens = remote.max_tokens;
if (remote.temperature !== undefined) App.settings.temperature = remote.temperature;
if (remote.show_thinking !== undefined) App.settings.showThinking = remote.show_thinking;
}
} catch (e) { console.warn('Settings load failed:', e.message); }
// Fetch models for the model dropdown
await fetchModels();
// Populate form elements
const modelSel = document.getElementById('settingsModel');
if (modelSel) {
modelSel.innerHTML = '';
App.models.filter(m => !m.hidden).forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
modelSel.appendChild(opt);
});
if (App.settings.model) modelSel.value = App.settings.model;
}
const sysEl = document.getElementById('settingsSystemPrompt');
if (sysEl) sysEl.value = App.settings.systemPrompt || '';
const maxEl = document.getElementById('settingsMaxTokens');
if (maxEl) maxEl.value = App.settings.maxTokens || '';
const tempEl = document.getElementById('settingsTemp');
const tempVal = document.getElementById('settingsTempValue');
if (tempEl) {
tempEl.value = App.settings.temperature;
if (tempVal) tempVal.textContent = App.settings.temperature;
tempEl.addEventListener('input', () => {
if (tempVal) tempVal.textContent = tempEl.value;
});
}
const thinkEl = document.getElementById('settingsThinking');
if (thinkEl) thinkEl.checked = App.settings.showThinking;
// Model change → update max tokens hint
if (modelSel) {
modelSel.addEventListener('change', () => {
const model = App.findModel(modelSel.value);
const caps = model?.capabilities || {};
const hint = document.getElementById('settingsMaxHint');
if (hint) {
hint.textContent = caps.max_output_tokens > 0
? `(model max: ${(caps.max_output_tokens / 1000).toFixed(0)}K)` : '';
}
});
// Trigger once to show current model's hint
modelSel.dispatchEvent(new Event('change'));
}
// Save button (reuse existing pattern — form auto-saves are not a thing yet)
const section = document.querySelector('.settings-section');
if (section && !section.querySelector('.settings-save-btn')) {
const btn = document.createElement('button');
btn.className = 'btn-md btn-primary settings-save-btn';
btn.textContent = 'Save';
btn.style.marginTop = '12px';
btn.addEventListener('click', async () => {
App.settings.model = modelSel?.value || App.settings.model;
App.settings.systemPrompt = sysEl?.value?.trim() || '';
App.settings.maxTokens = parseInt(maxEl?.value) || 0;
App.settings.temperature = parseFloat(tempEl?.value) || 0.7;
App.settings.showThinking = thinkEl?.checked || false;
try {
await API.updateSettings({
model: App.settings.model,
system_prompt: App.settings.systemPrompt,
max_tokens: App.settings.maxTokens,
temperature: App.settings.temperature,
show_thinking: App.settings.showThinking,
});
UI.toast('Settings saved', 'success');
} catch (e) { UI.toast(e.message, 'error'); }
});
section.appendChild(btn);
}
},
// ── Appearance Settings ─────────────────
saveAppearance() {
const scaleEl = document.getElementById('settingsScale');
const msgFontEl = document.getElementById('settingsMsgFont');
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
if (scaleEl) prefs.scale = parseInt(scaleEl.value);
if (msgFontEl) prefs.msgFont = parseInt(msgFontEl.value);
localStorage.setItem('cs-appearance', JSON.stringify(prefs));
UI.toast('Appearance saved', 'success');
},
// ── Teams Settings (Settings Surface) ────
async loadTeamsSettings() {
const el = document.getElementById('settingsDynamic');
if (!el) return;
try {
const resp = await API.listMyTeams();
const teams = resp.data || [];
if (!teams.length) {
el.innerHTML = '<div class="empty-hint">You are not a member of any teams.</div>';
return;
}
el.innerHTML = teams.map(t => `
<div class="settings-section" style="margin-bottom:16px;">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px;">
<h3 style="margin:0">${esc(t.name)}</h3>
<span class="badge-${t.my_role === 'admin' ? 'success' : 'muted'}" style="font-size:11px;">${t.my_role}</span>
</div>
${t.description ? `<p style="color:var(--text-2);font-size:13px;margin:0 0 8px;">${esc(t.description)}</p>` : ''}
<div style="font-size:12px;color:var(--text-3);">${t.member_count} member${t.member_count !== 1 ? 's' : ''}</div>
</div>
`).join('');
} catch (e) {
el.innerHTML = '<div class="empty-hint">Failed to load teams.</div>';
}
},
loadAppearanceSettings() {
const prefs = JSON.parse(localStorage.getItem('cs-appearance') || '{}');
const scale = prefs.scale || 100;
@@ -125,7 +255,7 @@ Object.assign(UI, {
localStorage.setItem('cs-appearance', JSON.stringify({ scale, msgFont }));
// Recheck tab overflow after layout settles with new zoom
requestAnimationFrame(() => {
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(checkTabsOverflow);
document.querySelectorAll('.modal-overlay.active .modal-tabs').forEach(t => { if (typeof checkTabsOverflow === 'function') checkTabsOverflow(t); });
});
},
@@ -147,9 +277,9 @@ Object.assign(UI, {
if (kbTabBtn) kbTabBtn.style.display = allowed ? '' : 'none';
},
checkUserPresetsAllowed() {
const addBtn = document.getElementById('userAddPresetBtn');
const addForm = document.getElementById('userAddPresetForm');
checkUserPersonasAllowed() {
const addBtn = document.getElementById('userAddPersonaBtn');
const addForm = document.getElementById('userAddPersonaForm');
const tabBtn = document.getElementById('settingsPersonasTabBtn');
const allowed = App.policies?.allow_user_personas === 'true';
if (addBtn) addBtn.style.display = allowed ? '' : 'none';
@@ -227,7 +357,7 @@ Object.assign(UI, {
document.getElementById('teamAdminContent').style.display = '';
// Reset forms (null-safe — not all form containers may exist)
['settingsTeamAddMember', 'settingsTeamAddPreset', 'settingsTeamProviderForm'].forEach(id => {
['settingsTeamAddMember', 'settingsTeamAddPersona', 'settingsTeamProviderForm'].forEach(id => {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
});
@@ -359,22 +489,22 @@ Object.assign(UI, {
await UI._teamProvList.refresh();
},
async loadTeamManagePresets(teamId) {
const el = document.getElementById('settingsTeamPresets');
async loadTeamManagePersonas(teamId) {
const el = document.getElementById('settingsTeamPersonas');
el.innerHTML = '<div class="loading">Loading...</div>';
try {
const resp = await API.teamListPresets(teamId);
const presets = resp.presets || [];
el.innerHTML = presets.map(p => {
const resp = await API.teamListPersonas(teamId);
const personas = resp.personas || [];
el.innerHTML = personas.map(p => {
const icon = p.icon || '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${icon ? icon + ' ' : ''}${esc(p.name)}</strong>
<div class="preset-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
<div class="persona-meta" style="font-size:11px">${esc(p.base_model_id)}</div>
</div>
<button class="btn-delete" onclick="settingsDeleteTeamPreset('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
<button class="btn-delete" onclick="settingsDeleteTeamPersona('${teamId}','${p.id}','${esc(p.name)}')" title="Delete">✕</button>
</div>`;
}).join('') || '<div class="empty-hint">No team presets — create one to give your team preconfigured models</div>';
}).join('') || '<div class="empty-hint">No team personas — create one to give your team preconfigured models</div>';
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
@@ -534,8 +664,8 @@ Object.assign(UI, {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadTeamPresetModelDropdown(teamId) {
const sel = document.getElementById('teamPreset_model');
async loadTeamPersonaModelDropdown(teamId) {
const sel = document.getElementById('teamPersona_model');
if (!sel) return;
sel.innerHTML = '<option value="">Loading models...</option>';
try {
@@ -566,7 +696,7 @@ Object.assign(UI, {
} catch (e) {
// Fallback to App.models if team endpoint fails
sel.innerHTML = '<option value="">Select base model...</option>';
App.models.filter(m => !m.isPreset).forEach(m => {
App.models.filter(m => !m.isPersona).forEach(m => {
const opt = document.createElement('option');
opt.value = m.baseModelId || m.id;
opt.textContent = (m.name || m.id) + (m.provider ? ` (${m.provider})` : '');
@@ -610,7 +740,7 @@ Object.assign(UI, {
}
const data = await API.listEnabledModels();
const models = (data.models || []).filter(m => !m.is_preset);
const models = (data.models || []).filter(m => !m.is_persona);
if (!models.length) {
el.innerHTML = '<div class="empty-hint">No models available</div>';
return;
@@ -635,26 +765,26 @@ Object.assign(UI, {
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }
},
async loadUserPresets() {
const el = document.getElementById('userPresetList');
async loadUserPersonas() {
const el = document.getElementById('userPersonaList');
if (!el) return;
try {
const data = await API.listUserPresets();
const data = await API.listUserPersonas();
// Only show personal presets owned by user
const presets = (data.presets || []).filter(p => p.scope === 'personal');
if (!presets.length) {
const personas = (data.personas || []).filter(p => p.scope === 'personal');
if (!personas.length) {
el.innerHTML = '<div class="empty-hint">No personal presets yet</div>';
return;
}
el.innerHTML = presets.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="preset-row-avatar" alt="">` : '';
return `<div class="admin-preset-row" style="padding:6px 0">
<div class="preset-info">
el.innerHTML = personas.map(p => {
const avatarEl = p.avatar ? `<img src="${p.avatar}" class="persona-row-avatar" alt="">` : '';
return `<div class="admin-persona-row" style="padding:6px 0">
<div class="persona-info">
<strong>${avatarEl}${esc(p.name)}</strong>
<div class="preset-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
<div class="persona-meta">${esc(p.base_model_id)} · ${esc(p.provider_name || 'auto')}</div>
</div>
<div class="preset-actions">
<button class="btn-delete" onclick="deleteUserPreset('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
<div class="persona-actions">
<button class="btn-delete" onclick="deleteUserPersona('${p.id}', '${esc(p.name)}')" title="Delete">✕</button>
</div>
</div>`;
}).join('');