Changeset 0.23.0 (#153)

This commit is contained in:
2026-03-05 22:40:26 +00:00
parent 40d9834f64
commit 2fc620e1ac
62 changed files with 6214 additions and 362 deletions

View File

@@ -131,7 +131,7 @@ function processModelsResponse(data, hiddenModels = new Set()) {
return (data.models || []).map(m => {
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id;
const cfgId = m.config_id || m.provider_config_id || '';
const id = isPersona
? (m.persona_id || m.id)
: (cfgId ? `${cfgId}:${baseModelId}` : baseModelId);
@@ -143,12 +143,13 @@ function processModelsResponse(data, hiddenModels = new Set()) {
configId: cfgId || null,
isPersona,
personaId: m.persona_id || null,
personaHandle: m.persona_handle || 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),
hidden: !isPersona && hiddenModels.has((cfgId || '') + ':' + baseModelId),
};
});
}

View File

@@ -111,6 +111,7 @@ describe('processModelsResponse — personas', () => {
persona_id: 'preset-uuid',
persona_scope: 'global',
persona_avatar: '/avatars/code.png',
persona_handle: 'code-helper',
persona_team_name: null,
config_id: 'cfg-uuid',
provider_name: 'OpenAI',
@@ -147,6 +148,7 @@ describe('processModelsResponse — personas', () => {
const models = processModelsResponse(apiResponse);
assert.equal(models[0].personaScope, 'global');
assert.equal(models[0].personaAvatar, '/avatars/code.png');
assert.equal(models[0].personaHandle, 'code-helper');
assert.equal(models[0].personaId, 'preset-uuid');
});
@@ -208,8 +210,8 @@ describe('processModelsResponse — hidden models', () => {
],
};
it('marks models as hidden from user prefs', () => {
const hidden = new Set(['gpt-4o']);
it('marks models as hidden from user prefs (composite key)', () => {
const hidden = new Set(['c1:gpt-4o']);
const models = processModelsResponse(apiResponse, hidden);
assert.equal(models[0].hidden, true, 'gpt-4o should be hidden');
assert.equal(models[1].hidden, false, 'claude-3 should NOT be hidden');
@@ -221,7 +223,7 @@ describe('processModelsResponse — hidden models', () => {
{ model_id: 'gpt-4o', is_persona: true, persona_id: 'p1', display_name: 'Persona' },
],
};
const hidden = new Set(['gpt-4o']);
const hidden = new Set([':gpt-4o']);
const models = processModelsResponse(resp, hidden);
assert.equal(models[0].hidden, false,
'personas must NOT be hidden by base model hidden pref');

View File

@@ -314,7 +314,7 @@ describe('Edge: provider_config_id only (no config_id alias)', () => {
describe('Edge: Hidden model preferences', () => {
it('marks hidden models correctly', () => {
const hidden = new Set(['gpt-4o-mini']);
const hidden = new Set(['cfg-global-openai:gpt-4o-mini']);
const models = processModelsResponse({ models: GLOBAL_MODELS }, hidden);
const mini = models.find(m => m.baseModelId === 'gpt-4o-mini');
const main = models.find(m => m.baseModelId === 'gpt-4o');
@@ -324,7 +324,7 @@ describe('Edge: Hidden model preferences', () => {
it('personas are never hidden', () => {
const persona = personaModel('preset-1', 'gpt-4o', 'global');
const hidden = new Set(['gpt-4o']);
const hidden = new Set([':gpt-4o']);
const models = processModelsResponse({ models: [persona] }, hidden);
assert.equal(models[0].hidden, false, 'personas must never be hidden');
});

View File

@@ -154,11 +154,12 @@ async function bulkSetVisibility(visibility) {
// ── User Model Preferences ──────────────────
async function toggleUserModelVisibility(modelId, currentlyHidden) {
async function toggleUserModelVisibility(modelId, providerConfigId, currentlyHidden) {
const compositeKey = (providerConfigId || '') + ':' + modelId;
try {
await API.setModelPreference(modelId, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(modelId);
else App.hiddenModels.add(modelId);
await API.setModelPreference(modelId, providerConfigId || null, !currentlyHidden);
if (currentlyHidden) App.hiddenModels.delete(compositeKey);
else App.hiddenModels.add(compositeKey);
await UI.loadUserModels();
await fetchModels(); // refresh model selector
} catch (e) { UI.toast(e.message, 'error'); }
@@ -170,12 +171,18 @@ async function bulkSetUserModelVisibility(show) {
const shouldHide = !show;
// Only update models not already in the desired state
const toUpdate = models
.map(m => m.baseModelId || m.id)
.filter(mid => App.hiddenModels.has(mid) !== shouldHide);
.filter(m => {
const compositeKey = (m.configId || '') + ':' + (m.baseModelId || m.id);
return App.hiddenModels.has(compositeKey) !== shouldHide;
})
.map(m => ({ model_id: m.baseModelId || m.id, provider_config_id: m.configId || '' }));
if (!toUpdate.length) { UI.toast(show ? 'All already visible' : 'All already hidden'); return; }
try {
await API.bulkSetModelPreferences(toUpdate, shouldHide);
toUpdate.forEach(mid => shouldHide ? App.hiddenModels.add(mid) : App.hiddenModels.delete(mid));
toUpdate.forEach(e => {
const key = (e.provider_config_id || '') + ':' + e.model_id;
shouldHide ? App.hiddenModels.add(key) : App.hiddenModels.delete(key);
});
await UI.loadUserModels();
await fetchModels();
UI.toast(show ? `${toUpdate.length} model(s) shown` : `${toUpdate.length} model(s) hidden`);

View File

@@ -194,6 +194,12 @@ const API = {
updateChannelModel(channelId, modelId, data) { return this._patch(`/api/v1/channels/${channelId}/models/${modelId}`, data); },
deleteChannelModel(channelId, modelId) { return this._del(`/api/v1/channels/${channelId}/models/${modelId}`); },
// Channel participants (v0.23.0 — ICD §3.7)
listParticipants(channelId) { return this._get(`/api/v1/channels/${channelId}/participants`); },
addParticipant(channelId, data) { return this._post(`/api/v1/channels/${channelId}/participants`, data); },
updateParticipant(channelId, participantId, data) { return this._patch(`/api/v1/channels/${channelId}/participants/${participantId}`, data); },
removeParticipant(channelId, participantId) { return this._del(`/api/v1/channels/${channelId}/participants/${participantId}`); },
// Notification preferences (v0.20.0 Phase 3)
listNotifPrefs() { return this._get('/api/v1/notifications/preferences'); },
setNotifPref(type, data) { return this._put(`/api/v1/notifications/preferences/${encodeURIComponent(type)}`, data); },
@@ -451,8 +457,8 @@ const API = {
listEnabledModels() { return this._get('/api/v1/models/enabled'); },
getModelPreferences() { return this._get('/api/v1/models/preferences'); },
setModelPreference(modelId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, hidden }); },
bulkSetModelPreferences(modelIds, hidden) { return this._post('/api/v1/models/preferences/bulk', { model_ids: modelIds, hidden }); },
setModelPreference(modelId, providerConfigId, hidden) { return this._put('/api/v1/models/preferences', { model_id: modelId, provider_config_id: providerConfigId, hidden }); },
bulkSetModelPreferences(entries, hidden) { return this._post('/api/v1/models/preferences/bulk', { entries, hidden }); },
// User personas
listUserPersonas() { return this._get('/api/v1/personas'); },

View File

@@ -54,7 +54,9 @@ async function fetchModels() {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
(prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
@@ -63,25 +65,27 @@ async function fetchModels() {
App.models = (data.models || []).map(m => {
const isPersona = !!m.is_persona;
const baseModelId = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_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);
: (cfgId ? `${cfgId}:${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,
configId: cfgId || null,
model_type: m.model_type || 'chat',
capabilities: resolveCapabilities(m.capabilities, baseModelId),
isPersona,
personaId: m.persona_id || null,
personaHandle: m.persona_handle || 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),
hidden: !isPersona && App.hiddenModels.has((cfgId || '') + ':' + baseModelId),
};
});

View File

@@ -187,31 +187,44 @@ const ChannelModels = {
* Shows a dropdown of channel models when user types @.
*/
onInput(inputEl) {
if (this._roster.length < 2) return; // no autocomplete for single-model channels
const text = typeof inputEl.getValue === 'function' ? inputEl.getValue() : inputEl.value;
const cursorPos = typeof inputEl.getCursorPos === 'function' ? inputEl.getCursorPos() : inputEl.selectionStart;
// Find the @token being typed (search backward from cursor)
const before = text.slice(0, cursorPos);
const atIdx = before.lastIndexOf('@');
if (atIdx < 0) { this.hideAutocomplete(); return; }
// @ must be at start or preceded by whitespace
if (atIdx > 0 && before[atIdx - 1] !== ' ' && before[atIdx - 1] !== '\n') {
this.hideAutocomplete();
return;
}
const partial = before.slice(atIdx + 1).toLowerCase();
const matches = this._roster.filter(m =>
m.display_name.toLowerCase().startsWith(partial) ||
m.display_name.toLowerCase().replace(/-/g, ' ').startsWith(partial.replace(/-/g, ' '))
);
const partial = before.slice(atIdx + 1).toLowerCase().replace(/-/g, ' ');
if (partial.length === 0) {
// Just typed @ — show everything
}
// Build match list from ALL enabled models + personas (not just roster)
const allModels = (App.models || []).filter(m => !m.hidden);
const matches = allModels.filter(m => {
// Match against persona handle
const handle = (m.personaHandle || '').toLowerCase().replace(/-/g, ' ');
// Match against model ID
const modelId = (m.baseModelId || m.id || '').toLowerCase().replace(/-/g, ' ');
// Match against display name
const name = (m.name || '').toLowerCase().replace(/-/g, ' ');
const firstName = name.split(' ')[0] || '';
if (partial.length === 0) return true; // show all on bare @
return handle.startsWith(partial)
|| modelId.startsWith(partial)
|| name.startsWith(partial)
|| firstName.startsWith(partial);
});
if (matches.length === 0) { this.hideAutocomplete(); return; }
this.showAutocomplete(inputEl, atIdx, cursorPos, matches);
// Cap at 10 to keep dropdown manageable
this.showAutocomplete(inputEl, atIdx, cursorPos, matches.slice(0, 10));
},
showAutocomplete(inputEl, atIdx, cursorPos, matches) {
@@ -221,19 +234,36 @@ const ChannelModels = {
wrap.className = 'mention-ac';
wrap.id = 'mentionAc';
// Position relative to input
const inputRect = (inputEl.el || inputEl).getBoundingClientRect();
wrap.style.bottom = (window.innerHeight - inputRect.top + 4) + 'px';
wrap.style.left = inputRect.left + 'px';
wrap.style.minWidth = '200px';
wrap.style.minWidth = '280px';
matches.forEach((m, i) => {
const item = document.createElement('div');
item.className = 'mention-ac-item' + (i === 0 ? ' mention-ac-active' : '');
item.dataset.name = m.display_name;
item.innerHTML = `<span class="mention-ac-name">${esc(m.display_name)}</span><span class="mention-ac-model">${esc(m.model_id)}</span>`;
// Determine the @handle to insert
const mentionToken = m.isPersona
? (m.personaHandle || m.name?.replace(/\s+/g, '-').toLowerCase() || m.id)
: (m.baseModelId || m.id);
item.dataset.handle = mentionToken;
const avatar = m.personaAvatar
? `<img src="${esc(m.personaAvatar)}" class="mention-ac-avatar" alt="">`
: m.isPersona
? `<span class="mention-ac-avatar mention-ac-avatar-fallback">⚡</span>`
: `<span class="mention-ac-avatar mention-ac-avatar-fallback">🤖</span>`;
const handleText = m.isPersona && m.personaHandle
? `@${esc(m.personaHandle)}`
: `@${esc(m.baseModelId || m.id)}`;
const providerHint = m.provider ? esc(m.provider) : '';
item.innerHTML = `${avatar}<div class="mention-ac-info"><span class="mention-ac-name">${esc(m.name || m.id)}</span><span class="mention-ac-handle">${handleText}</span></div><span class="mention-ac-model">${providerHint}</span>`;
item.addEventListener('click', () => {
this._insertMention(inputEl, atIdx, cursorPos, m.display_name);
this._insertMention(inputEl, atIdx, cursorPos, mentionToken);
});
wrap.appendChild(item);
});
@@ -278,9 +308,7 @@ const ChannelModels = {
if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
const active = items[activeIdx];
if (active) {
active.click();
}
if (active) active.click();
return true;
}
if (e.key === 'Escape') {
@@ -326,6 +354,31 @@ const ChannelModels = {
if (cm?.display_name) return cm.display_name;
const m = App.models?.find(x => x.id === modelId || x.baseModelId === modelId);
return m?.name || modelId;
},
/**
* Resolve persona avatar for a channel model roster entry.
* Looks up via persona_id in App.models.
*/
_resolveAvatar(rosterEntry) {
if (!rosterEntry.persona_id) return null;
const persona = App.models?.find(m => m.personaId === rosterEntry.persona_id);
return persona?.personaAvatar || null;
},
/**
* Get persona info for a given model_id from the roster + App.models.
* Returns { displayName, avatar, personaId } or null.
*/
resolvePersonaInfo(modelId) {
const cm = this._roster.find(m => m.model_id === modelId);
if (!cm) return null;
const avatar = this._resolveAvatar(cm);
return {
displayName: cm.display_name || modelId,
avatar: avatar,
personaId: cm.persona_id || null,
};
}
};

View File

@@ -185,7 +185,7 @@ async function toggleChannelAutoCompact(enabled) {
async function loadChats() {
try {
const resp = await API.listChannels(1, 100, 'direct');
const resp = await API.listChannels(1, 100);
App.chats = (resp.data || []).map(c => ({
id: c.id,
title: c.title,
@@ -404,6 +404,123 @@ async function newChat() {
window.history.replaceState(null, '', `${window.__BASE__ || ''}/`);
}
// ── New Group Chat (v0.23.0) ─────────────────
// Creates a group channel and adds persona participants.
// Personas can @mention each other → AI-to-AI chaining.
async function newGroupChat() {
const personas = (App.models || []).filter(m => m.isPersona);
if (personas.length === 0) {
UI.toast('No personas available. Ask your admin to create one.', 'warning');
return;
}
return new Promise(resolve => {
const overlay = document.createElement('div');
overlay.className = 'confirm-overlay';
const personaCheckboxes = personas.map(p => {
const scope = p.personaScope || 'global';
const badge = scope === 'personal' ? ' <span class="dd-hint">personal</span>'
: scope === 'team' ? ` <span class="dd-hint">${esc(p.personaTeamName || 'team')}</span>` : '';
const handle = p.personaHandle ? ` <span class="group-persona-handle">@${esc(p.personaHandle)}</span>` : '';
return `<label class="group-persona-item">
<input type="checkbox" value="${esc(p.personaId)}" data-name="${esc(p.name)}" data-handle="${esc(p.personaHandle || '')}">
<span class="group-persona-name">${esc(p.name)}${handle}${badge}</span>
<span class="group-persona-model">${esc(p.baseModelId || '')}</span>
</label>`;
}).join('');
overlay.innerHTML = `
<div class="confirm-dialog" style="max-width:480px">
<div class="confirm-header">New Group Chat</div>
<div class="confirm-body">
<div style="margin-bottom:12px">
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:4px">Channel Name</label>
<input id="groupChatTitle" type="text" class="settings-input" placeholder="e.g. Code Review Team" style="width:100%;box-sizing:border-box" autofocus>
</div>
<div>
<label style="font-size:12px;color:var(--text-secondary);display:block;margin-bottom:6px">Add Personas (select at least one)</label>
<div class="group-persona-list" style="max-height:200px;overflow-y:auto;display:flex;flex-direction:column;gap:4px">
${personaCheckboxes}
</div>
</div>
</div>
<div class="confirm-footer">
<button class="btn-small" data-action="cancel">Cancel</button>
<button class="btn-small btn-primary" data-action="ok">Create</button>
</div>
</div>`;
function close(result) {
overlay.remove();
document.removeEventListener('keydown', onKey);
resolve(result);
}
function onKey(e) {
if (e.key === 'Escape') close(false);
}
overlay.querySelector('[data-action="cancel"]').addEventListener('click', () => close(false));
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(false); });
document.addEventListener('keydown', onKey);
overlay.querySelector('[data-action="ok"]').addEventListener('click', async () => {
const title = overlay.querySelector('#groupChatTitle').value.trim() || 'Group Chat';
const checked = [...overlay.querySelectorAll('.group-persona-list input:checked')];
if (checked.length === 0) {
UI.toast('Select at least one persona', 'warning');
return;
}
try {
// Create group channel
const resp = await API.createChannel(title, null, null, 'group');
const chat = {
id: resp.id, title: resp.title, type: 'group', model: null,
messages: [], messageCount: 0, projectId: resp.project_id || null,
workspace_id: resp.workspace_id || null, updatedAt: resp.updated_at,
settings: resp.settings || {}
};
// Add persona participants
for (const cb of checked) {
try {
await API.addParticipant(chat.id, {
participant_type: 'persona',
participant_id: cb.value,
role: 'member'
});
} catch (e) {
console.warn('Failed to add persona:', cb.dataset.name, e.message);
}
}
App.chats.unshift(chat);
App.currentChatId = chat.id;
UI.renderChatList();
Events.emit('chat.created', { chatId: chat.id, projectId: null }, { localOnly: true });
window.history.replaceState(null, '', `${window.__BASE__ || ''}/chat/${chat.id}`);
// Load the channel model roster (populated by participant auto-add)
if (typeof ChannelModels !== 'undefined') ChannelModels.load(chat.id);
UI.showEmptyState();
const names = checked.map(cb => cb.dataset.name).join(', ');
UI.toast(`Group created with ${checked.length} persona${checked.length > 1 ? 's' : ''}: ${names}`, 'success');
ChatInput.focus();
} catch (e) {
UI.toast('Failed to create group: ' + e.message, 'error');
}
close(true);
});
document.body.appendChild(overlay);
overlay.querySelector('#groupChatTitle').focus();
});
}
async function deleteChat(chatId) {
if (!await showConfirm('Delete this chat?')) return;
try {
@@ -908,6 +1025,65 @@ function _initChatListeners() {
// Multi-model channel roster (v0.20.0)
if (typeof ChannelModels !== 'undefined') ChannelModels.init();
// AI-to-AI chain: handle messages arriving via WebSocket (v0.23.0)
// When a persona @mentions another persona, the follow-up completion
// runs server-side and delivers the result via WS, not SSE.
Events.on('message.created', (payload) => {
if (!payload || !payload.channel_id) return;
if (payload.channel_id !== App.currentChatId) return;
const chat = App.chats.find(c => c.id === payload.channel_id);
if (!chat) return;
// Append the chained message
chat.messages.push({
id: payload.id,
parent_id: null,
role: payload.role || 'assistant',
content: payload.content || '',
model: payload.model || '',
modelName: payload.display_name || payload.model || '',
timestamp: new Date().toISOString(),
tool_calls: null,
metadata: payload.chain_depth ? { chain_depth: payload.chain_depth } : null,
siblingCount: 1,
siblingIndex: 0,
participant_type: payload.participant_type,
participant_id: payload.participant_id,
});
// Remove typing indicator and re-render
document.getElementById('typingIndicator')?.remove();
UI.renderMessages(chat.messages);
});
// Typing indicators from persona chain activity
Events.on('typing.start', (payload) => {
if (!payload || payload.channel_id !== App.currentChatId) return;
if (payload.participant_type !== 'persona') return;
// Show typing indicator with persona name
document.getElementById('typingIndicator')?.remove();
const container = document.getElementById('chatMessages');
if (!container) return;
const typing = document.createElement('div');
typing.id = 'typingIndicator';
typing.className = 'message assistant';
const name = payload.display_name || 'AI';
typing.innerHTML = `
<div class="msg-row">
<div class="msg-avatar"><span class="avatar-emoji">⚡</span></div>
<div class="msg-body"><span class="chain-typing-name">${name}</span> <div class="typing-dots"><span></span><span></span><span></span></div></div>
</div>`;
container.appendChild(typing);
container.scrollTop = container.scrollHeight;
});
Events.on('typing.stop', (payload) => {
if (!payload || payload.channel_id !== App.currentChatId) return;
document.getElementById('typingIndicator')?.remove();
});
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {

View File

@@ -630,7 +630,7 @@ function _showSaveToNoteModal(opts) {
const modal = document.createElement('div');
modal.id = 'saveToNoteModal';
modal.className = 'modal-overlay';
modal.className = 'modal-overlay active';
modal.innerHTML = `
<div class="modal-content save-to-note-modal">
<h3>Save to Note</h3>

View File

@@ -17,7 +17,17 @@ function avatarHTML(role, avatarDataURI) {
// Look up the current model/persona avatar for assistant messages.
function assistantAvatarURI(msg) {
// Try to find persona avatar from the model that generated this message
// 1. Check channel model roster for persona avatar (v0.23.0)
if (typeof ChannelModels !== 'undefined' && msg?.model) {
const info = ChannelModels.resolvePersonaInfo(msg.model);
if (info?.avatar) return info.avatar;
}
// 2. Check participant_id directly (for chained messages)
if (msg?.participant_type === 'persona' && msg?.participant_id) {
const m = App.models.find(x => x.personaId === msg.participant_id);
if (m?.personaAvatar) return m.personaAvatar;
}
// 3. Standard lookup by model ID or persona ID
const modelId = msg?.model || msg?.persona_id;
if (modelId) {
const m = App.models.find(x => x.id === modelId || x.personaId === modelId);
@@ -37,6 +47,7 @@ function renderPersonaForm(containerEl, options = {}) {
containerEl.innerHTML = `
<div class="form-group"><label>Name</label><input type="text" id="${pfx}_name" placeholder="Code Reviewer"></div>
<div class="form-group"><label>@mention Handle</label><input type="text" id="${pfx}_handle" placeholder="code-reviewer" maxlength="50" class="mono-input"><div class="form-hint">Auto-generated from name. Used for @mentions in chat.</div></div>
${showAvatar ? `
<div class="avatar-upload-row" style="margin-bottom:0.75rem">
<div class="avatar-preview avatar-preview-sm" id="${pfx}_avatarPreview">
@@ -95,6 +106,22 @@ function renderPersonaForm(containerEl, options = {}) {
if (options.onCancel) options.onCancel();
});
// Auto-generate handle from name
let _handleManuallyEdited = false;
document.getElementById(`${pfx}_handle`)?.addEventListener('input', () => { _handleManuallyEdited = true; });
document.getElementById(`${pfx}_name`)?.addEventListener('input', () => {
if (_handleManuallyEdited) return;
const name = document.getElementById(`${pfx}_name`)?.value || '';
const handle = name.toLowerCase().trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 50);
const hEl = document.getElementById(`${pfx}_handle`);
if (hEl) hEl.value = handle;
});
// Submit wiring
document.getElementById(`${pfx}_submitBtn`)?.addEventListener('click', () => {
if (options.onSubmit) options.onSubmit(form.getValues());
@@ -104,6 +131,7 @@ function renderPersonaForm(containerEl, options = {}) {
getValues() {
const v = {
name: document.getElementById(`${pfx}_name`)?.value.trim() || '',
handle: document.getElementById(`${pfx}_handle`)?.value.trim() || '',
description: document.getElementById(`${pfx}_desc`)?.value.trim() || '',
base_model_id: document.getElementById(`${pfx}_model`)?.value || '',
system_prompt: document.getElementById(`${pfx}_prompt`)?.value || '',
@@ -129,6 +157,7 @@ function renderPersonaForm(containerEl, options = {}) {
setValues(p) {
const el = id => document.getElementById(`${pfx}_${id}`);
if (el('name')) el('name').value = p.name || '';
if (el('handle')) { el('handle').value = p.handle || ''; _handleManuallyEdited = !!p.handle; }
if (el('desc')) el('desc').value = p.description || '';
if (el('prompt')) el('prompt').value = p.system_prompt || '';
if (el('temp')) el('temp').value = p.temperature != null ? p.temperature : '';
@@ -141,7 +170,7 @@ function renderPersonaForm(containerEl, options = {}) {
if (el('memoryExtractionPrompt')) el('memoryExtractionPrompt').value = p.memory_extraction_prompt || '';
},
clearForm() {
['name','desc','prompt','temp','maxTokens'].forEach(f => {
['name','handle','desc','prompt','temp','maxTokens'].forEach(f => {
const el = document.getElementById(`${pfx}_${f}`);
if (el) el.value = '';
});
@@ -288,6 +317,8 @@ const UI = {
// ── Chat item renderer ──────────────────
const renderChatItem = (c) => {
const time = _relativeTime(c.updatedAt);
const typeIcon = c.type === 'group' ? '<span class="chat-type-icon" title="Group Chat">👥</span> '
: c.type === 'workflow' ? '<span class="chat-type-icon" title="Workflow">⚙</span> ' : '';
return `
<div class="chat-item ${c.id === App.currentChatId ? 'active' : ''}"
onclick="selectChat('${c.id}')"
@@ -295,7 +326,7 @@ const UI = {
draggable="true"
ondragstart="onChatDragStart(event,'${c.id}')"
ondragend="onChatDragEnd(event)">
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${esc(c.title)}</span>
<span class="chat-item-title" ondblclick="startRenameChat('${c.id}');event.stopPropagation();" title="Double-click to rename">${typeIcon}${esc(c.title)}</span>
<span class="chat-item-time">${time}</span>
<button class="chat-item-delete" onclick="deleteChat('${c.id}');event.stopPropagation();" title="Delete">✕</button>
</div>`;
@@ -487,9 +518,11 @@ const UI = {
const time = msg.timestamp ? new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : '';
let assistantLabel = 'Assistant';
if (!isUser) {
// Use stored modelName, or resolve from current model list, or fall back to model id
// Use stored modelName, or resolve from channel roster, or model list, or fall back
if (msg.modelName) {
assistantLabel = msg.modelName;
} else if (typeof ChannelModels !== 'undefined' && msg.model) {
assistantLabel = ChannelModels.resolveDisplayName(msg.model);
} else if (msg.model) {
const m = App.models.find(x => x.id === msg.model || x.baseModelId === msg.model);
assistantLabel = m?.name || msg.model;
@@ -598,7 +631,15 @@ const UI = {
const label = modelName || 'Assistant';
const currentModel = App.findModel(App.settings.model);
const streamAvatar = currentModel?.personaAvatar || null;
// Resolve avatar: channel roster persona → selected model → null
let streamAvatar = null;
if (typeof ChannelModels !== 'undefined') {
const info = ChannelModels.resolvePersonaInfo(currentModel?.baseModelId);
if (info?.avatar) streamAvatar = info.avatar;
}
if (!streamAvatar) {
streamAvatar = currentModel?.personaAvatar || null;
}
// Multi-model state: when model_start events arrive, we create
// separate message bubbles per model. Without them, single-bubble.
@@ -911,8 +952,9 @@ const UI = {
div.className = 'model-dropdown-item';
div.dataset.value = m.id;
const avatar = m.personaAvatar ? `<img src="${m.personaAvatar}" class="dropdown-avatar" alt="">` : '';
const handleHint = m.isPersona && m.personaHandle ? `<span class="item-handle">@${esc(m.personaHandle)}</span>` : '';
const teamBadge = m.source === 'team' && m.teamName ? `<span class="badge-team" style="font-size:9px;padding:0 4px">👥 ${esc(m.teamName)}</span>` : '';
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.innerHTML = `${avatar}<span class="item-label">${esc(m.name || m.id)}${handleHint}</span>${teamBadge}${m.provider ? `<span class="item-provider">${esc(m.provider)}</span>` : ''}`;
div.addEventListener('click', () => {
UI.setModelValue(m.id, (m.name || m.id) + (m.provider ? ` (${m.provider})` : ''));
UI._closeModelDropdown();

View File

@@ -44,9 +44,71 @@ function formatMessage(content) {
html = html.replace(`THINK_PLACEHOLDER_${b.id}`, thinkHTML);
}
// @mention highlighting (v0.23.0): style @Name references as pills
html = _highlightMentions(html);
return html;
}
/**
* Highlight @mentions in rendered HTML as styled pills.
* Matches @Name patterns against the channel model roster.
* Only highlights inside text nodes (not inside code/pre/a tags).
*/
function _highlightMentions(html) {
if (typeof ChannelModels === 'undefined') return html;
const roster = ChannelModels.getRoster();
if (!roster || roster.length < 2) return html;
// Build patterns from handles (primary) and display names (fallback)
const patterns = [];
const seen = new Set();
for (const m of roster) {
// Handle pattern (preferred)
if (m.handle) {
const escaped = m.handle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped)) {
patterns.push(new RegExp('(@' + escaped + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped);
}
}
// Display name pattern (hyphenated form)
if (m.display_name) {
const hyphenated = m.display_name.replace(/\s+/g, '-');
const escaped = hyphenated.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
if (!seen.has(escaped.toLowerCase())) {
const flexible = escaped.replace(/[-]+/g, '[\\s-]+');
patterns.push(new RegExp('(@' + flexible + ')(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
seen.add(escaped.toLowerCase());
}
}
}
// @all
patterns.push(new RegExp('(@all)(?=[\\s.,!?;:)\\]}<]|$)', 'gi'));
// Don't highlight inside <code>, <pre>, <a> tags
const parts = html.split(/(<[^>]+>)/);
let inCode = false;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (part.startsWith('<')) {
const tag = part.toLowerCase();
if (tag.startsWith('<code') || tag.startsWith('<pre') || tag.startsWith('<a ')) inCode = true;
if (tag.startsWith('</code') || tag.startsWith('</pre') || tag.startsWith('</a')) inCode = false;
continue;
}
if (inCode) continue;
let text = part;
for (const re of patterns) {
text = text.replace(re, '<span class="mention-pill">$1</span>');
}
parts[i] = text;
}
return parts.join('');
}
function _formatMarked(text) {
// ── Unwrap outer ```markdown wrappers ────────────────────────
// LLMs frequently wrap entire responses in ```markdown ... ``` which is

View File

@@ -766,7 +766,9 @@ Object.assign(UI, {
try {
const prefData = await API.getModelPreferences();
App.hiddenModels = new Set(
(prefData.preferences || []).filter(p => p.hidden).map(p => p.model_id)
(prefData.preferences || []).filter(p => p.hidden).map(p =>
(p.provider_config_id || '') + ':' + p.model_id
)
);
} catch (e) {
if (!App.hiddenModels) App.hiddenModels = new Set();
@@ -781,11 +783,13 @@ Object.assign(UI, {
}
el.innerHTML = models.map(m => {
const mid = m.model_id || m.id;
const cfgId = m.config_id || m.provider_config_id || '';
const compositeKey = (cfgId || '') + ':' + mid;
const caps = m.capabilities || {};
const badges = renderCapBadges(caps, { compact: true });
const src = m.source === 'personal' ? '<span class="badge-user" style="font-size:10px">personal</span>'
: m.source === 'team' ? `<span class="badge-team" style="font-size:10px">👥 ${esc(m.team_name || 'team')}</span>` : '';
const hidden = App.hiddenModels.has(mid);
const hidden = App.hiddenModels.has(compositeKey);
const toggleCls = hidden ? '' : 'enabled';
const toggleLabel = hidden ? 'Hidden' : '✓ Visible';
return `<div class="model-list-item ${hidden ? 'model-hidden' : ''}">
@@ -793,7 +797,7 @@ Object.assign(UI, {
<span class="model-caps-inline">${badges}</span>
<span class="model-provider">${esc(m.provider_name || m.provider || '')}</span>
${src}
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
<button class="admin-model-toggle ${toggleCls}" onclick="toggleUserModelVisibility('${esc(mid)}', '${esc(cfgId)}', ${hidden})" title="${hidden ? 'Show in selector' : 'Hide from selector'}">${toggleLabel}</button>
</div>`;
}).join('');
} catch (e) { el.innerHTML = `<div class="error-hint">${esc(e.message)}</div>`; }