Ship Assistent 0.10.18: settings as a peer tab, stream/VRAM chat fixes.

Move settings beside Chat/Cards; stop fence ramble, sticky scroll, horny echo filter, and force-warm after Generate.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Leonid Pershin
2026-08-22 04:12:53 +03:00
co-authored by Cursor
parent 43984208fc
commit 536637714c
11 changed files with 490 additions and 163 deletions
+24 -5
View File
@@ -731,11 +731,17 @@
.sa-settings {
display: flex;
flex-direction: column;
gap: 0.45rem;
padding: 0.7rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, currentColor 18%, transparent);
background: color-mix(in srgb, currentColor 5%, transparent);
max-height: 50vh;
gap: 0.55rem;
padding: 0.75rem 0.85rem 1rem;
flex: 1;
min-height: 0;
overflow: hidden;
border-bottom: none;
background: transparent;
}
#sa_view_settings {
overflow: hidden;
}
.sa-settings-head {
@@ -743,12 +749,14 @@
align-items: center;
justify-content: space-between;
gap: 0.5rem;
flex-shrink: 0;
}
.sa-settings-tabs {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
flex-shrink: 0;
}
.sa-stab {
@@ -1164,6 +1172,12 @@
gap: 0.35rem;
}
.sa-msg-body.sa-prose-live {
display: block;
white-space: pre-wrap;
word-break: break-word;
}
.sa-prose-h {
font-weight: 650;
letter-spacing: 0.01em;
@@ -1485,6 +1499,11 @@
background: color-mix(in srgb, currentColor 12%, transparent);
}
#sa_btn_settings.sa-subtab-active {
opacity: 1;
background: color-mix(in srgb, currentColor 12%, transparent);
}
.sa-view {
flex: 1;
display: flex;
+221 -72
View File
@@ -128,6 +128,7 @@
exact: null,
sessionExact: {},
lastUserParamIntent: false,
lastUserControlIntent: false,
lastPatch: null,
pendingSilentGen: false,
enabledSkills: [],
@@ -147,6 +148,7 @@
streamEl: null,
streamMeta: null,
streamText: '',
streamFenceDone: false,
critiqueHopUsed: false,
visionHopUsed: false,
lastSystemChars: 0,
@@ -215,6 +217,37 @@
}
}
let scrollMessagesRaf = 0;
/** Autoscroll only when already near the bottom — otherwise streaming fights the user's scroll (shakes). */
function messagesNearBottom(thresholdPx = 96) {
const box = $('sa_messages');
if (!box) {
return true;
}
return (box.scrollHeight - box.scrollTop - box.clientHeight) <= thresholdPx;
}
function scrollMessagesToBottom({ force = false } = {}) {
const box = $('sa_messages');
if (!box) {
return;
}
if (!force && !messagesNearBottom()) {
return;
}
if (scrollMessagesRaf) {
return;
}
scrollMessagesRaf = requestAnimationFrame(() => {
scrollMessagesRaf = 0;
const el = $('sa_messages');
if (el && (force || messagesNearBottom(120))) {
el.scrollTop = el.scrollHeight;
}
});
}
function showChatEmptyIfIdle() {
const box = $('sa_messages');
const empty = $('sa_chat_empty');
@@ -574,7 +607,7 @@
return parts.join('');
}
function setAssistantBody(div, text) {
function setAssistantBody(div, text, { live = false } = {}) {
if (!div) {
return;
}
@@ -585,7 +618,15 @@
div.appendChild(body);
}
const raw = text || '';
body.classList.add('sa-prose');
// While streaming, plain text — full HTML reformat every token reflows and shakes scroll.
if (live) {
body.classList.add('sa-prose', 'sa-prose-live');
body.classList.remove('sa-prose-rich');
body.textContent = raw;
return;
}
body.classList.add('sa-prose', 'sa-prose-rich');
body.classList.remove('sa-prose-live');
const html = formatAssistantProseHtml(raw);
if (html) {
body.innerHTML = html;
@@ -674,6 +715,56 @@
|| /смени\s+(размер|aspect|соотношен)/i.test(t);
}
function userTextMentionsControls(text) {
const t = String(text || '');
if (/\b(horny|controls?|preference[_\s-]?bias)\b/i.test(t)) {
return true;
}
return cyrTokenRe('хорни|остынь|вкус|слайдер').test(t)
|| /\/\s*(остынь|ostyn|horny-game)/i.test(t);
}
function patchLooksLikeGeneration(patch) {
if (!patch || typeof patch !== 'object') {
return false;
}
if (patch.prompt != null || patch.loras || patch.aspect != null
|| patch.width != null || patch.height != null || patch.steps != null
|| patch.cfg != null || patch.seed != null) {
return true;
}
return Array.isArray(patch.actions) && patch.actions.map(String).includes('generate');
}
/** Drop Generate-patch echo of schema defaults that would wipe a user-tuned slider. */
function filterControlPatch(incoming, patch) {
const schema = state.config?.controls || {};
const out = {};
if (!incoming || typeof incoming !== 'object') {
return out;
}
const likeGen = patchLooksLikeGeneration(patch) && !state.lastUserControlIntent;
for (const [id, raw] of Object.entries(incoming)) {
if (!schema[id]) {
continue;
}
const n = Number(raw);
if (!Number.isFinite(n)) {
continue;
}
const def = Number(schema[id]?.default);
const cur = getControlValue(id, Number.isFinite(def) ? def : n);
if (Math.abs(n - cur) < 0.0005) {
continue;
}
if (likeGen && Number.isFinite(def) && Math.abs(n - def) < 0.0005 && Math.abs(cur - def) > 0.0005) {
continue;
}
out[id] = n;
}
return out;
}
function userTextMentionsParams(text) {
const t = String(text || '');
if (parseAspectFromUserText(t)) {
@@ -1597,7 +1688,7 @@
div.className = 'sa-msg assistant sa-welcome';
div.innerHTML = WELCOME_HTML;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
scrollMessagesToBottom({ force: true });
}
function chatUid() {
@@ -2411,8 +2502,7 @@
fillEmptyParamsFromExact();
renderPersonaControls(data?.controls || {}, data?.control_values || data?.exact?.controls || {});
syncPersonaDeleteButton(data?.persona_source || data?.personas?.find((p) => p.id === id)?.source);
const settings = $('sa_settings');
if (settings && !settings.hidden) {
if (state.view === 'settings') {
if (state.settingsTab === 'user') {
refreshUserPrefs();
}
@@ -3598,17 +3688,15 @@
}
}
// Persona Exact controls (model or user patch). Ignore persona_delete.
// Persona Exact controls (model or user patch). Ignore default-echo inside Generate patches.
if (patch.controls && typeof patch.controls === 'object' && !Array.isArray(patch.controls)) {
const schema = state.config?.controls || {};
const next = { ...(state.config?.control_values || state.exact?.controls || {}) };
for (const [k, v] of Object.entries(patch.controls)) {
if (schema[k]) {
next[k] = v;
}
const filtered = filterControlPatch(patch.controls, patch);
if (Object.keys(filtered).length) {
const next = { ...(state.config?.control_values || state.exact?.controls || {}), ...filtered };
savePersonaControls(filtered);
renderPersonaControls(schema, next);
}
savePersonaControls(next);
renderPersonaControls(schema, next);
}
const acts = Array.isArray(patch.actions) ? patch.actions.map(String) : [];
@@ -3728,11 +3816,15 @@
});
}
/** Re-load chat model after park. Returns a Promise (await after Generate so the next Send is warm). */
function warmLlm() {
/** Re-load chat model into VRAM. force=true after Generate even without park — Krea often evicts Ollama. */
function warmLlm({ force = false } = {}) {
return new Promise((resolve) => {
const model = $('sa_model')?.value;
if (!model || !state.llmParked || typeof genericRequest !== 'function') {
if (!model || typeof genericRequest !== 'function') {
resolve(false);
return;
}
if (!force && !state.llmParked) {
resolve(false);
return;
}
@@ -3749,7 +3841,7 @@
}
resolve(!!ok);
};
// VL 7B cold-load can exceed a minute — don't time out the flag early.
// VL cold-load can exceed a minute — don't time out the flag early.
setTimeout(() => finish(false), 180000);
genericRequest('AssistentWarmLlm', { baseUrl, model }, () => finish(true), 0, () => finish(false));
});
@@ -3864,12 +3956,15 @@
const src = await waitForNewImage(prev);
state.generating = false;
setInterruptVisible(state.busy);
// Auto-critique / next Send load the model themselves — skip warm if critique will run.
// Krea Generate almost always evicts the VL chat weights from VRAM — even when Park LLM is off.
state.expectColdLoad = true;
const paneVisible = !!document.getElementById('swarm_assistent_root')?.offsetParent;
if (state.llmParked && state.view === 'chat' && paneVisible && !$('sa_auto_critique')?.checked) {
const willAutoCritique = !!$('sa_auto_critique')?.checked;
// Auto-critique's own chat request will cold-load; otherwise preload before the user types.
if (state.view === 'chat' && paneVisible && !willAutoCritique) {
startBusyUi('warming');
setStatus('Возвращаю LLM в GPU…');
await warmLlm();
await warmLlm({ force: true });
}
if (!state.busy) {
stopBusyUi(src ? 'Generate готов' : 'Generate завершён (новое изображение не найдено)');
@@ -4053,7 +4148,7 @@
div.appendChild(buildCivitaiCards(civitaiResults));
}
box.appendChild(div);
box.scrollTop = box.scrollHeight;
scrollMessagesToBottom({ force: true });
return div;
}
@@ -4071,13 +4166,42 @@
body.innerHTML = '<span class="sa-dots" aria-hidden="true"><i></i><i></i><i></i></span><span class="sa-typing-label">Waiting for the model…</span>';
div.appendChild(body);
box.appendChild(div);
box.scrollTop = box.scrollHeight;
scrollMessagesToBottom({ force: true });
state.streamEl = div;
state.streamMeta = meta || null;
state.streamFenceDone = false;
return div;
}
function streamHasClosedPatchFence(text) {
const t = String(text || '');
if (!/```[\s\S]*```/.test(t)) {
return false;
}
const { patch } = extractPatch(t);
return !!patch;
}
function trimToClosedPatchFence(text) {
const t = String(text || '');
const re = /```(?:json)?\s*([\s\S]*?)```/gi;
let match;
let lastEnd = -1;
while ((match = re.exec(t)) !== null) {
try {
const obj = JSON.parse(match[1].trim());
if (isPatchObject(obj) || isCardObject(obj)) {
lastEnd = match.index + match[0].length;
}
} catch (e) { /* ignore */ }
}
return lastEnd > 0 ? t.slice(0, lastEnd).trimEnd() : t;
}
function appendStreamDelta(delta) {
if (state.streamFenceDone) {
return;
}
if (!state.streamEl) {
beginStreamMessage(state.streamMeta || undefined);
}
@@ -4085,7 +4209,7 @@
if (state.streamEl.classList.contains('sa-typing')) {
state.streamEl.classList.remove('sa-typing');
state.streamText = '';
setAssistantBody(state.streamEl, '');
setAssistantBody(state.streamEl, '', { live: true });
}
state.gotDelta = true;
state.expectColdLoad = false;
@@ -4093,11 +4217,12 @@
setBusyPhase('streaming');
}
state.streamText = (state.streamText || '') + (delta || '');
setAssistantBody(state.streamEl, state.streamText);
const box = $('sa_messages');
if (box) {
box.scrollTop = box.scrollHeight;
if (streamHasClosedPatchFence(state.streamText)) {
state.streamText = trimToClosedPatchFence(state.streamText);
state.streamFenceDone = true;
}
setAssistantBody(state.streamEl, state.streamText, { live: true });
scrollMessagesToBottom();
}
}
@@ -4107,6 +4232,7 @@
state.streamEl = null;
state.streamMeta = null;
state.streamText = '';
state.streamFenceDone = false;
if (!el) {
appendMessage('assistant', fullReply, null, civitaiResults, meta || undefined);
return;
@@ -4131,10 +4257,7 @@
if (civitaiResults && civitaiResults.length) {
el.appendChild(buildCivitaiCards(civitaiResults));
}
const box = $('sa_messages');
if (box) {
box.scrollTop = box.scrollHeight;
}
scrollMessagesToBottom();
}
function buildCivitaiCards(results) {
@@ -4517,7 +4640,7 @@
if (paneW) {
document.documentElement.style.setProperty('--sa-image-width', paneW);
}
if (view === 'cards' || view === 'chat') {
if (view === 'cards' || view === 'chat' || view === 'settings') {
state.view = view;
}
const boardTab = localStorage.getItem(LS_BOARD_TAB);
@@ -4577,7 +4700,7 @@
fill(LS_PACK, ui.pack, (v) => { if ($('sa_pack')) { $('sa_pack').value = v; } });
fill(LS_PERSONA, ui.persona, (v) => { if ($('sa_persona')) { $('sa_persona').value = v; } });
fill(LS_PANE_WIDTH, ui.pane_width, (v) => document.documentElement.style.setProperty('--sa-image-width', v));
if (ui.view === 'cards' || ui.view === 'chat') {
if (ui.view === 'cards' || ui.view === 'chat' || ui.view === 'settings') {
fill(LS_VIEW, ui.view, (v) => { state.view = v; });
}
if (ui.board_tab === 'refs' || ui.board_tab === 'generate') {
@@ -4817,16 +4940,26 @@
controlsPointerDown = true;
});
const endPointer = () => {
const v = Number(input.value);
applyLocal(v);
controlsPointerDown = false;
// Discard mid-drag rebuilds that carried stale server defaults — keep local values.
if (pendingControlsRender) {
const pending = pendingControlsRender;
const schema = pendingControlsRender.schema;
pendingControlsRender = null;
renderPersonaControls(pending.schema, pending.values);
renderPersonaControls(
schema,
state.config?.control_values || state.exact?.controls || {},
);
}
if (controlSaveTimer) {
clearTimeout(controlSaveTimer);
}
controlSaveTimer = setTimeout(() => savePersonaControls({ [id]: v }), 50);
};
input.addEventListener('pointerup', endPointer);
input.addEventListener('pointercancel', endPointer);
// Live label while dragging; persist only on release (change) so mid-drag saves cannot snap back.
// Live label while dragging; also persist on change for keyboard tweaks.
input.addEventListener('input', () => {
applyLocal(Number(input.value));
});
@@ -4857,6 +4990,7 @@
setStatus('/остынь только для Leonid');
return;
}
state.lastUserControlIntent = true;
const schema = state.config?.controls || {};
if (!schema.horny) {
setStatus('У этой личности нет слайдера Хорни');
@@ -4889,6 +5023,7 @@
return;
}
const cur = getControlValue('horny', 35);
state.lastUserControlIntent = true;
await sendChat({
skipSlash: true,
skipAutoPack: true,
@@ -5549,22 +5684,6 @@
}
}
function openSettings(tab) {
const s = $('sa_settings');
if (!s) {
return;
}
s.hidden = false;
setSettingsTab(tab || state.settingsTab || 'behavior');
}
function closeSettings() {
const s = $('sa_settings');
if (s) {
s.hidden = true;
}
}
function fillKnobsFromConfig(data) {
const asst = data?.assistant || state.config?.assistant || {};
const exact = data?.exact || state.config?.exact || state.exact || {};
@@ -6133,26 +6252,51 @@
}
function setView(view) {
state.view = view === 'cards' ? 'cards' : 'chat';
if (view === 'cards') {
state.view = 'cards';
} else if (view === 'settings') {
state.view = 'settings';
} else {
state.view = 'chat';
}
const chat = $('sa_view_chat');
const cards = $('sa_view_cards');
const settings = $('sa_view_settings');
if (chat) {
chat.hidden = state.view !== 'chat';
}
if (cards) {
cards.hidden = state.view !== 'cards';
}
if (settings) {
settings.hidden = state.view !== 'settings';
}
$('sa_tab_chat')?.classList.toggle('sa-subtab-active', state.view === 'chat');
$('sa_tab_cards')?.classList.toggle('sa-subtab-active', state.view === 'cards');
$('sa_tab_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
$('sa_btn_settings')?.classList.toggle('sa-subtab-active', state.view === 'settings');
saveSettings();
if (state.view === 'cards') {
renderCardsList();
} else if (state.llmParked && !state.generating) {
// Back in the chat — bring the model home before the user hits Send.
warmLlm();
} else if (state.view === 'settings') {
setSettingsTab(state.settingsTab || 'behavior');
} else if ((state.llmParked || state.expectColdLoad) && !state.generating) {
// Back in the chat — bring the model home (Krea may have evicted it).
warmLlm({ force: true });
}
}
function openSettings(tab) {
if (tab) {
state.settingsTab = tab;
}
setView('settings');
}
function closeSettings() {
setView('chat');
}
function refreshPersonas() {
loadConfig($('sa_persona')?.value || localStorage.getItem(LS_PERSONA) || 'neutral', (data) => {
if (data?.personas) {
@@ -7050,7 +7194,7 @@
div.className = 'sa-msg assistant sa-system-note';
div.textContent = text;
box.appendChild(div);
box.scrollTop = box.scrollHeight;
scrollMessagesToBottom();
}
function clipDebug(s, max) {
@@ -7423,6 +7567,7 @@
}
if (!opts.fromAutoCritique && !opts.fromDownload && !opts.fromVisionHop && !opts.fromDebug) {
state.lastUserParamIntent = userTextMentionsParams(text);
state.lastUserControlIntent = userTextMentionsControls(text);
state.pendingSilentGen = userAsksGenerate(text) || isSameButAspectRequest(text);
}
@@ -7491,13 +7636,21 @@
const chatEpoch = bumpChatEpoch();
state.busy = true;
// This request will keep_alive 15m; clear parked only after we know load started.
// Keep expectColdLoad so the UI can show a real GPU-load message if we just parked.
if (!state.llmParked) {
state.expectColdLoad = false;
}
// If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here.
state.llmParked = false;
setInterruptVisible(true);
if (state.expectColdLoad && !opts.fromVisionHop && !opts.fromAutoCritique) {
startBusyUi('warming');
setStatus('Возвращаю LLM в GPU…');
try {
await warmLlm({ force: true });
} catch (e) {
console.warn('Assistent warm before send', e);
}
if (chatEpoch !== state.chatEpoch) {
return;
}
}
startBusyUi(state.expectColdLoad ? 'loading' : 'thinking');
saveSettings();
@@ -8015,6 +8168,7 @@
$('sa_tab_chat')?.addEventListener('click', () => setView('chat'));
$('sa_tab_cards')?.addEventListener('click', () => setView('cards'));
$('sa_tab_settings')?.addEventListener('click', () => openSettings(state.settingsTab || 'behavior'));
$('sa_board_tab_gen')?.addEventListener('click', () => setBoardTab('generate'));
$('sa_board_tab_refs')?.addEventListener('click', () => setBoardTab('refs'));
$('sa_persona')?.addEventListener('change', onPersonaChanged);
@@ -8025,15 +8179,11 @@
$('sa_btn_card_generate')?.addEventListener('click', () => generateCardWithAssistent());
$('sa_btn_card_save')?.addEventListener('click', () => saveCurrentCard());
$('sa_btn_card_wanted')?.addEventListener('click', () => enqueueWantedOnly());
$('sa_btn_settings')?.addEventListener('click', () => {
const s = $('sa_settings');
if (s) {
if (s.hidden) {
openSettings(state.settingsTab || 'behavior');
} else {
closeSettings();
}
if (state.view === 'settings') {
closeSettings();
} else {
openSettings(state.settingsTab || 'behavior');
}
});
$('sa_settings_close')?.addEventListener('click', () => closeSettings());
@@ -8105,8 +8255,7 @@
return;
}
let closed = false;
const settings = $('sa_settings');
if (settings && !settings.hidden) {
if (state.view === 'settings') {
closeSettings();
closed = true;
}