Changeset 0.22.7 (#149)
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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
372
src/js/admin-scaffold.js
Normal 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">← 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">← 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 & 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">← Prev</button>' +
|
||||
'<span id="auditPageInfo" class="text-muted" style="font-size:12px"></span>' +
|
||||
'<button class="btn-small" id="auditNextBtn">Next →</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]();
|
||||
}
|
||||
});
|
||||
})();
|
||||
@@ -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
101
src/js/app-state.js
Normal 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); }
|
||||
}
|
||||
123
src/js/app.js
123
src/js/app.js
@@ -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.
|
||||
|
||||
@@ -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
162
src/js/chat-pane.js
Normal 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; }
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
200
src/js/notes.js
200
src/js/notes.js
@@ -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
189
src/js/pages-splash.js
Normal 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 = ''; }
|
||||
})();
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
43
src/js/surface-nav.js
Normal 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');
|
||||
})();
|
||||
@@ -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="${p.id}"]')" 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]) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
199
src/js/ui-primitives-additions.js
Normal file
199
src/js/ui-primitives-additions.js
Normal 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 };
|
||||
}
|
||||
@@ -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.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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('');
|
||||
|
||||
Reference in New Issue
Block a user