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

@@ -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();