Changeset 0.23.0 (#153)
This commit is contained in:
@@ -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),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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'); },
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
});
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
178
src/js/chat.js
178
src/js/chat.js
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>`; }
|
||||
|
||||
Reference in New Issue
Block a user