diff --git a/Assets/assistent.bundle.js b/Assets/assistent.bundle.js index 8af3191..ccab9cd 100644 --- a/Assets/assistent.bundle.js +++ b/Assets/assistent.bundle.js @@ -1040,10 +1040,179 @@ park: "\u25BC", inventory: "\u25A4", compress: "\u25A4", + ctx: "\u2699", + model: "\u25C9", done: "\u2713", skip: "\u2013", error: "!" }; + function trimDetail(s, max = 220) { + const t = String(s || "").trim(); + if (t.length <= max) { + return t; + } + return `${t.slice(0, max)}\u2026`; + } + function shortModelName(model) { + const s = String(model || "").trim(); + if (!s) { + return ""; + } + const slash = s.lastIndexOf("/"); + return slash >= 0 ? s.slice(slash + 1) : s; + } + function buildContextMeta(context = {}) { + const parts = []; + const model = shortModelName(context.model); + if (model) { + parts.push(model); + } + if (context.pack) { + parts.push(String(context.pack).replace(/_/g, " ")); + } + if (context.persona && context.persona !== "neutral") { + parts.push(String(context.persona)); + } + if (Array.isArray(context.skills) && context.skills.length) { + parts.push(`skills: ${context.skills.slice(0, 4).join(", ")}`); + } + if (context.hop) { + parts.push(String(context.hop)); + } + return parts.join(" \xB7 "); + } + function renderMetaRow(context) { + const meta = buildContextMeta(context); + if (!meta) { + return null; + } + const row = document.createElement("div"); + row.className = "sa-activity-meta"; + row.textContent = meta; + row.title = meta; + return row; + } + function renderSteps(listEl, steps) { + if (!listEl) { + return; + } + listEl.replaceChildren(...steps.map(renderStep)); + } + function renderStep(step) { + const row = document.createElement("div"); + row.className = `sa-activity-step sa-activity-${step.status || "done"}`; + row.dataset.id = step.id; + const icon = document.createElement("span"); + icon.className = "sa-activity-icon"; + icon.setAttribute("aria-hidden", "true"); + icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think; + const body = document.createElement("div"); + body.className = "sa-activity-body"; + const label = document.createElement("div"); + label.className = "sa-activity-label"; + label.textContent = step.label || step.id; + body.appendChild(label); + if (step.detail) { + const detail = document.createElement("div"); + detail.className = "sa-activity-detail"; + detail.textContent = step.detail; + body.appendChild(detail); + } + row.appendChild(icon); + row.appendChild(body); + return row; + } + function buildActivityCard(trace, { live = false, collapsed = false } = {}) { + const card = document.createElement("div"); + card.className = "sa-activity" + (live ? " sa-activity-live" : " sa-activity-done sa-activity-historical"); + const steps = Array.isArray(trace?.steps) ? trace.steps : []; + const running = steps.some((s) => s.status === "running"); + if (!live && !running) { + card.classList.add("sa-activity-done"); + } + if (running) { + card.classList.add("sa-activity-live"); + } + card.setAttribute("role", "status"); + card.setAttribute("aria-live", live ? "polite" : "off"); + const head = document.createElement("button"); + head.type = "button"; + head.className = "sa-activity-head"; + head.setAttribute("aria-expanded", collapsed ? "false" : "true"); + const spin = document.createElement("span"); + spin.className = "sa-activity-spin"; + spin.setAttribute("aria-hidden", "true"); + const titleEl = document.createElement("span"); + titleEl.className = "sa-activity-title"; + const runningStep = steps.find((s) => s.status === "running"); + const last = steps[steps.length - 1]; + titleEl.textContent = trace?.title || runningStep?.label || last?.label || "Assistent"; + const chev = document.createElement("span"); + chev.className = "sa-activity-chev"; + chev.setAttribute("aria-hidden", "true"); + chev.textContent = "\u25BE"; + head.appendChild(spin); + head.appendChild(titleEl); + head.appendChild(chev); + let open = !collapsed; + head.addEventListener("click", () => { + open = !open; + card.classList.toggle("sa-activity-collapsed", !open); + head.setAttribute("aria-expanded", open ? "true" : "false"); + }); + if (collapsed) { + card.classList.add("sa-activity-collapsed"); + } + const meta = renderMetaRow(trace?.context || {}); + const listEl = document.createElement("div"); + listEl.className = "sa-activity-steps"; + renderSteps(listEl, steps); + card.appendChild(head); + if (meta) { + card.appendChild(meta); + } + card.appendChild(listEl); + return card; + } + function mountActivityTrace(trace, { box, insertBefore = null, collapsed = true } = {}) { + const host = box || null; + if (!host || !trace || !Array.isArray(trace.steps) || !trace.steps.length) { + return null; + } + const card = buildActivityCard(trace, { live: false, collapsed }); + if (insertBefore && insertBefore.parentNode === host) { + host.insertBefore(card, insertBefore); + } else { + host.appendChild(card); + } + return card; + } + function slimActivityTrace(trace) { + if (!trace || typeof trace !== "object") { + return void 0; + } + const steps = (trace.steps || []).slice(0, 32).map((s) => ({ + id: String(s.id || "").slice(0, 40), + kind: s.kind || "think", + label: String(s.label || s.id || "").slice(0, 120), + detail: trimDetail(s.detail, 180), + status: s.status || "done" + })); + if (!steps.length) { + return void 0; + } + return { + title: String(trace.title || "Assistent").slice(0, 80), + context: trace.context ? { + model: trace.context.model || void 0, + pack: trace.context.pack || void 0, + persona: trace.context.persona || void 0, + skills: Array.isArray(trace.context.skills) ? trace.context.skills.slice(0, 8) : void 0, + hop: trace.context.hop || void 0 + } : void 0, + steps + }; + } function createActivityController(opts = {}) { const { getMessagesEl, @@ -1053,8 +1222,27 @@ let card = null; let listEl = null; let titleEl = null; + let metaEl = null; let steps = []; + let context = {}; + let title = "Assistent"; let open = true; + function insertCard(el) { + const box = typeof getMessagesEl === "function" ? getMessagesEl() : null; + if (!box || !el) { + return; + } + const lastUser = box.querySelector(".sa-msg.user:last-of-type"); + if (lastUser && lastUser.parentNode === box) { + if (lastUser.nextSibling) { + box.insertBefore(el, lastUser.nextSibling); + } else { + box.appendChild(el); + } + return; + } + box.appendChild(el); + } function ensureCard() { const box = typeof getMessagesEl === "function" ? getMessagesEl() : null; if (!box) { @@ -1079,7 +1267,7 @@ spin.setAttribute("aria-hidden", "true"); titleEl = document.createElement("span"); titleEl.className = "sa-activity-title"; - titleEl.textContent = "Assistent"; + titleEl.textContent = title || "Assistent"; const chev = document.createElement("span"); chev.className = "sa-activity-chev"; chev.setAttribute("aria-hidden", "true"); @@ -1095,46 +1283,32 @@ listEl = document.createElement("div"); listEl.className = "sa-activity-steps"; card.appendChild(head); + metaEl = renderMetaRow(context); + if (metaEl) { + card.appendChild(metaEl); + } card.appendChild(listEl); - box.appendChild(card); + insertCard(card); if (typeof scrollToBottom === "function") { scrollToBottom(); } return card; } - function renderStep(step) { - const row = document.createElement("div"); - row.className = `sa-activity-step sa-activity-${step.status || "running"}`; - row.dataset.id = step.id; - const icon = document.createElement("span"); - icon.className = "sa-activity-icon"; - icon.setAttribute("aria-hidden", "true"); - icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think; - const body = document.createElement("div"); - body.className = "sa-activity-body"; - const label = document.createElement("div"); - label.className = "sa-activity-label"; - label.textContent = step.label || step.id; - body.appendChild(label); - if (step.detail) { - const detail = document.createElement("div"); - detail.className = "sa-activity-detail"; - detail.textContent = step.detail; - body.appendChild(detail); - } - row.appendChild(icon); - row.appendChild(body); - return row; - } function paint() { if (!ensureCard() || !listEl) { return; } - listEl.replaceChildren(...steps.map(renderStep)); + if (metaEl) { + const nextMeta = buildContextMeta(context); + metaEl.textContent = nextMeta; + metaEl.title = nextMeta; + metaEl.hidden = !nextMeta; + } + renderSteps(listEl, steps); const running = steps.find((s) => s.status === "running"); const last = steps[steps.length - 1]; if (titleEl) { - titleEl.textContent = running ? running.label : last?.label || "Assistent"; + titleEl.textContent = running ? running.label : last?.label || title || "Assistent"; } card.classList.toggle("sa-activity-live", steps.some((s) => s.status === "running")); card.classList.toggle("sa-activity-done", steps.length > 0 && steps.every((s) => s.status === "done" || s.status === "skip")); @@ -1142,15 +1316,44 @@ scrollToBottom(); } } - function begin(title) { + function setContext(patch = {}) { + context = { ...context, ...patch }; + if (card && !metaEl) { + metaEl = renderMetaRow(context); + if (metaEl && card.firstChild) { + card.insertBefore(metaEl, card.firstChild); + } else if (metaEl) { + card.prepend(metaEl); + } + } + paint(); + } + function begin(nextTitle, nextContext = {}) { steps = []; card = null; listEl = null; titleEl = null; + metaEl = null; open = true; + title = nextTitle || "Assistent"; + context = nextContext && typeof nextContext === "object" ? { ...nextContext } : {}; ensureCard(); - if (titleEl && title) { - titleEl.textContent = title; + const meta = buildContextMeta(context); + if (meta) { + upsert("ctx", { + kind: "ctx", + label: "\u041A\u043E\u043D\u0442\u0435\u043A\u0441\u0442 \u0445\u043E\u0434\u0430", + detail: meta, + status: "done" + }); + } + if (context.model) { + upsert("model", { + kind: "model", + label: "\u041C\u043E\u0434\u0435\u043B\u044C", + detail: String(context.model), + status: "done" + }); } paint(); } @@ -1183,6 +1386,9 @@ s.status = "done"; } }); + if (summary) { + title = summary; + } if (summary && titleEl) { titleEl.textContent = summary; } @@ -1192,6 +1398,13 @@ card.classList.add("sa-activity-done"); } } + function snapshot() { + return slimActivityTrace({ + title: titleEl?.textContent || title || "Assistent", + context, + steps: steps.slice() + }); + } function noteModelCommands(patch) { if (!patch || typeof patch !== "object") { return; @@ -1200,16 +1413,16 @@ if (keys.length) { done("delta", { kind: "delta", - label: "\u041E\u0431\u043D\u043E\u0432\u0438\u043B \u0441\u0435\u0441\u0441\u0438\u044E", - detail: keys.slice(0, 10).join(", ") + label: "\u041F\u0430\u0442\u0447 \u0441\u0435\u0441\u0441\u0438\u0438", + detail: keys.slice(0, 12).join(", ") }); } const ask = Array.isArray(patch.ask) ? patch.ask.map(String) : patch.ask ? [String(patch.ask)] : []; if (ask.length) { upsert("ask", { kind: "ask", - label: `\u0417\u0430\u043F\u0440\u043E\u0441\u0438\u043B ${ask.join(", ")}`, - detail: "\u043F\u043E\u0434\u0433\u0440\u0443\u0436\u0430\u044E \u0434\u0435\u0442\u0430\u043B\u0438\u2026", + label: `ask: ${ask.join(", ")}`, + detail: "\u0437\u0430\u043F\u0440\u043E\u0441 \u0434\u0435\u0442\u0430\u043B\u0435\u0439 \u0443 SwarmUI", status: "running" }); } @@ -1217,7 +1430,7 @@ const slots = [].concat(patch.look_at || patch.vision_from || patch.vision_slots || []); upsert("look", { kind: "look", - label: "\u0421\u043C\u043E\u0442\u0440\u0438\u0442 \u043D\u0430 \u043A\u0430\u0434\u0440", + label: "look_at", detail: slots.map(String).slice(0, 4).join(", "), status: "running" }); @@ -1226,25 +1439,27 @@ upsert("generate", { kind: "generate", label: "Generate", - detail: "\u0436\u0434\u0451\u0442 \u043F\u0430\u0439\u043F\u043B\u0430\u0439\u043D\u2026", + detail: "\u043A\u043B\u0438\u0435\u043D\u0442 \u2192 SwarmUI", status: "running" }); } if (Array.isArray(patch.variants) && patch.variants.length) { upsert("variants", { kind: "generate", - label: `\u0412\u0430\u0440\u0438\u0430\u043D\u0442\u044B \xD7${patch.variants.length}`, + label: `variants \xD7${patch.variants.length}`, status: "running" }); } } return { begin, + setContext, upsert, done, skip, fail, finish, + snapshot, noteModelCommands, get steps() { return steps.slice(); @@ -1253,6 +1468,8 @@ } function attachActivity(SA2) { SA2.createActivityController = createActivityController; + SA2.mountActivityTrace = mountActivityTrace; + SA2.slimActivityTrace = slimActivityTrace; } // src/kreaProfile.js @@ -1566,7 +1783,8 @@ settingsPersonaId: null, ollamaHealth: "unknown", trainingLock: false, - activity: null + activity: null, + pendingActivityTrace: null }; function getActivity() { if (state.activity) { @@ -1581,12 +1799,24 @@ } return state.activity; } - function activityBegin(title) { + function activityBegin(title, context) { const a = getActivity(); if (a) { - a.begin(title || "Assistent"); + a.begin(title || "Assistent", context || {}); } } + function captureActivityTrace() { + const a = getActivity(); + if (!a || typeof a.snapshot !== "function") { + return null; + } + const snap = a.snapshot(); + if (snap && snap.steps && snap.steps.length) { + state.pendingActivityTrace = snap; + return snap; + } + return null; + } function activityStep(id, patch) { const a = getActivity(); if (a) { @@ -1603,6 +1833,7 @@ const a = getActivity(); if (a) { a.finish(summary); + captureActivityTrace(); } } const HOP_BUDGET = 4; @@ -4364,7 +4595,8 @@ ${patch.prompt}`; role: m.role, content: content.slice(0, 4e3), persona: m.persona || void 0, - pack: m.pack || void 0 + pack: m.pack || void 0, + activity: window.SA && typeof SA.slimActivityTrace === "function" ? SA.slimActivityTrace(m.activity) : m.activity || void 0 }; }); } @@ -4725,7 +4957,8 @@ ${patch.prompt}`; appendMessage("assistant", m.content, null, null, { persona: m.persona ? { id: m.persona, title: m.persona } : null, pack: m.pack, - historical: true + historical: true, + activity: m.activity }); } } @@ -6162,7 +6395,14 @@ ${patch.prompt}`; } else { appendMessage("assistant", prose); } - state.history.push({ role: "assistant", content: prose, persona, pack }); + state.history.push({ + role: "assistant", + content: prose, + persona, + pack, + activity: captureActivityTrace() || state.pendingActivityTrace || void 0 + }); + state.pendingActivityTrace = null; persistHistory(); state.turnSettled = true; } @@ -6711,6 +6951,9 @@ ${patch.prompt}`; if (role === "assistant" && !(meta && meta.historical)) { mountCurateButtons(div, meta); } + if (role === "assistant" && meta?.historical && meta?.activity && window.SA && typeof SA.mountActivityTrace === "function") { + SA.mountActivityTrace(meta.activity, { box, insertBefore: div, collapsed: true }); + } box.appendChild(div); scrollMessagesToBottom({ force: true }); return div; @@ -9725,16 +9968,6 @@ ${HELP_TEXT}`); state.turnSettled = false; state.llmParked = false; setInterruptVisible(true); - if (!isContinuationTurn(opts) && !opts.fromAskHop) { - activityBegin(opts.fromDebug ? "Debug" : "\u0425\u043E\u0434 Assistent"); - activityStep("think", { kind: "think", label: "\u0414\u0443\u043C\u0430\u044E\u2026", status: "running" }); - } else if (opts.fromAskHop) { - activityStep("think", { kind: "think", label: "\u041E\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0441 \u0434\u0435\u0442\u0430\u043B\u044F\u043C\u0438\u2026", status: "running" }); - } else if (opts.fromVisionHop) { - activityStep("look", { kind: "look", label: "\u0421\u043C\u043E\u0442\u0440\u0438\u0442 \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435\u2026", status: "running" }); - } else if (opts.fromPromptEnRetry) { - activityStep("prep", { kind: "prep", label: "\u0414\u043E\u043F\u0438\u0441\u044B\u0432\u0430\u044E EN-\u043F\u0440\u043E\u043C\u043F\u0442\u2026", status: "running" }); - } if (state.expectColdLoad && !isContinuationTurn(opts)) { startBusyUi("warming"); setStatus("\u0412\u043E\u0437\u0432\u0440\u0430\u0449\u0430\u044E LLM \u0432 GPU\u2026"); @@ -9811,6 +10044,27 @@ ${HELP_TEXT}`); persistHistory(); } } + if (!isContinuationTurn(opts) && !opts.fromAskHop) { + state.pendingActivityTrace = null; + const hopLabel = opts.fromVisionHop ? "vision hop" : opts.fromPromptEnRetry ? "krea prep" : opts.fromAutoCritique ? "critique" : opts.fromDebug ? "debug" : ""; + activityBegin(opts.fromDebug ? "Debug" : "\u0425\u043E\u0434 Assistent", { + model, + pack, + persona, + skills: opts.fromDebug ? [] : state.enabledSkills || [], + hop: hopLabel || void 0 + }); + activityStep("think", { kind: "think", label: "\u0414\u0443\u043C\u0430\u044E\u2026", status: "running" }); + } else if (opts.fromAskHop) { + getActivity()?.setContext?.({ hop: "ask hop", pack, model }); + activityStep("think", { kind: "think", label: "\u041E\u0442\u0432\u0435\u0447\u0430\u0435\u0442 \u0441 \u0434\u0435\u0442\u0430\u043B\u044F\u043C\u0438\u2026", status: "running" }); + } else if (opts.fromVisionHop) { + getActivity()?.setContext?.({ hop: "vision hop" }); + activityStep("look", { kind: "look", label: "\u0421\u043C\u043E\u0442\u0440\u0438\u0442 \u0438\u0437\u043E\u0431\u0440\u0430\u0436\u0435\u043D\u0438\u0435\u2026", status: "running" }); + } else if (opts.fromPromptEnRetry) { + getActivity()?.setContext?.({ hop: "krea prep" }); + activityStep("prep", { kind: "prep", label: "\u0414\u043E\u043F\u0438\u0441\u044B\u0432\u0430\u044E EN-\u043F\u0440\u043E\u043C\u043F\u0442\u2026", status: "running" }); + } if (!opts.fromDebug && !opts.fromCompress && !opts.fromAskHop && !opts.fromVisionHop && !opts.fromAutoCritique && !isContinuationTurn(opts)) { try { await maybeAutoCompressBeforeSend(chatEpoch); @@ -9885,8 +10139,6 @@ ${HELP_TEXT}`); } updateCtxChip(); const prose = visibleAssistantProse(reply); - state.history.push({ role: "assistant", content: prose, persona, pack }); - persistHistory(); setBusyPhase(state.pendingSilentGen ? "silent_gen" : "thinking"); try { await handleReplySideEffects(reply, civitaiResults, { @@ -9895,6 +10147,26 @@ ${HELP_TEXT}`); userWantsGenerate: !!opts.userWantsGenerate || !!state.turnUserWantsGenerate || !isMachineTurn(opts) && state.pendingSilentGen, attachedSlotIds: visionSlots.map((s) => s.id) }); + if (meta.system_layers && typeof meta.system_layers === "object") { + const layers = Object.keys(meta.system_layers).filter((k) => meta.system_layers[k]); + if (layers.length) { + activityDone("layers", { + kind: "ctx", + label: "system_layers", + detail: layers.slice(0, 12).join(", ") + }); + } + } + captureActivityTrace(); + state.history.push({ + role: "assistant", + content: prose, + persona, + pack, + activity: state.pendingActivityTrace || void 0 + }); + state.pendingActivityTrace = null; + persistHistory(); } finally { if (chatEpoch !== state.chatEpoch) { return; diff --git a/Assets/assistent.css b/Assets/assistent.css index 8b1d9ab..57d5de9 100644 --- a/Assets/assistent.css +++ b/Assets/assistent.css @@ -3081,6 +3081,24 @@ word-break: break-word; } +.sa-activity-meta { + padding: 0.15rem 0.55rem 0.25rem; + font-size: 0.72rem; + line-height: 1.35; + opacity: 0.78; + word-break: break-word; + border-bottom: 1px solid color-mix(in srgb, currentColor 12%, transparent); +} + +.sa-activity-historical { + margin: 0.25rem 0 0.35rem; + opacity: 0.95; +} + +.sa-activity-historical .sa-activity-head { + cursor: pointer; +} + @keyframes sa-spin { to { transform: rotate(360deg); } } diff --git a/SwarmAssistentExtension.cs b/SwarmAssistentExtension.cs index 6e00733..a837959 100644 --- a/SwarmAssistentExtension.cs +++ b/SwarmAssistentExtension.cs @@ -33,7 +33,7 @@ public partial class SwarmAssistentExtension : Extension ExtensionAuthor = "mrleo1nid"; Description = "Collaborative Krea 2 assistant: Ollama chat, persona presets, vector memory, model cards, Generate loop."; License = "MIT"; - Version = "0.15.16"; + Version = "0.15.17"; Tags = ["tabs", "ui", "llm", "ollama", "krea", "inpaint", "memory", "training", "heard", "qlora"]; } diff --git a/package.json b/package.json index b0c12db..395dfc9 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build": "node scripts/build.mjs", "watch": "node scripts/build.mjs --watch", - "test": "node --test test/intent.test.js test/patch.test.js test/context.test.js test/kreaProfile.test.js test/aspect.test.js" + "test": "node --test test/intent.test.js test/patch.test.js test/context.test.js test/kreaProfile.test.js test/aspect.test.js test/activity.test.js" }, "devDependencies": { "esbuild": "^0.25.0" diff --git a/src/activity.js b/src/activity.js index 71bd150..d0d444f 100644 --- a/src/activity.js +++ b/src/activity.js @@ -1,6 +1,6 @@ /** * Turn activity timeline — shows model commands and pipeline steps in chat. - * Cursor-like, but compact and chat-native. + * Cursor-like, but compact and chat-native. Persists per assistant turn in history. */ const STEP_ICONS = { @@ -16,11 +16,207 @@ const STEP_ICONS = { park: '▼', inventory: '▤', compress: '▤', + ctx: '⚙', + model: '◉', done: '✓', skip: '–', error: '!', }; +function trimDetail(s, max = 220) { + const t = String(s || '').trim(); + if (t.length <= max) { + return t; + } + return `${t.slice(0, max)}…`; +} + +function shortModelName(model) { + const s = String(model || '').trim(); + if (!s) { + return ''; + } + const slash = s.lastIndexOf('/'); + return slash >= 0 ? s.slice(slash + 1) : s; +} + +function buildContextMeta(context = {}) { + const parts = []; + const model = shortModelName(context.model); + if (model) { + parts.push(model); + } + if (context.pack) { + parts.push(String(context.pack).replace(/_/g, ' ')); + } + if (context.persona && context.persona !== 'neutral') { + parts.push(String(context.persona)); + } + if (Array.isArray(context.skills) && context.skills.length) { + parts.push(`skills: ${context.skills.slice(0, 4).join(', ')}`); + } + if (context.hop) { + parts.push(String(context.hop)); + } + return parts.join(' · '); +} + +function renderMetaRow(context) { + const meta = buildContextMeta(context); + if (!meta) { + return null; + } + const row = document.createElement('div'); + row.className = 'sa-activity-meta'; + row.textContent = meta; + row.title = meta; + return row; +} + +function renderSteps(listEl, steps) { + if (!listEl) { + return; + } + listEl.replaceChildren(...steps.map(renderStep)); +} + +function renderStep(step) { + const row = document.createElement('div'); + row.className = `sa-activity-step sa-activity-${step.status || 'done'}`; + row.dataset.id = step.id; + + const icon = document.createElement('span'); + icon.className = 'sa-activity-icon'; + icon.setAttribute('aria-hidden', 'true'); + icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think; + + const body = document.createElement('div'); + body.className = 'sa-activity-body'; + + const label = document.createElement('div'); + label.className = 'sa-activity-label'; + label.textContent = step.label || step.id; + + body.appendChild(label); + if (step.detail) { + const detail = document.createElement('div'); + detail.className = 'sa-activity-detail'; + detail.textContent = step.detail; + body.appendChild(detail); + } + + row.appendChild(icon); + row.appendChild(body); + return row; +} + +function buildActivityCard(trace, { live = false, collapsed = false } = {}) { + const card = document.createElement('div'); + card.className = 'sa-activity' + + (live ? ' sa-activity-live' : ' sa-activity-done sa-activity-historical'); + + const steps = Array.isArray(trace?.steps) ? trace.steps : []; + const running = steps.some((s) => s.status === 'running'); + if (!live && !running) { + card.classList.add('sa-activity-done'); + } + if (running) { + card.classList.add('sa-activity-live'); + } + card.setAttribute('role', 'status'); + card.setAttribute('aria-live', live ? 'polite' : 'off'); + + const head = document.createElement('button'); + head.type = 'button'; + head.className = 'sa-activity-head'; + head.setAttribute('aria-expanded', collapsed ? 'false' : 'true'); + + const spin = document.createElement('span'); + spin.className = 'sa-activity-spin'; + spin.setAttribute('aria-hidden', 'true'); + + const titleEl = document.createElement('span'); + titleEl.className = 'sa-activity-title'; + const runningStep = steps.find((s) => s.status === 'running'); + const last = steps[steps.length - 1]; + titleEl.textContent = trace?.title + || runningStep?.label + || last?.label + || 'Assistent'; + + const chev = document.createElement('span'); + chev.className = 'sa-activity-chev'; + chev.setAttribute('aria-hidden', 'true'); + chev.textContent = '▾'; + + head.appendChild(spin); + head.appendChild(titleEl); + head.appendChild(chev); + + let open = !collapsed; + head.addEventListener('click', () => { + open = !open; + card.classList.toggle('sa-activity-collapsed', !open); + head.setAttribute('aria-expanded', open ? 'true' : 'false'); + }); + if (collapsed) { + card.classList.add('sa-activity-collapsed'); + } + + const meta = renderMetaRow(trace?.context || {}); + const listEl = document.createElement('div'); + listEl.className = 'sa-activity-steps'; + renderSteps(listEl, steps); + card.appendChild(head); + if (meta) { + card.appendChild(meta); + } + card.appendChild(listEl); + return card; +} + +/** Static activity card for chat history replay. */ +export function mountActivityTrace(trace, { box, insertBefore = null, collapsed = true } = {}) { + const host = box || null; + if (!host || !trace || !Array.isArray(trace.steps) || !trace.steps.length) { + return null; + } + const card = buildActivityCard(trace, { live: false, collapsed }); + if (insertBefore && insertBefore.parentNode === host) { + host.insertBefore(card, insertBefore); + } else { + host.appendChild(card); + } + return card; +} + +export function slimActivityTrace(trace) { + if (!trace || typeof trace !== 'object') { + return undefined; + } + const steps = (trace.steps || []).slice(0, 32).map((s) => ({ + id: String(s.id || '').slice(0, 40), + kind: s.kind || 'think', + label: String(s.label || s.id || '').slice(0, 120), + detail: trimDetail(s.detail, 180), + status: s.status || 'done', + })); + if (!steps.length) { + return undefined; + } + return { + title: String(trace.title || 'Assistent').slice(0, 80), + context: trace.context ? { + model: trace.context.model || undefined, + pack: trace.context.pack || undefined, + persona: trace.context.persona || undefined, + skills: Array.isArray(trace.context.skills) ? trace.context.skills.slice(0, 8) : undefined, + hop: trace.context.hop || undefined, + } : undefined, + steps, + }; +} + export function createActivityController(opts = {}) { const { getMessagesEl, @@ -31,9 +227,29 @@ export function createActivityController(opts = {}) { let card = null; let listEl = null; let titleEl = null; + let metaEl = null; let steps = []; + let context = {}; + let title = 'Assistent'; let open = true; + function insertCard(el) { + const box = typeof getMessagesEl === 'function' ? getMessagesEl() : null; + if (!box || !el) { + return; + } + const lastUser = box.querySelector('.sa-msg.user:last-of-type'); + if (lastUser && lastUser.parentNode === box) { + if (lastUser.nextSibling) { + box.insertBefore(el, lastUser.nextSibling); + } else { + box.appendChild(el); + } + return; + } + box.appendChild(el); + } + function ensureCard() { const box = typeof getMessagesEl === 'function' ? getMessagesEl() : null; if (!box) { @@ -61,7 +277,7 @@ export function createActivityController(opts = {}) { titleEl = document.createElement('span'); titleEl.className = 'sa-activity-title'; - titleEl.textContent = 'Assistent'; + titleEl.textContent = title || 'Assistent'; const chev = document.createElement('span'); chev.className = 'sa-activity-chev'; @@ -81,55 +297,35 @@ export function createActivityController(opts = {}) { listEl.className = 'sa-activity-steps'; card.appendChild(head); + metaEl = renderMetaRow(context); + if (metaEl) { + card.appendChild(metaEl); + } card.appendChild(listEl); - box.appendChild(card); + insertCard(card); if (typeof scrollToBottom === 'function') { scrollToBottom(); } return card; } - function renderStep(step) { - const row = document.createElement('div'); - row.className = `sa-activity-step sa-activity-${step.status || 'running'}`; - row.dataset.id = step.id; - - const icon = document.createElement('span'); - icon.className = 'sa-activity-icon'; - icon.setAttribute('aria-hidden', 'true'); - icon.textContent = STEP_ICONS[step.kind] || STEP_ICONS.think; - - const body = document.createElement('div'); - body.className = 'sa-activity-body'; - - const label = document.createElement('div'); - label.className = 'sa-activity-label'; - label.textContent = step.label || step.id; - - body.appendChild(label); - if (step.detail) { - const detail = document.createElement('div'); - detail.className = 'sa-activity-detail'; - detail.textContent = step.detail; - body.appendChild(detail); - } - - row.appendChild(icon); - row.appendChild(body); - return row; - } - function paint() { if (!ensureCard() || !listEl) { return; } - listEl.replaceChildren(...steps.map(renderStep)); + if (metaEl) { + const nextMeta = buildContextMeta(context); + metaEl.textContent = nextMeta; + metaEl.title = nextMeta; + metaEl.hidden = !nextMeta; + } + renderSteps(listEl, steps); const running = steps.find((s) => s.status === 'running'); const last = steps[steps.length - 1]; if (titleEl) { titleEl.textContent = running ? running.label - : (last?.label || 'Assistent'); + : (last?.label || title || 'Assistent'); } card.classList.toggle('sa-activity-live', steps.some((s) => s.status === 'running')); card.classList.toggle('sa-activity-done', steps.length > 0 && steps.every((s) => s.status === 'done' || s.status === 'skip')); @@ -138,15 +334,45 @@ export function createActivityController(opts = {}) { } } - function begin(title) { + function setContext(patch = {}) { + context = { ...context, ...patch }; + if (card && !metaEl) { + metaEl = renderMetaRow(context); + if (metaEl && card.firstChild) { + card.insertBefore(metaEl, card.firstChild); + } else if (metaEl) { + card.prepend(metaEl); + } + } + paint(); + } + + function begin(nextTitle, nextContext = {}) { steps = []; card = null; listEl = null; titleEl = null; + metaEl = null; open = true; + title = nextTitle || 'Assistent'; + context = nextContext && typeof nextContext === 'object' ? { ...nextContext } : {}; ensureCard(); - if (titleEl && title) { - titleEl.textContent = title; + const meta = buildContextMeta(context); + if (meta) { + upsert('ctx', { + kind: 'ctx', + label: 'Контекст хода', + detail: meta, + status: 'done', + }); + } + if (context.model) { + upsert('model', { + kind: 'model', + label: 'Модель', + detail: String(context.model), + status: 'done', + }); } paint(); } @@ -184,6 +410,9 @@ export function createActivityController(opts = {}) { s.status = 'done'; } }); + if (summary) { + title = summary; + } if (summary && titleEl) { titleEl.textContent = summary; } @@ -194,6 +423,14 @@ export function createActivityController(opts = {}) { } } + function snapshot() { + return slimActivityTrace({ + title: titleEl?.textContent || title || 'Assistent', + context, + steps: steps.slice(), + }); + } + /** Describe a sparse model patch as human-readable activity steps. */ function noteModelCommands(patch) { if (!patch || typeof patch !== 'object') { @@ -204,16 +441,16 @@ export function createActivityController(opts = {}) { if (keys.length) { done('delta', { kind: 'delta', - label: 'Обновил сессию', - detail: keys.slice(0, 10).join(', '), + label: 'Патч сессии', + detail: keys.slice(0, 12).join(', '), }); } const ask = Array.isArray(patch.ask) ? patch.ask.map(String) : (patch.ask ? [String(patch.ask)] : []); if (ask.length) { upsert('ask', { kind: 'ask', - label: `Запросил ${ask.join(', ')}`, - detail: 'подгружаю детали…', + label: `ask: ${ask.join(', ')}`, + detail: 'запрос деталей у SwarmUI', status: 'running', }); } @@ -221,7 +458,7 @@ export function createActivityController(opts = {}) { const slots = [].concat(patch.look_at || patch.vision_from || patch.vision_slots || []); upsert('look', { kind: 'look', - label: 'Смотрит на кадр', + label: 'look_at', detail: slots.map(String).slice(0, 4).join(', '), status: 'running', }); @@ -231,14 +468,14 @@ export function createActivityController(opts = {}) { upsert('generate', { kind: 'generate', label: 'Generate', - detail: 'ждёт пайплайн…', + detail: 'клиент → SwarmUI', status: 'running', }); } if (Array.isArray(patch.variants) && patch.variants.length) { upsert('variants', { kind: 'generate', - label: `Варианты ×${patch.variants.length}`, + label: `variants ×${patch.variants.length}`, status: 'running', }); } @@ -246,11 +483,13 @@ export function createActivityController(opts = {}) { return { begin, + setContext, upsert, done, skip, fail, finish, + snapshot, noteModelCommands, get steps() { return steps.slice(); @@ -260,4 +499,6 @@ export function createActivityController(opts = {}) { export function attachActivity(SA) { SA.createActivityController = createActivityController; + SA.mountActivityTrace = mountActivityTrace; + SA.slimActivityTrace = slimActivityTrace; } diff --git a/src/app.js b/src/app.js index 452de17..bf06021 100644 --- a/src/app.js +++ b/src/app.js @@ -183,6 +183,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } ollamaHealth: 'unknown', trainingLock: false, activity: null, + pendingActivityTrace: null, }; function getActivity() { @@ -199,13 +200,26 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } return state.activity; } - function activityBegin(title) { + function activityBegin(title, context) { const a = getActivity(); if (a) { - a.begin(title || 'Assistent'); + a.begin(title || 'Assistent', context || {}); } } + function captureActivityTrace() { + const a = getActivity(); + if (!a || typeof a.snapshot !== 'function') { + return null; + } + const snap = a.snapshot(); + if (snap && snap.steps && snap.steps.length) { + state.pendingActivityTrace = snap; + return snap; + } + return null; + } + function activityStep(id, patch) { const a = getActivity(); if (a) { @@ -224,6 +238,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } const a = getActivity(); if (a) { a.finish(summary); + captureActivityTrace(); } } @@ -3255,6 +3270,9 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } content: content.slice(0, 4000), persona: m.persona || undefined, pack: m.pack || undefined, + activity: (window.SA && typeof SA.slimActivityTrace === 'function') + ? SA.slimActivityTrace(m.activity) + : (m.activity || undefined), }; }); } @@ -3644,6 +3662,7 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } persona: m.persona ? { id: m.persona, title: m.persona } : null, pack: m.pack, historical: true, + activity: m.activity, }); } } @@ -5216,7 +5235,14 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } } else { appendMessage('assistant', prose); } - state.history.push({ role: 'assistant', content: prose, persona, pack }); + state.history.push({ + role: 'assistant', + content: prose, + persona, + pack, + activity: captureActivityTrace() || state.pendingActivityTrace || undefined, + }); + state.pendingActivityTrace = null; persistHistory(); state.turnSettled = true; } @@ -5820,6 +5846,10 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } if (role === 'assistant' && !(meta && meta.historical)) { mountCurateButtons(div, meta); } + if (role === 'assistant' && meta?.historical && meta?.activity + && window.SA && typeof SA.mountActivityTrace === 'function') { + SA.mountActivityTrace(meta.activity, { box, insertBefore: div, collapsed: true }); + } box.appendChild(div); scrollMessagesToBottom({ force: true }); return div; @@ -8978,16 +9008,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } // If Krea just ran, expectColdLoad stays true until warm / first token — don't clear it here. state.llmParked = false; setInterruptVisible(true); - if (!isContinuationTurn(opts) && !opts.fromAskHop) { - activityBegin(opts.fromDebug ? 'Debug' : 'Ход Assistent'); - activityStep('think', { kind: 'think', label: 'Думаю…', status: 'running' }); - } else if (opts.fromAskHop) { - activityStep('think', { kind: 'think', label: 'Отвечает с деталями…', status: 'running' }); - } else if (opts.fromVisionHop) { - activityStep('look', { kind: 'look', label: 'Смотрит изображение…', status: 'running' }); - } else if (opts.fromPromptEnRetry) { - activityStep('prep', { kind: 'prep', label: 'Дописываю EN-промпт…', status: 'running' }); - } if (state.expectColdLoad && !isContinuationTurn(opts)) { startBusyUi('warming'); setStatus('Возвращаю LLM в GPU…'); @@ -9073,6 +9093,31 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } } } + if (!isContinuationTurn(opts) && !opts.fromAskHop) { + state.pendingActivityTrace = null; + const hopLabel = opts.fromVisionHop ? 'vision hop' + : (opts.fromPromptEnRetry ? 'krea prep' + : (opts.fromAutoCritique ? 'critique' + : (opts.fromDebug ? 'debug' : ''))); + activityBegin(opts.fromDebug ? 'Debug' : 'Ход Assistent', { + model, + pack, + persona, + skills: opts.fromDebug ? [] : (state.enabledSkills || []), + hop: hopLabel || undefined, + }); + activityStep('think', { kind: 'think', label: 'Думаю…', status: 'running' }); + } else if (opts.fromAskHop) { + getActivity()?.setContext?.({ hop: 'ask hop', pack, model }); + activityStep('think', { kind: 'think', label: 'Отвечает с деталями…', status: 'running' }); + } else if (opts.fromVisionHop) { + getActivity()?.setContext?.({ hop: 'vision hop' }); + activityStep('look', { kind: 'look', label: 'Смотрит изображение…', status: 'running' }); + } else if (opts.fromPromptEnRetry) { + getActivity()?.setContext?.({ hop: 'krea prep' }); + activityStep('prep', { kind: 'prep', label: 'Дописываю EN-промпт…', status: 'running' }); + } + // Auto-compress after the new user turn is in history (budget includes it). if (!opts.fromDebug && !opts.fromCompress && !opts.fromAskHop && !opts.fromVisionHop && !opts.fromAutoCritique && !isContinuationTurn(opts)) { @@ -9157,8 +9202,6 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } } updateCtxChip(); const prose = visibleAssistantProse(reply); - state.history.push({ role: 'assistant', content: prose, persona, pack }); - persistHistory(); setBusyPhase(state.pendingSilentGen ? 'silent_gen' : 'thinking'); try { await handleReplySideEffects(reply, civitaiResults, { @@ -9169,6 +9212,26 @@ import { DEFAULT_ASPECT_TABLE, applyAspectTableFromObject as mergeAspectTable } || (!isMachineTurn(opts) && state.pendingSilentGen), attachedSlotIds: visionSlots.map((s) => s.id), }); + if (meta.system_layers && typeof meta.system_layers === 'object') { + const layers = Object.keys(meta.system_layers).filter((k) => meta.system_layers[k]); + if (layers.length) { + activityDone('layers', { + kind: 'ctx', + label: 'system_layers', + detail: layers.slice(0, 12).join(', '), + }); + } + } + captureActivityTrace(); + state.history.push({ + role: 'assistant', + content: prose, + persona, + pack, + activity: state.pendingActivityTrace || undefined, + }); + state.pendingActivityTrace = null; + persistHistory(); } finally { if (chatEpoch !== state.chatEpoch) { return; diff --git a/test/activity.test.js b/test/activity.test.js new file mode 100644 index 0000000..2e0fafc --- /dev/null +++ b/test/activity.test.js @@ -0,0 +1,19 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { slimActivityTrace } from '../src/activity.js'; + +describe('activity.js', () => { + it('slimActivityTrace keeps model/pack steps for history', () => { + const slim = slimActivityTrace({ + title: 'Готово', + context: { model: 'qwen3:8b', pack: 'ordinary', persona: 'neutral' }, + steps: [ + { id: 'model', kind: 'model', label: 'Модель', detail: 'qwen3:8b', status: 'done' }, + { id: 'generate', kind: 'generate', label: 'Generate', detail: 'ok', status: 'done' }, + ], + }); + assert.ok(slim); + assert.equal(slim.context.model, 'qwen3:8b'); + assert.equal(slim.steps.length, 2); + }); +});