`;
- html += `
GGUF `;
- html += `
`;
- html += `
`;
+ html += `
`;
+ html += `GGUF `;
+ html += ` `;
+ html += ` `;
html += `
`;
// Ollama-library browse used to live here as its own collapsible dropdown,
// but that duplicated the Engine filter (which already has Ollama). The
@@ -3047,6 +3071,56 @@ export function isVisible() {
return !modal.classList.contains('hidden');
}
+let _sharedSyncInFlight = false;
+let _sharedSyncLast = 0;
+async function _refreshSharedCookbookState(reason = '') {
+ if (!isVisible() || _sharedSyncInFlight) return;
+ const now = Date.now();
+ if (now - _sharedSyncLast < 1500) return;
+ _sharedSyncInFlight = true;
+ _sharedSyncLast = now;
+ try {
+ const ok = await _syncFromServer();
+ if (!ok) return;
+ try { Object.assign(_envState, _readStoredEnvState()); } catch {}
+ const modal = document.getElementById('cookbook-modal');
+ const activeTab = modal?.querySelector('.cookbook-tab.active')?.dataset?.backend || '';
+ if (activeTab === 'Running') {
+ _renderRunningTab();
+ } else if (activeTab === 'Settings') {
+ const active = document.activeElement;
+ const editingSettings = active && active.closest && active.closest('.cookbook-settings-stack');
+ if (!editingSettings) {
+ _renderRecipes();
+ const tab = document.querySelector('#cookbook-modal .cookbook-tab[data-backend="Settings"]');
+ if (tab) tab.click();
+ }
+ }
+ } catch (e) {
+ console.warn('[cookbook] shared state refresh failed', reason, e);
+ } finally {
+ _sharedSyncInFlight = false;
+ }
+}
+
+document.addEventListener('cookbook:state-synced', () => {
+ try { Object.assign(_envState, _readStoredEnvState()); } catch {}
+ if (isVisible()) {
+ const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || '';
+ if (activeTab === 'Running') _renderRunningTab();
+ }
+});
+
+window.addEventListener('focus', () => { _refreshSharedCookbookState('focus'); });
+document.addEventListener('visibilitychange', () => {
+ if (document.visibilityState === 'visible') _refreshSharedCookbookState('visible');
+});
+setInterval(() => {
+ if (!isVisible()) return;
+ const activeTab = document.querySelector('#cookbook-modal .cookbook-tab.active')?.dataset?.backend || '';
+ if (activeTab === 'Running') _refreshSharedCookbookState('active-poll');
+}, 5000);
+
// Close button
document.addEventListener('DOMContentLoaded', () => {
const closeBtn = document.getElementById('close-cookbook-modal');
diff --git a/static/js/cookbookDownload.js b/static/js/cookbookDownload.js
index 6ea07cc85..295189c28 100644
--- a/static/js/cookbookDownload.js
+++ b/static/js/cookbookDownload.js
@@ -85,6 +85,22 @@ function _ggufIncludePattern(model, source) {
return '*.gguf';
}
+function _ggufDisplayPartFromInclude(include) {
+ const clean = String(include || '').replace(/\*/g, '');
+ const parts = clean.split('/').filter(Boolean);
+ const file = parts[parts.length - 1] || clean;
+ const dir = parts.length > 1 ? parts[parts.length - 2] : '';
+ const quant = `${dir} ${file}`.match(/\b(?:UD-)?(?:IQ[1-8]_[A-Z0-9]+|Q[2-8]_K_[MLS]|Q[2-8]_[0-9A-Z]+|Q[2-8])\b/i);
+ if (quant) return quant[0].toUpperCase().replace(/^UD-/, '');
+ return file.replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/i, '');
+}
+
+function _downloadTaskName(shortName, payload) {
+ const include = payload?.include || '';
+ const part = include ? _ggufDisplayPartFromInclude(include) : '';
+ return part ? `${shortName} · ${part}` : shortName;
+}
+
function _missingGgufMessage(model) {
const name = model?.name || 'this model';
if (/\bnvfp4\b/i.test(name)) {
@@ -519,6 +535,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
}
const shortName = (model.name || repo).split('/').pop();
+ const taskName = _downloadTaskName(shortName, payload);
const targetHost = host || 'local';
const tasks = _loadTasks();
@@ -576,7 +593,7 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
if (activeOnHost) {
const queueId = `queue-${Date.now().toString(36)}`;
const allTasks = _loadTasks();
- allTasks.push({ id: queueId, sessionId: queueId, name: shortName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
+ allTasks.push({ id: queueId, sessionId: queueId, name: taskName, type: 'download', status: 'queued', output: '', ts: Date.now(), payload, remoteHost: host });
_saveTasks(allTasks);
_renderRunningTab();
uiModule.showToast(`Queued ${shortName} — waiting for current download`);
@@ -601,8 +618,8 @@ export async function _runModelDownload(panel, model, backend, hostOverride) {
uiModule.showToast('Download failed: ' + (data.error || ''), 9000);
return;
}
- _addTask(data.session_id, shortName, 'download', payload);
- uiModule.showToast(`Downloading ${shortName}...`);
+ _addTask(data.session_id, taskName, 'download', payload);
+ uiModule.showToast(`Downloading ${taskName}...`);
} catch (e) {
uiModule.showToast('Download failed: ' + e.message, 9000);
}
diff --git a/static/js/cookbookRunning.js b/static/js/cookbookRunning.js
index fae84ce68..9fed23a8a 100644
--- a/static/js/cookbookRunning.js
+++ b/static/js/cookbookRunning.js
@@ -38,6 +38,47 @@ function _taskBadge(task) {
return { text: _statusLabel(task.status, task.type), cls: 'cookbook-task-' + task.status };
}
+function _ggufDisplayPartFromPath(path) {
+ const parts = String(path || '').split('/').filter(Boolean);
+ const file = parts[parts.length - 1] || '';
+ const dir = parts.length > 1 ? parts[parts.length - 2] : '';
+ const text = `${dir} ${file}`;
+ const quant = text.match(/\b(?:UD-)?(?:IQ[1-8]_[A-Z0-9]+|Q[2-8]_K_[MLS]|Q[2-8]_[0-9A-Z]+|Q[2-8])\b/i);
+ if (quant) return quant[0].toUpperCase().replace(/^UD-/, '');
+ return file.replace(/\.gguf$/i, '').replace(/-\d{5}-of-\d{5}$/i, '');
+}
+
+function _downloadDisplayName(name, task) {
+ const include = task?.payload?.include || '';
+ if (!include || String(name || '').includes(' · ')) return name;
+ const part = _ggufDisplayPartFromPath(include.replace(/\*/g, ''));
+ return part ? `${name} · ${part}` : name;
+}
+
+function _taskDisplayName(task) {
+ const name = String(task?.name || '').trim();
+ if (task?.type === 'download') return _downloadDisplayName(name, task);
+ if (task?.type !== 'serve') return name;
+ const gguf = task?.payload?._fields?.gguf_file || task?.payload?.gguf_file || '';
+ if (!gguf || name.includes(' · ')) return name;
+ const part = _ggufDisplayPartFromPath(gguf);
+ return part ? `${name} · ${part}` : name;
+}
+
+function _canLaunchDownloadedTask(task) {
+ return task?.type === 'download' && ['done', 'completed'].includes(task.status || '') && !!(task.payload?.repo_id || task.name);
+}
+
+function _downloadServeFields(task) {
+ const include = String(task?.payload?.include || '').trim();
+ if (!include) return null;
+ return {
+ backend: 'llamacpp',
+ _forceBackend: true,
+ _preferredGgufInclude: include,
+ };
+}
+
// A download task whose tmux output still shows an active per-shard line
// (e.g. "model-00012-of-00082.safetensors: 56%|") is NOT actually finished —
// the cookbook just lost track. The clear pill becomes a "reconnect" affordance
@@ -282,6 +323,40 @@ let _detectToolParser;
let _detectModelOptimizations;
let _buildServeCmd;
+function _taskServerSelection(task) {
+ const host = task?.remoteHost || task?.payload?.remote_host || '';
+ const savedKey = task?.remoteServerKey || task?.payload?.remote_server_key || '';
+ const server = (savedKey ? _serverByVal(savedKey) : null)
+ || (host ? _serverByVal(host) : null)
+ || (host ? _envState.servers.find(s => s.host === host) : null)
+ || null;
+ const key = server ? (_serverKey ? _serverKey(server) : savedKey) : (savedKey || (host || 'local'));
+ return { host, server, key };
+}
+
+function _selectTaskServer(task) {
+ const { host, server, key } = _taskServerSelection(task);
+ _envState.remoteHost = host;
+ _envState.remoteServerKey = key === 'local' ? '' : key;
+ if (server) {
+ _envState.env = server.env || 'none';
+ _envState.envPath = server.envPath || '';
+ _envState.platform = server.platform || '';
+ } else if (!host) {
+ _envState.env = 'none';
+ _envState.envPath = '';
+ _envState.platform = '';
+ }
+ document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
+ if (!sel || sel.tagName !== 'SELECT') return;
+ const wanted = key || (host || 'local');
+ if ([...sel.options].some(o => o.value === wanted)) sel.value = wanted;
+ else if (host && [...sel.options].some(o => o.value === host)) sel.value = host;
+ else sel.value = host ? wanted : 'local';
+ });
+ return { host, server, key };
+}
+
// When a new action is started (download / dependency / serve), this holds the
// new task's id so the next render collapses every other card and leaves only
// the new one open. Consumed (cleared) by _renderRunningTab.
@@ -654,16 +729,31 @@ function _loadPrunedTasks() {
const _REMOVED_KEY = 'cookbook-removed-tasks';
const _TOMBSTONE_TTL_MS = 24 * 3600 * 1000;
function _loadTombstones() {
- try { return JSON.parse(localStorage.getItem(_REMOVED_KEY)) || {}; }
+ try {
+ const tomb = JSON.parse(localStorage.getItem(_REMOVED_KEY)) || {};
+ const now = Date.now();
+ let changed = false;
+ for (const k in tomb) {
+ if (now - tomb[k] > _TOMBSTONE_TTL_MS) {
+ delete tomb[k];
+ changed = true;
+ }
+ }
+ if (changed) localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb));
+ return tomb;
+ }
catch { return {}; }
}
+function _saveTombstones(tomb) {
+ localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb || {}));
+}
function _tombstoneTask(id) {
if (!id) return;
const tomb = _loadTombstones();
const now = Date.now();
tomb[id] = now;
for (const k in tomb) { if (now - tomb[k] > _TOMBSTONE_TTL_MS) delete tomb[k]; }
- localStorage.setItem(_REMOVED_KEY, JSON.stringify(tomb));
+ _saveTombstones(tomb);
}
function _isTombstoned(id) {
const ts = _loadTombstones()[id];
@@ -1098,6 +1188,7 @@ function _syncToServer() {
if (!_envState || !Array.isArray(_envState.servers) || _envState.servers.length === 0) return;
const state = {
tasks: _loadTasks(),
+ removedTasks: _loadTombstones(),
presets: _loadPresets(),
env: _envState,
serveState: null,
@@ -1146,9 +1237,16 @@ export async function _syncFromServer() {
const localTasks = _loadTasks();
const serverTasks = state.tasks || [];
+ const serverTombstones = (state.removedTasks && typeof state.removedTasks === 'object') ? state.removedTasks : {};
+ const localTombstones = _loadTombstones();
+ const mergedTombstones = { ...serverTombstones, ...localTombstones };
+ for (const [id, ts] of Object.entries(serverTombstones)) {
+ if (localTombstones[id] == null || Number(ts) > Number(localTombstones[id])) mergedTombstones[id] = ts;
+ }
+ _saveTombstones(mergedTombstones);
const localIds = new Set(localTasks.map(t => t.sessionId));
- const merged = [...localTasks];
+ const merged = localTasks.filter(t => !_isTombstoned(t.sessionId));
for (const t of serverTasks) {
if (!localIds.has(t.sessionId) && !_isTombstoned(t.sessionId)) {
merged.push(t);
@@ -1165,6 +1263,18 @@ export async function _syncFromServer() {
const { remoteHost: _rh, env: _e, envPath: _ep, platform: _pf, ...settings } = state.env;
delete settings.hfToken;
Object.assign(_envState, settings);
+ const selected = (_envState.remoteServerKey && _serverByVal?.(_envState.remoteServerKey))
+ || (_envState.remoteHost ? (_envState.servers || []).find(s => s.host === _envState.remoteHost) : null);
+ if (selected) {
+ _envState.env = selected.env || 'none';
+ _envState.envPath = selected.envPath || '';
+ _envState.platform = selected.platform || '';
+ } else if (!_envState.remoteHost) {
+ const local = (_envState.servers || []).find(s => !s.host || s.host === 'local');
+ _envState.env = local?.env || 'none';
+ _envState.envPath = local?.envPath || '';
+ _envState.platform = local?.platform || '';
+ }
const { hfToken, ...safeState } = _envState;
localStorage.setItem('cookbook-last-state', JSON.stringify(safeState));
}
@@ -1174,6 +1284,7 @@ export async function _syncFromServer() {
if (state.serveState) {
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify(state.serveState));
}
+ document.dispatchEvent(new CustomEvent('cookbook:state-synced', { detail: state }));
return true;
} catch { return false; }
}
@@ -1332,17 +1443,11 @@ async function _openServeEditForTask(task, cmdOverride, fieldOverrides = null) {
if (fieldOverrides && typeof fieldOverrides === 'object') {
fields = { ...(fields || {}), ...fieldOverrides };
}
- // Switch the active server to the one this serve ran on (mirrors _openEdit).
- const _tHost = task.remoteHost || '';
- _envState.remoteHost = _tHost;
- const _tSrv = _serverByVal(_envState.remoteServerKey || _tHost)
- || _envState.servers.find(s => s.host === _tHost);
- if (_tSrv) { _envState.env = _tSrv.env || 'none'; _envState.envPath = _tSrv.envPath || ''; _envState.platform = _tSrv.platform || ''; }
- else if (!_tHost) { _envState.env = 'none'; _envState.envPath = ''; _envState.platform = ''; }
- document.querySelectorAll('#hwfit-server-select, #hwfit-dl-server, #hwfit-cache-server, #hwfit-deps-server').forEach(sel => {
- if (!sel || sel.tagName !== 'SELECT') return;
- sel.value = _tHost || 'local';
- });
+ fields = { ...(fields || {}), _replaceTaskId: task.sessionId };
+ // Switch the active server to the exact profile this serve ran on. The
+ // dropdown stores stable srv: keys, not raw host strings, so preserving only
+ // task.remoteHost can relaunch against the local container by accident.
+ _selectTaskServer(task);
try {
const { openServePanelForRepo } = await import('./cookbookServe.js');
await openServePanelForRepo(repo, fields);
@@ -1553,6 +1658,20 @@ export async function _launchServeTask(shortName, repo, cmd, fields, hostOverrid
const _serverMetaKey = _targetKey || (_hsrv && _serverKey ? _serverKey(_hsrv) : '') || (_host || 'local');
const _serverMetaName = targetMeta?.serverName || _hsrv.name || (_host ? _host : 'Local');
const _hplatform = _host ? (_hsrv.platform || '') : (_envState.platform || '');
+ const _replaceTaskId = fields?._replaceTaskId || '';
+ if (_replaceTaskId) {
+ try {
+ const _old = _loadTasks().find(t => t.sessionId === _replaceTaskId);
+ if (_old && _old.type === 'serve') {
+ await fetch('/api/shell/exec', {
+ method: 'POST', credentials: 'same-origin',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ command: _tmuxGracefulKill(_old) }),
+ });
+ _removeTask(_old.sessionId);
+ }
+ } catch {}
+ }
// Replace any serve already targeting this same host:port — you can't run two
// servers on one port, so re-serving (or retrying) should stop & remove the
@@ -1750,7 +1869,7 @@ export function _renderRunningTab() {
'
' +
'
Active ' + activeCount + ' ' +
'' +
- '
Active downloads and serving processes.
' +
+ '
Active downloads, installs and model launches.
' +
'
';
const firstGroup = body.querySelector('.cookbook-group');
if (firstGroup) body.insertBefore(group, firstGroup);
@@ -1863,6 +1982,7 @@ export function _renderRunningTab() {
return;
}
if (!await window.styledConfirm(`Clear ${toRemove.length} finished task${toRemove.length === 1 ? '' : 's'} on ${_serverName(host)}?`, { confirmText: 'Clear' })) return;
+ toRemove.forEach(t => _tombstoneTask(t.sessionId));
const remaining = allTasks.filter(t => _taskServerKey(t) !== host || !_canClearTask(t));
_saveTasks(remaining);
// Fade/slide each finished card out (same exit as the per-card clear)
@@ -2000,11 +2120,12 @@ export function _renderRunningTab() {
const _bdg = _taskBadge(task);
const _bdgTitle = (task._unreachable && task.status === 'running') ? ' title="Server not responding — it may have crashed"' : '';
+ const displayName = _taskDisplayName(task);
el.innerHTML = `
`;
const _bk = _detectBackend(m).backend;
@@ -962,6 +1080,11 @@ function _rerenderCachedModels() {
const _isMiniMaxM3 = _isMiniMaxM3Model({ ...m, repo_id: repo });
const _isMiniMaxM2 = _isMiniMaxM2Model({ ...m, repo_id: repo });
const _isMiniMaxMSeries = _isMiniMaxM3 || _isMiniMaxM2;
+ const _toolParserDefault = _detectToolParser(repo);
+ const _isStepFunStep = _toolParserDefault === 'step3p5';
+ const _nativeToolDefault = _isMiniMaxMSeries || _isStepFunStep;
+ const _reasoningDefault = _isMiniMaxMSeries || _isStepFunStep;
+ const _expertParallelDefault = _isMiniMaxMSeries || _isStepFunStep;
const svm = (k, def) => (_modelSs && _hasOwn(_modelSs, k)) ? _modelSs[k] : def;
const _serveTarget = _selectedServeTarget();
const _backendChoices = _backendChoicesForTarget(_serveTarget);
@@ -993,8 +1116,15 @@ function _rerenderCachedModels() {
const _l = (name, tip) => ``;
+ const _replaceTaskId = String(sv('_replaceTaskId', '') || '');
+ if (_replaceTaskId) {
+ panelHtml += `
`;
+ }
// Runtime-readiness note pinned at the top of the serve area so the
// user sees "vLLM ready on …" before scrolling into the configure
// form. Hidden until the readiness probe returns. The × button
@@ -1202,20 +1336,20 @@ function _rerenderCachedModels() {
const _rp_name = _rp_flag ? _rp_flag.split(' ')[1] : '';
panelHtml += `
`;
panelHtml += `
Trust Remote Code${_h('Allow model to run custom code from HuggingFace')}`;
- panelHtml += `
Auto Tool Choice${_h('Enable function/tool calling for agent mode')}`;
+ panelHtml += `
Auto Tool Choice${_h('Enable function/tool calling for agent mode')}`;
// Always-render the Reasoning Parser, Expert Parallel, and MoE Env
// checkboxes — the model-family detection above is a hint, not a
// hard gate. User asked to keep these visible regardless so that
// a borderline-undetected MoE/reasoning model can still toggle
// them without dropping back to the raw command box.
- panelHtml += `
Reasoning Parser${_rp_name ? ` ${_rp_name} ` : ''}${_h('Splits tokens into a separate channel. The tag (when shown) is the auto-detected parser; edit the command if you need a different one.')} `;
+ panelHtml += `
Reasoning Parser${_rp_name ? ` ${_rp_name} ` : ''}${_h('Splits tokens into a separate channel. The tag (when shown) is the auto-detected parser; edit the command if you need a different one.')} `;
panelHtml += `
Enforce Eager${_h('Disable CUDA graphs. Slower but uses less memory')}`;
panelHtml += `
Prefix Caching${_h('Cache shared prompt prefixes across requests')}`;
// Inline the previously-second vLLM checks row so Expert Parallel /
// Speculative / MoE Env sit next to Prefix Caching with no gap. All
// three are vLLM-only — class-gated so they hide on SGLang. Always
// render so the user can flip them on for any MoE model.
- panelHtml += `
Expert Parallel${_h('MoE: shard expert layers across GPUs. Helps for MiniMax M-series, Qwen3 A3B/A10B/A22B MoE, DeepSeek V3+/R1. Ignored / wasteful on dense models.')}`;
+ panelHtml += `
Expert Parallel${_h('MoE: shard expert layers across GPUs. Helps for MiniMax M-series, StepFun Step-3, Qwen3 A3B/A10B/A22B MoE, DeepSeek V3+/R1. Ignored / wasteful on dense models.')}`;
panelHtml += `
Language Model Only${_h('vLLM --language-model-only. Needed by MiniMax M3 text serving when the repo also contains VL components.')}`;
panelHtml += `
Disable Custom All Reduce${_h('vLLM --disable-custom-all-reduce. Useful for some 8-GPU/nightly configurations.')}`;
{
@@ -2870,11 +3004,11 @@ function _rerenderCachedModels() {
// preflight and let the launch silently fall to CPU.
let _hwGpus = [];
try {
- const _gh = (_selectedServeTarget.host || '').trim();
+ const _gh = (launchTarget.host || '').trim();
const _gp = new URLSearchParams();
if (_gh) {
_gp.set('host', _gh);
- const _sp = (_serverByVal?.(_selectedServeTarget.serverKey || _gh) || {}).port;
+ const _sp = (_serverByVal?.(launchTarget.serverKey || _gh) || {}).port;
if (_sp) _gp.set('ssh_port', _sp);
}
const _gr = await fetch('/api/cookbook/gpus' + (_gp.toString() ? '?' + _gp : ''), { credentials: 'same-origin' });
@@ -3069,6 +3203,7 @@ function _rerenderCachedModels() {
try { cur = JSON.parse(localStorage.getItem(SERVE_STATE_KEY)) || {}; } catch {}
const byRepo = (cur && cur._byRepo && typeof cur._byRepo === 'object') ? cur._byRepo : {};
const _saved = { ...serveState, _forceBackend: true };
+ delete _saved._replaceTaskId;
byRepo[repo] = _saved;
localStorage.setItem(SERVE_STATE_KEY, JSON.stringify({ _byRepo: byRepo, _lastUsed: _saved }));
} catch {}
@@ -3127,7 +3262,8 @@ function _rerenderCachedModels() {
await _withSpinner(_launchBtn, async () => {
// Pass the exact form values so the running task can be re-opened
// in the Serve panel pre-filled with these settings (Edit button).
- await _launchServeTask(shortName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName });
+ const taskDisplayName = _serveTaskDisplayName(shortName, m, serveState);
+ await _launchServeTask(taskDisplayName, repo, launchCmd, serveState, serveHost, { serverKey: serveServerKey, serverName: serveServerName });
});
} finally {
_envState.env = origEnv;
@@ -3188,7 +3324,6 @@ function _resolveCacheHost() {
}
async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = null) {
- if (!skipConfirm && !(await uiModule.styledConfirm(`Delete ${repo} from cache?`, { confirmText: 'Delete', danger: true }))) return;
const m = model || _cachedAllModels.find(x => x.repo_id === repo);
// Delete the EXACT on-disk path the scan reported. Models in a custom
// model dir live at
/; HF-cache models at
@@ -3204,13 +3339,32 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
} else {
target = `~/.cache/huggingface/hub/models--${repo.replace(/\//g, '--')}`;
}
+ let deleteChoice = { mode: 'repo' };
+ const ggufFiles = _ggufFilesForModel(m);
+ if (!skipConfirm) {
+ if (ggufFiles.length > 1) {
+ deleteChoice = await _ggufDeleteChoice(repo, ggufFiles);
+ if (!deleteChoice) return;
+ } else if (!(await uiModule.styledConfirm(`Delete ${repo} from cache?`, { confirmText: 'Delete', danger: true }))) {
+ return;
+ }
+ }
const host = _resolveCacheHost();
let cmd;
if (_isWindows()) {
const winTarget = target.startsWith('~')
? target.replace(/^~/, '$env:USERPROFILE').replace(/\//g, '\\')
: target.replace(/\//g, '\\');
- cmd = `Remove-Item -Recurse -Force "${winTarget}" -ErrorAction SilentlyContinue`;
+ if (deleteChoice.mode === 'files') {
+ const targets = deleteChoice.files
+ .map(f => _safeGgufRelPath(f.rel_path))
+ .filter(Boolean)
+ .map(rel => `${winTarget}\\${rel.replace(/\//g, '\\')}`);
+ if (!targets.length) return;
+ cmd = targets.map(p => `Remove-Item -Force "${p.replace(/"/g, '\\"')}" -ErrorAction SilentlyContinue`).join('; ');
+ } else {
+ cmd = `Remove-Item -Recurse -Force "${winTarget}" -ErrorAction SilentlyContinue`;
+ }
if (host) {
const pf = _sshPrefix(_getPort(host));
cmd = `ssh ${pf}${host} "powershell -Command \\"${cmd}\\""`;
@@ -3219,7 +3373,16 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
// $HOME expands inside double quotes; ~ would not, so normalize the
// fallback. Quoting also handles spaces in custom model-dir paths.
const unixTarget = target.startsWith('~') ? target.replace(/^~/, '$HOME') : target;
- cmd = `rm -rf "${unixTarget}"`;
+ if (deleteChoice.mode === 'files') {
+ const targets = deleteChoice.files
+ .map(f => _safeGgufRelPath(f.rel_path))
+ .filter(Boolean)
+ .map(rel => `${target.replace(/\/+$/, '')}/${rel}`);
+ if (!targets.length) return;
+ cmd = `rm -f ${targets.map(p => _shellPathExpr(p)).join(' ')} && find ${_shellPathExpr(target)} -type d -empty -delete`;
+ } else {
+ cmd = `rm -rf "${unixTarget}"`;
+ }
if (host) cmd = _sshCmd(host, cmd, _getPort(host));
}
// Deleting a large model (tens/hundreds of GB) can take a while, especially
@@ -3244,7 +3407,13 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
body: JSON.stringify({ command: cmd }),
});
if (!res.ok) { uiModule.showError(`Delete failed (${res.status})`); return; }
- if (itemEl) {
+ if (deleteChoice.mode === 'files') {
+ if (m && Array.isArray(m.gguf_files)) {
+ const removed = new Set(deleteChoice.files.map(f => _safeGgufRelPath(f.rel_path)));
+ m.gguf_files = m.gguf_files.filter(f => !removed.has(_safeGgufRelPath(f.rel_path)));
+ }
+ await _fetchCachedModels(false);
+ } else if (itemEl) {
itemEl.querySelector('.cookbook-delete-overlay')?.remove();
itemEl.style.transition = 'opacity 0.24s ease, transform 0.24s ease, max-height 0.28s ease, padding 0.28s ease, margin 0.28s ease';
itemEl.style.maxHeight = `${Math.max(itemEl.getBoundingClientRect().height, itemEl.scrollHeight)}px`;
@@ -3258,9 +3427,9 @@ async function _deleteCachedModel(repo, itemEl, skipConfirm = false, model = nul
requestAnimationFrame(() => { itemEl.style.maxHeight = '0'; });
await new Promise(resolve => setTimeout(resolve, 300));
if (itemEl.parentElement) itemEl.remove();
+ // Drop from the in-memory list so a re-render/filter doesn't resurrect it.
+ _cachedAllModels = _cachedAllModels.filter(x => x.repo_id !== repo);
}
- // Drop from the in-memory list so a re-render/filter doesn't resurrect it.
- _cachedAllModels = _cachedAllModels.filter(x => x.repo_id !== repo);
} catch (e) {
uiModule.showError('Delete failed: ' + (e && e.message ? e.message : e));
} finally {
diff --git a/static/js/modelPicker.js b/static/js/modelPicker.js
index 5538bf278..12fb3479e 100644
--- a/static/js/modelPicker.js
+++ b/static/js/modelPicker.js
@@ -77,6 +77,7 @@ function _handlePickerKeydown(e, listEl, itemSelector, closeFn) {
// Dependencies injected via initModelPicker()
let _deps = null;
let _autoSelectingDefault = false;
+let _defaultChatPickInFlight = false;
function _modelExists(modelId, url) {
if (!modelId || !window.modelsModule || !window.modelsModule.getCachedItems) return false;
@@ -91,6 +92,43 @@ function _modelExists(modelId, url) {
});
}
+async function _ensureDefaultPendingChat() {
+ if (!_deps || _defaultChatPickInFlight) return;
+ if (_deps.getCurrentSessionId && _deps.getCurrentSessionId()) return;
+ const pending = _deps.getPendingChat && _deps.getPendingChat();
+ if (pending && pending.modelId) return;
+ _defaultChatPickInFlight = true;
+ try {
+ let dc = null;
+ try {
+ const res = await fetch(`${API_BASE}/api/default-chat`, { credentials: 'same-origin' });
+ if (res.ok) dc = await res.json();
+ } catch (_) {}
+ if (dc && dc.endpoint_url && dc.model) {
+ _deps.setPendingChat({
+ url: dc.endpoint_url,
+ modelId: dc.model,
+ endpointId: dc.endpoint_id || '',
+ });
+ try { window.__odysseusDefaultChat = dc; } catch (_) {}
+ updateModelPicker();
+ return;
+ }
+ // No configured default: preserve the old convenience fallback.
+ if (window.modelsModule && window.modelsModule.getCachedItems) {
+ const items = window.modelsModule.getCachedItems();
+ const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
+ if (first) {
+ const models = (first.models || []).concat(first.models_extra || []);
+ _deps.setPendingChat({ url: first.url, modelId: models[0], endpointId: first.endpoint_id });
+ updateModelPicker();
+ }
+ }
+ } finally {
+ _defaultChatPickInFlight = false;
+ }
+}
+
/**
* Initialize the model picker dropdown.
* @param {Object} deps
@@ -710,25 +748,7 @@ export function updateModelPicker() {
}
}
if (!modelId && !_autoSelectingDefault && window.modelsModule && window.modelsModule.getCachedItems) {
- const items = window.modelsModule.getCachedItems();
- const first = items.find(item => !item.offline && ((item.models || []).length || (item.models_extra || []).length));
- if (first) {
- const models = (first.models || []).concat(first.models_extra || []);
- modelId = models[0];
- if (!currentSessionId) {
- _deps.setPendingChat({ url: first.url, modelId, endpointId: first.endpoint_id });
- } else {
- if (s) { s.model = modelId; s.endpoint_url = first.url; }
- _autoSelectingDefault = true;
- const fd = new FormData();
- fd.append('model', modelId);
- fd.append('endpoint_url', first.url || '');
- if (first.endpoint_id) fd.append('endpoint_id', first.endpoint_id);
- fetch(`${API_BASE}/api/session/${currentSessionId}`, { method: 'PATCH', body: fd })
- .catch(() => {})
- .finally(() => { _autoSelectingDefault = false; });
- }
- }
+ _ensureDefaultPendingChat();
}
const displayName = modelId ? modelId.split('/').pop() : 'Select model';
diff --git a/static/js/notes.js b/static/js/notes.js
index fa754b771..9758f3608 100644
--- a/static/js/notes.js
+++ b/static/js/notes.js
@@ -1896,10 +1896,6 @@ function _renderNotes() {
${_hasItems(note) ? `
` : ''}
${reminderTagHtml}
${noteTags.length ? `${noteTags.map(t => `#${_esc(t)} `).join(' ')}
` : ''}
- ${note.agent_session_id ? `
-
- Agent
- ` : ''}
${colorDots}
@@ -2304,16 +2300,6 @@ function _bindCardEvents(body) {
_openNoteCornerMenu(btn);
});
});
- // Agent tag — opens the chat session the agent ran for this note.
- body.querySelectorAll('.note-agent-tag').forEach(tag => {
- tag.addEventListener('click', (e) => {
- e.preventDefault();
- e.stopPropagation();
- const sid = tag.dataset.sessionId;
- const _sm = window.sessionModule;
- if (sid && _sm && _sm.selectSession) { closePanel(); _sm.selectSession(sid); }
- });
- });
body.querySelectorAll('.note-card-label-chip').forEach(chip => {
chip.addEventListener('click', (e) => {
e.preventDefault();
@@ -4383,18 +4369,16 @@ function _openTodoAgentMenu(btn) {
const noteId = btn.dataset.noteId;
const idx = parseInt(btn.dataset.idx);
const sid = btn.dataset.sessionId || '';
- const title = btn.dataset.agentTitle || 'Agent chat';
const menu = document.createElement('div');
menu.className = 'note-corner-menu-dropdown note-agent-item-menu';
menu.innerHTML = `
-
${_esc(title)}
${sid ? `
- Open this agent chat
+ Open
` : ''}
- ${sid ? 'Run again for this todo' : 'Start agent for this todo'}
+ ${sid ? 'Run again' : 'Run Agent'}
`;
_positionNoteMenu(menu, btn);
const openBtn = menu.querySelector('[data-act="open"]');
diff --git a/static/style.css b/static/style.css
index 44ef5e6c9..97dec8b08 100644
--- a/static/style.css
+++ b/static/style.css
@@ -5324,6 +5324,84 @@ body.bg-pattern-sparkles {
.confirm-btn-primary:hover { filter:brightness(1.15); }
.confirm-btn-danger { background:var(--color-danger); color:#fff; border-color:transparent; }
.confirm-btn-danger:hover { background:var(--color-error); }
+ #cookbook-gguf-delete-overlay {
+ background:rgba(0,0,0,0.5);
+ backdrop-filter:blur(4px);
+ pointer-events:auto !important;
+ z-index:99999 !important;
+ position:fixed !important;
+ inset:0 !important;
+ }
+ .cookbook-gguf-delete-box {
+ width:560px;
+ max-width:92vw;
+ }
+ .cookbook-gguf-delete-list {
+ display:flex;
+ flex-direction:column;
+ gap:6px;
+ max-height:42vh;
+ overflow:auto;
+ padding:2px 2px 4px;
+ }
+ .cookbook-gguf-delete-row {
+ display:grid;
+ grid-template-columns:18px minmax(0,1fr);
+ gap:7px 8px;
+ align-items:start;
+ padding:7px 8px;
+ border:1px solid var(--border);
+ border-radius:7px;
+ background:color-mix(in srgb, var(--panel, var(--bg)) 92%, var(--fg) 8%);
+ cursor:pointer;
+ }
+ .cookbook-gguf-delete-row:hover {
+ border-color:color-mix(in srgb, var(--accent-primary, var(--fg)) 45%, var(--border));
+ }
+ .cookbook-gguf-delete-cb {
+ -webkit-appearance:none;
+ appearance:none;
+ width:8px !important;
+ height:8px !important;
+ min-width:8px;
+ min-height:8px;
+ padding:0;
+ margin:4px 0 0;
+ border:1px solid var(--border);
+ border-radius:50%;
+ background:transparent;
+ box-sizing:content-box;
+ cursor:pointer;
+ transition:background 0.15s, border-color 0.15s, transform 0.12s;
+ }
+ .cookbook-gguf-delete-cb:hover {
+ border-color:var(--accent, var(--red));
+ transform:scale(1.12);
+ }
+ .cookbook-gguf-delete-cb:checked {
+ background:var(--accent, var(--red));
+ border-color:var(--accent, var(--red));
+ }
+ .cookbook-gguf-delete-main,
+ .cookbook-gguf-delete-path {
+ min-width:0;
+ overflow:hidden;
+ text-overflow:ellipsis;
+ white-space:nowrap;
+ }
+ .cookbook-gguf-delete-main {
+ font-size:0.86rem;
+ color:var(--fg);
+ }
+ .cookbook-gguf-delete-path {
+ grid-column:2;
+ margin-top:-2px;
+ font-size:0.74rem;
+ opacity:0.58;
+ }
+ .cookbook-gguf-delete-actions {
+ flex-wrap:wrap;
+ }
/* Styled prompt — text-input dialog (used in place of window.prompt) */
#styled-prompt-overlay {
background:rgba(0,0,0,0.5);
@@ -19222,6 +19300,18 @@ body.gallery-selecting .gallery-dl-btn,
background: color-mix(in srgb, var(--red) 20%, transparent);
}
.cookbook-gpu-kill:disabled { opacity: 0.4; cursor: wait; }
+.cookbook-serve-title {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ min-width: 0;
+}
+.cookbook-serve-title-name {
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
.cookbook-hf-link {
font-size: 9px;
text-decoration: none;
@@ -19234,6 +19324,7 @@ body.gallery-selecting .gallery-dl-btn,
vertical-align: 1px;
letter-spacing: 0.3px;
font-weight: 600;
+ flex-shrink: 0;
}
.cookbook-hf-link:hover {
opacity: 0.8;
@@ -19626,6 +19717,9 @@ body.gallery-selecting .gallery-dl-btn,
position: relative;
top: -2px;
}
+.cookbook-dep-reinstall {
+ top: -3px;
+}
.cookbook-dep-rebuild:hover {
background: color-mix(in srgb, var(--accent, var(--red)) 18%, transparent);
color: var(--accent, var(--red));
@@ -20619,6 +20713,11 @@ body.gallery-selecting .gallery-dl-btn,
}
.cookbook-task[data-status="done"] .cookbook-task-check-ico { display: inline; }
.cookbook-task[data-status="done"] .cookbook-task-clear-ico { display: none; }
+@media (max-width: 820px) {
+ .cookbook-task-check {
+ top: 2px;
+ }
+}
.cookbook-task-start-now {
display: inline-flex;
align-items: center;
@@ -20652,24 +20751,30 @@ body.gallery-selecting .gallery-dl-btn,
/* "Serve" button on a finished download — green pill matching the "running" /
finished badge (it sits next to the green FINISHED chip + check). */
.cookbook-task-serve-btn {
- font-size: 9px;
- font-weight: 600;
- padding: 1px 6px;
- border: none;
- border-radius: 3px;
- line-height: 16px;
+ display: inline-flex;
+ align-items: center;
+ gap: 3px;
+ padding: 1px 6px 1px 4px;
+ border: 0;
+ border-radius: 9px;
+ line-height: 1;
flex-shrink: 0;
cursor: pointer;
font-family: inherit;
- background: color-mix(in srgb, var(--green, #50fa7b) 20%, transparent);
+ font-size: 9px;
+ text-transform: lowercase;
+ background: transparent;
color: var(--green, #50fa7b);
position: relative;
top: -2px;
+ margin-right: 2px;
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
+ transition: background 0.15s;
}
-.cookbook-task-serve-btn:hover { background: color-mix(in srgb, var(--green, #50fa7b) 32%, transparent); }
+.cookbook-task-serve-btn svg { flex-shrink: 0; }
+.cookbook-task-serve-btn:hover { background: color-mix(in srgb, var(--green, #50fa7b) 16%, transparent); }
.cookbook-task-sub {
padding: 1px 10px 4px;
line-height: 1;
@@ -21448,6 +21553,31 @@ body.gallery-selecting .gallery-dl-btn,
.cookbook-dl-btn:hover {
opacity: 0.9;
}
+.cookbook-dl-gguf-row {
+ margin-top: -1px;
+ gap: 5px;
+ align-items: center;
+ justify-content: flex-end;
+ font-size: 11px;
+ position: relative;
+ top: -2px;
+}
+.cookbook-dl-gguf-label {
+ opacity: 0.65;
+ flex-shrink: 0;
+}
+#cookbook-dl-gguf-quant {
+ height: 28px;
+ min-width: 118px;
+ flex: 0 0 auto;
+}
+#cookbook-dl-gguf-note {
+ opacity: 0.55;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ max-width: 240px;
+}
/* HF link in search panel */
.hwfit-panel-hf-link {
@@ -31920,24 +32050,34 @@ body.notes-drag-mode .note-card-pin svg {
.note-corner-menu-dropdown .ncm-item:hover {
background: color-mix(in srgb, var(--fg) 8%, transparent);
}
-/* "Agent" tag on a note that has a linked agent chat session */
-.note-agent-tag {
- align-self: flex-start;
+.note-checkbox-agent {
display: inline-flex;
align-items: center;
- gap: 5px;
- background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent);
- border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 35%, transparent);
+ justify-content: center;
+ width: 14px;
+ height: 14px;
+ padding: 0;
+ margin: 0 1px;
+ border: 0;
+ background: transparent;
color: var(--accent, var(--red));
- border-radius: 999px;
- padding: 3px 10px 3px 8px;
- font-size: 11px;
- font-weight: 600;
+ box-shadow: none;
cursor: pointer;
- margin-top: 2px;
- transition: background 0.12s;
+ opacity: 0;
+ transition: opacity 0.12s, color 0.12s;
+}
+.note-checkbox:hover .note-checkbox-agent { opacity: 0.55; }
+.note-checkbox-agent:hover {
+ background: transparent;
+ opacity: 1 !important;
+}
+.note-checkbox-agent.is-agent-stream-complete {
+ color: #50fa7b;
+ opacity: 0.9;
+}
+.note-checkbox-agent svg {
+ display: block;
}
-.note-agent-tag:hover { background: color-mix(in srgb, var(--accent, var(--red)) 24%, transparent); }
.note-card {
/* Same tint that .doclib-card uses so a default (uncolored) note
@@ -36414,6 +36554,10 @@ body.research-panel-view #research-divider { display:none; }
.research-setting {
display:flex; flex-direction:column; flex:1; min-width:90px;
}
+.research-settings-row .research-setting:nth-last-child(-n + 3) {
+ position: relative;
+ top: 3px;
+}
.research-setting-label {
font-size:9px; text-transform:uppercase; letter-spacing:0.5px;
opacity:0.5; margin-bottom:2px;
diff --git a/tests/test_fenced_example_not_executed_for_native_models.py b/tests/test_fenced_example_not_executed_for_native_models.py
index 2b69ebc5b..9cac7ab8d 100644
--- a/tests/test_fenced_example_not_executed_for_native_models.py
+++ b/tests/test_fenced_example_not_executed_for_native_models.py
@@ -221,6 +221,60 @@ def test_skip_fenced_still_recovers_xml_invoke_markup():
assert "latest python release" in blocks[0].content
+def test_stepfun_native_tool_tokens_are_executed_even_when_fenced_fallback_is_skipped():
+ leaked = (
+ "<|tool▁calls▁begin|>"
+ "<|tool▁call▁begin|>web_search<|tool▁sep|>"
+ '{"query":"Sweden news today"}'
+ "<|tool▁call▁end|>"
+ "<|tool▁calls▁end|>"
+ )
+ blocks = parse_tool_blocks(leaked, skip_fenced=True)
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert "Sweden news today" in blocks[0].content
+ assert strip_tool_blocks(leaked, skip_fenced=True) == ""
+
+
+def test_stepfun_native_tool_tokens_accept_plain_web_query():
+ leaked = (
+ "<|tool▁call▁begin|>web_search<|tool▁sep|>"
+ "Sweden news today"
+ "<|tool▁call▁end|>"
+ )
+ blocks = parse_tool_blocks(leaked, skip_fenced=True)
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert "Sweden news today" in blocks[0].content
+
+
+def test_skip_fenced_still_recovers_direct_xml_tool_markup():
+ leaked = (
+ "I'll search now.\n"
+ "
News in Sweden today 2026-06-22 "
+ )
+ blocks = parse_tool_blocks(leaked, skip_fenced=True)
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert "News in Sweden today 2026-06-22" in blocks[0].content
+ assert strip_tool_blocks(leaked, skip_fenced=True) == "I'll search now."
+
+
+def test_skip_fenced_recovers_direct_xml_tool_markup_with_unclosed_wrapper():
+ leaked = (
+ "I'll search now.\n"
+ "
\n"
+ "\n"
+ "Sweden news today 2026-06-22\n"
+ " "
+ )
+ blocks = parse_tool_blocks(leaked, skip_fenced=True)
+ assert len(blocks) == 1
+ assert blocks[0].tool_type == "web_search"
+ assert "Sweden news today 2026-06-22" in blocks[0].content
+ assert strip_tool_blocks(leaked, skip_fenced=True) == "I'll search now."
+
+
def test_skip_fenced_still_recovers_dsml_markup():
dsml = (
"Let me search for that.\n"
diff --git a/tests/test_upload_multifile.py b/tests/test_upload_multifile.py
index ef2e43596..2e40948e6 100644
--- a/tests/test_upload_multifile.py
+++ b/tests/test_upload_multifile.py
@@ -19,7 +19,12 @@ from pathlib import Path
import pytest
from fastapi import APIRouter
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker
+from sqlalchemy.pool import NullPool
+import core.database as cdb
+from core.database import GalleryImage
from src.upload_handler import count_recent_uploads, UploadHandler
import routes.upload_routes as up
@@ -82,6 +87,10 @@ def _files(n):
return [types.SimpleNamespace(filename=f"f{i}.txt") for i in range(n)]
+def _image_upload(name="photo.png", content=b"not really png but enough for route metadata"):
+ return types.SimpleNamespace(filename=name, file=io.BytesIO(content))
+
+
@pytest.fixture(autouse=True)
def _reset_router(monkeypatch):
# Module-level router accumulates routes across setup calls; reset it.
@@ -163,3 +172,64 @@ def test_six_file_batch_is_not_rate_limited(tmp_path):
assert meta and meta.get("id")
saved += 1
assert saved == 6
+
+
+async def test_chat_image_upload_is_added_to_gallery(tmp_path, monkeypatch):
+ engine = create_engine(
+ f"sqlite:///{tmp_path / 'gallery.db'}",
+ connect_args={"check_same_thread": False},
+ poolclass=NullPool,
+ )
+ cdb.Base.metadata.create_all(engine)
+ TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+ gallery_dir = tmp_path / "generated_images"
+
+ monkeypatch.setattr(up, "SessionLocal", TestingSession)
+ monkeypatch.setattr(up, "GENERATED_IMAGES_DIR", str(gallery_dir))
+
+ h = UploadHandler(base_dir=str(tmp_path), upload_dir=str(tmp_path / "uploads"))
+ up.setup_upload_routes(h)
+ endpoint = _endpoint(up.router)
+
+ result = await endpoint(_request(user="alice"), [_image_upload()])
+ uploaded = result["files"][0]
+
+ assert uploaded["gallery_id"]
+ db = TestingSession()
+ try:
+ image = db.query(GalleryImage).filter(GalleryImage.id == uploaded["gallery_id"]).one()
+ assert image.owner == "alice"
+ assert image.model == "chat-upload"
+ assert image.prompt == "photo.png"
+ assert image.file_hash == uploaded["hash"]
+ assert (gallery_dir / image.filename).exists()
+ finally:
+ db.close()
+
+
+async def test_non_image_chat_upload_is_not_added_to_gallery(tmp_path, monkeypatch):
+ engine = create_engine(
+ f"sqlite:///{tmp_path / 'gallery.db'}",
+ connect_args={"check_same_thread": False},
+ poolclass=NullPool,
+ )
+ cdb.Base.metadata.create_all(engine)
+ TestingSession = sessionmaker(bind=engine, autoflush=False, autocommit=False)
+ monkeypatch.setattr(up, "SessionLocal", TestingSession)
+ monkeypatch.setattr(up, "GENERATED_IMAGES_DIR", str(tmp_path / "generated_images"))
+
+ h = UploadHandler(base_dir=str(tmp_path), upload_dir=str(tmp_path / "uploads"))
+ up.setup_upload_routes(h)
+ endpoint = _endpoint(up.router)
+
+ result = await endpoint(_request(user="alice"), [types.SimpleNamespace(
+ filename="notes.txt",
+ file=io.BytesIO(b"plain text upload"),
+ )])
+
+ assert "gallery_id" not in result["files"][0]
+ db = TestingSession()
+ try:
+ assert db.query(GalleryImage).count() == 0
+ finally:
+ db.close()