micro-models-agent 2.12.0 → 2.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/assets/app.js +22 -2
- package/dist/assets/index.html +39 -9
- package/dist/assets/js/dom.js +15 -0
- package/dist/assets/js/feed.js +199 -45
- package/dist/assets/js/sidebar.js +512 -0
- package/dist/assets/js/tabs.js +8 -2
- package/dist/assets/setup.js +267 -8
- package/dist/assets/styles.css +185 -28
- package/dist/i18n/en.json +92 -7
- package/dist/i18n/ru.json +92 -7
- package/dist/main.js +634 -101
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,41 @@ All notable changes to Micro Models Agent (MMA) will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [2.14.0] - 2026-10-01
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- **Web UI**: file actions now render as quiet terminal-style lines instead of boxed cards (`src/modules/webui/assets/js/feed.js`, `styles.css`). Each file tool shows `◇ <label> · <path-link> [copy-icon] <status>` in the tool-category color — no more Open/Download/Path pill buttons (path click opens the viewer, copy is an icon-only hover action). Consecutive file rows collapse into an expandable `│ N files` group with a chevron toggle. File-type badges are gone (format detection stays silent for highlighting only; draw.io hint is verbose-only). Dead strings `web.card.open/download/copy_path/download_title` removed, count via new `web.card.files_*` (en+ru). Tests: `tests/webui-assets.test.ts`, `tests/webui-smoke.test.ts`, new `tests/e2e/web-filecard.e2e-test.ts`.
|
|
14
|
+
|
|
15
|
+
## [2.13.1] - 2026-09-30
|
|
16
|
+
|
|
17
|
+
### Fixed
|
|
18
|
+
|
|
19
|
+
- **In-loop critic is now selective instead of always-on** (`src/core/agent/critic-gate.ts`, `loop-state.ts`, `src/core/agent.ts`): evidence is scoped to the current run (`LoopState.criticHistoryMark` + `gatherTurnToolEvents(cm, mark)`), so tool calls from previous questions no longer testify against the current task (observed: weather lookups from an earlier turn caused 4 false `critical` verdicts on a chat-only "show a React example" answer). Chat-only turns (no tool events in the run) now `pass` without an LLM call and without spending `maxCalls`; the dead mid-turn `mutationsSinceCritic` trigger branch is removed.
|
|
20
|
+
- **Advice and repeat verdicts no longer retry** (`src/core/agent/critic-gate.ts`, `src/i18n/en.json`, `ru.json`): only a fresh `critical` inside `maxRepairCycles` repairs + retries; `concern`, over-budget `critical` and near-identical repeats inject `<system-summary>` (`critic.advice`, new `critic.repeat` — "change approach or finish honestly") and `pass`. Previously every non-ok verdict retried identically until `maxCalls`, looping the same answer 4 times.
|
|
21
|
+
- **Critic sees chat-delivered answers** (`src/modules/critic/types.ts`, `evidence.ts`, `client.ts`, `src/core/agent.ts`): `EvidencePack.answerExcerpt` (≤2000 chars, secrets redacted) carries the final text for mutation-free turns, and the critic prompt states that a chat answer without files is not itself an issue. Previously the critic judged chat tasks blind (tools only) and demanded file artifacts the user never asked for.
|
|
22
|
+
- **Honest critic evidence bits** (`src/modules/critic/evidence.ts`, `provider.ts`): successful bash no longer gets a synthesized `exitCode: 0` (`undefined` renders as `?`; `1` stays for known failures); setting `critic.provider` without `critic.model` now warns instead of silently doing nothing.
|
|
23
|
+
- **Method calls no longer flagged as missing files** (`src/modules/hallucination/js-identifiers.ts`): `extractFileLikeTokens` drops tokens glued to `(` (`prev.map(`, `todos.map(` — `map` is a sourcemap extension), fixing false "mentioned files not found" warnings on code answers. Applies to all consumers (factual check, execution auditor, verifier).
|
|
24
|
+
- **Rejected drafts no longer duplicate in the feed** (`src/core/agent/artifact-resolve.ts`, `hallucination-gate.ts`, `critic-gate.ts`, `audit-gate.ts`, `src/core/types.ts`, `src/modules/webui/forward.ts`, `assets/js/feed.js`, `app.js`): a gate-rejected draft was already streamed before the verdict, so the re-answer stacked as a visible double (observed: artifact-claim false positive on bare `game.ts` vs `src/game.ts`). Gates now emit `onMeta(…, "retract")` (new `MetaKind`, user line `ui.draft_retracted` en+ru); the web forwarder sends a dedicated SSE `retract` event and `feed.js onRetract` removes the rejected block (also from turn copy buffers) with a one-line note, the terminal prints the same line. Rejections are logged as `claim_retry` session entries (`SessionLogger.logClaimRetry`, new `SessionLogEntry.kind`), closing the `llm_usage`-without-`assistant` gap; critic verify exceptions now log a fail-open diagnostic instead of a silent lone `critic_check`.
|
|
25
|
+
- **Artifact ground-check resolves bare basenames** (`src/core/agent/artifact-resolve.ts`): `resolveOneArtifact` goes through the auditor's `findExistingFile` (exact path, then basename/suffix subtree walk in the `baseDir` jail) instead of a strict `resolve`, so `game.ts` matches `src/game.ts` and no longer triggers a false claim-retry.
|
|
26
|
+
|
|
27
|
+
## [2.13.0] - 2026-09-30
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- **Web UI**: sidebar tabs — Session / Map / Trace. Square icon tabs with the active
|
|
32
|
+
tab name shown below them (`assets/js/sidebar.js`, `index.html`, `styles.css`).
|
|
33
|
+
- **Map tab**: project-map index state — status, last-updated timestamp, watcher,
|
|
34
|
+
files/symbols/tokens, root, per-language breakdown, and a Re-index button
|
|
35
|
+
(`POST /api/index`). `IndexerModule.mapInfo()` + `ProjectMapInfo` host contract.
|
|
36
|
+
- **Trace tab**: live per-session log of tool calls, phases, plan updates, errors,
|
|
37
|
+
questions and the answer text stream; clicking a row opens a detail panel (full
|
|
38
|
+
args, output, metadata) and doubles the sidebar width. Desktop only (hidden ≤760px).
|
|
39
|
+
- **i18n**: `web.sidebar.tab_*`, `web.sidebar.map_*`, `web.sidebar.trace_*`,
|
|
40
|
+
`web.sidebar.detail_*` (en + ru).
|
|
41
|
+
|
|
7
42
|
## [2.12.0] - 2026-09-29
|
|
8
43
|
|
|
9
44
|
### Added
|
package/dist/assets/app.js
CHANGED
|
@@ -5,6 +5,7 @@ import { S, hooks } from "./js/store.js";
|
|
|
5
5
|
import {
|
|
6
6
|
onText, onReasoning, onMeta, onPhase, onToolStart, onToolEnd,
|
|
7
7
|
onTurnEnd, onErrorEvent, onCommandOutput, onLog, onPlan, onQuestion,
|
|
8
|
+
onRetract,
|
|
8
9
|
showEmptyFeed, renderPlan, renderUsage, renderEnvironment, renderWorkspace,
|
|
9
10
|
feedHasContent, loadHistory,
|
|
10
11
|
} from "./js/feed.js";
|
|
@@ -19,6 +20,11 @@ loadMemory, loadSkills, loadPlugins, applyTheme, currentTheme, applyFontSize, se
|
|
|
19
20
|
import {
|
|
20
21
|
initRail, setPlan as railPlan, railEvent, applyRailI18n,
|
|
21
22
|
} from "./js/rail.js";
|
|
23
|
+
import {
|
|
24
|
+
initSidebar, sidebarSessionChanged, sidebarMapState,
|
|
25
|
+
sidebarTraceEvent, sidebarTurnEnd, sidebarBusy, sidebarMapIndicator,
|
|
26
|
+
applySidebarI18n,
|
|
27
|
+
} from "./js/sidebar.js";
|
|
22
28
|
|
|
23
29
|
document.querySelectorAll("[data-collapse]").forEach((head) => {
|
|
24
30
|
const initArrow = head.querySelector(".arrow");
|
|
@@ -77,6 +83,9 @@ function renderState(snapshot, busy) {
|
|
|
77
83
|
renderSelectors();
|
|
78
84
|
}
|
|
79
85
|
renderEnvironment(S.state.host);
|
|
86
|
+
sidebarMapState(S.state.host);
|
|
87
|
+
sidebarMapIndicator(S.state.host);
|
|
88
|
+
sidebarSessionChanged(sessionId);
|
|
80
89
|
renderWorkspace(S.state.host);
|
|
81
90
|
renderSessionsDropdown();
|
|
82
91
|
}
|
|
@@ -131,8 +140,9 @@ const handlers = [
|
|
|
131
140
|
["tool_end", onToolEnd],
|
|
132
141
|
["plan", onPlan],
|
|
133
142
|
["question", onQuestion],
|
|
134
|
-
["turn_end", onTurnEnd],
|
|
143
|
+
["turn_end", (d) => { onTurnEnd(d); sidebarTurnEnd(); sidebarBusy(false); }],
|
|
135
144
|
["log", onLog],
|
|
145
|
+
["retract", (d) => onRetract(d.reason || "", d.sessionId)],
|
|
136
146
|
];
|
|
137
147
|
for (const [event, fn] of handlers) {
|
|
138
148
|
es.addEventListener(event, (e) => {
|
|
@@ -149,6 +159,11 @@ for (const ev of ["tool_start", "tool_end", "text", "plan"]) {
|
|
|
149
159
|
else railEvent(ev, d.name);
|
|
150
160
|
});
|
|
151
161
|
}
|
|
162
|
+
// Sidebar Trace: live per-session event log. Feed handlers stay untouched.
|
|
163
|
+
for (const ev of ["tool_start", "tool_end", "phase", "plan", "question"]) {
|
|
164
|
+
es.addEventListener(ev, (e) => { const d = parse(e); if (d) sidebarTraceEvent(ev, d); });
|
|
165
|
+
}
|
|
166
|
+
es.addEventListener("text", (e) => { const d = parse(e); if (d) sidebarTraceEvent("text", d); });
|
|
152
167
|
es.addEventListener("command", (e) => {
|
|
153
168
|
const d = parse(e);
|
|
154
169
|
if (d && typeof d.text === "string") onCommandOutput(d.text, d.sessionId);
|
|
@@ -158,6 +173,8 @@ es.addEventListener("command", (e) => {
|
|
|
158
173
|
if (d) {
|
|
159
174
|
S.state.busy = !!d.busy;
|
|
160
175
|
reflectBusy();
|
|
176
|
+
sidebarBusy(!!d.busy);
|
|
177
|
+
refresh();
|
|
161
178
|
if (d.usage && typeof d.usage === "object") {
|
|
162
179
|
S.state.host = Object.assign({}, S.state.host, d.usage);
|
|
163
180
|
renderUsage(S.state.host);
|
|
@@ -167,7 +184,7 @@ es.addEventListener("command", (e) => {
|
|
|
167
184
|
es.addEventListener("error", (e) => {
|
|
168
185
|
if (!e.data) return;
|
|
169
186
|
const d = parse(e);
|
|
170
|
-
if (d) onErrorEvent(d);
|
|
187
|
+
if (d) { onErrorEvent(d); sidebarTraceEvent("error", d); }
|
|
171
188
|
});
|
|
172
189
|
}
|
|
173
190
|
|
|
@@ -239,6 +256,7 @@ function applyStaticI18n() {
|
|
|
239
256
|
renderSelectors();
|
|
240
257
|
renderSessionsDropdown();
|
|
241
258
|
applyRailI18n();
|
|
259
|
+
applySidebarI18n();
|
|
242
260
|
if (msgs.querySelector(".empty")) showEmptyFeed();
|
|
243
261
|
if (connStatus && !connStatus.hidden) setConnStatus(true);
|
|
244
262
|
}
|
|
@@ -265,6 +283,8 @@ async function boot() {
|
|
|
265
283
|
renderSettings();
|
|
266
284
|
await loadConfig();
|
|
267
285
|
initRail(S.state.config && S.state.config.ui ? S.state.config.ui.activityRail : undefined);
|
|
286
|
+
initSidebar();
|
|
287
|
+
applySidebarI18n();
|
|
268
288
|
await loadModels();
|
|
269
289
|
await loadProviders();
|
|
270
290
|
await loadSessions();
|
package/dist/assets/index.html
CHANGED
|
@@ -96,19 +96,49 @@
|
|
|
96
96
|
</div>
|
|
97
97
|
|
|
98
98
|
<div class="sb">
|
|
99
|
-
<div class="
|
|
100
|
-
<button class="
|
|
101
|
-
|
|
99
|
+
<div class="sb-tabs" role="tablist" aria-label="sidebar">
|
|
100
|
+
<button class="sb-tab on" type="button" role="tab" data-tab="session" aria-selected="true" aria-controls="pane-session" title="Session">
|
|
101
|
+
<span class="sb-ico" data-icon="session" aria-hidden="true"></span>
|
|
102
|
+
<span class="sb-dot" aria-hidden="true"></span>
|
|
103
|
+
</button>
|
|
104
|
+
<button class="sb-tab" type="button" role="tab" data-tab="map" aria-selected="false" aria-controls="pane-map" title="Map">
|
|
105
|
+
<span class="sb-ico" data-icon="map" aria-hidden="true"></span>
|
|
106
|
+
<span class="sb-dot" aria-hidden="true"></span>
|
|
107
|
+
</button>
|
|
108
|
+
<button class="sb-tab" type="button" role="tab" data-tab="trace" aria-selected="false" aria-controls="pane-trace" title="Trace">
|
|
109
|
+
<span class="sb-ico" data-icon="trace" aria-hidden="true"></span>
|
|
110
|
+
<span class="sb-dot" aria-hidden="true"></span>
|
|
111
|
+
</button>
|
|
102
112
|
</div>
|
|
113
|
+
<div class="sb-name" id="sbName"><span class="sb-ico" data-icon="session" aria-hidden="true"></span><span id="sbNameText">Session</span></div>
|
|
103
114
|
|
|
104
|
-
<div class="
|
|
105
|
-
<
|
|
106
|
-
|
|
115
|
+
<div class="sb-pane" id="pane-session" role="tabpanel" data-pane="session">
|
|
116
|
+
<div class="sec plan-sec">
|
|
117
|
+
<button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="planHeaderLabel">plan</span> <span class="n" id="planCount"></span></button>
|
|
118
|
+
<div class="sec-body" id="plan"></div>
|
|
119
|
+
</div>
|
|
120
|
+
<div class="sec usage-sec">
|
|
121
|
+
<button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="usageHeaderLabel">session · usage</span></button>
|
|
122
|
+
<div class="sec-body" id="usage"></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="sec env-sec">
|
|
125
|
+
<button class="sec-h" type="button" data-collapse aria-expanded="true"><span class="arrow">▾</span> <span id="envHeaderLabel">environment</span></button>
|
|
126
|
+
<div class="sec-body" id="env"></div>
|
|
127
|
+
</div>
|
|
107
128
|
</div>
|
|
108
129
|
|
|
109
|
-
<div class="
|
|
110
|
-
<
|
|
111
|
-
|
|
130
|
+
<div class="sb-pane" id="pane-map" role="tabpanel" data-pane="map" hidden>
|
|
131
|
+
<div class="sec map-sec">
|
|
132
|
+
<div class="sec-body" id="mapBody"></div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
<div class="sb-pane" id="pane-trace" role="tabpanel" data-pane="trace" hidden>
|
|
137
|
+
<div class="trace-list" id="traceList"></div>
|
|
138
|
+
<div class="trc-detail" id="traceDetail" hidden>
|
|
139
|
+
<div class="detail-h"><span class="g" id="detailGlyph">◇</span><span id="detailTitle"></span><button class="x linkbtn" type="button" id="detailClose">✕</button></div>
|
|
140
|
+
<div class="detail-b" id="detailBody"></div>
|
|
141
|
+
</div>
|
|
112
142
|
</div>
|
|
113
143
|
</div>
|
|
114
144
|
</div>
|
package/dist/assets/js/dom.js
CHANGED
|
@@ -74,6 +74,21 @@ search: [
|
|
|
74
74
|
["circle", { cx: 11, cy: 11, r: 8 }],
|
|
75
75
|
["path", { d: "m21 21-4.3-4.3" }],
|
|
76
76
|
],
|
|
77
|
+
"list-checks": [
|
|
78
|
+
["path", { d: "m3 17 2 2 4-4" }],
|
|
79
|
+
["path", { d: "m3 7 2 2 4-4" }],
|
|
80
|
+
["path", { d: "M13 6h8" }],
|
|
81
|
+
["path", { d: "M13 12h8" }],
|
|
82
|
+
["path", { d: "M13 18h8" }],
|
|
83
|
+
],
|
|
84
|
+
map: [
|
|
85
|
+
["path", { d: "M14.106 5.553a2 2 0 0 0 1.788 0l3.659-1.83A1 1 0 0 1 21 4.619v12.764a1 1 0 0 1-.553.894l-4.553 2.277a2 2 0 0 1-1.788 0l-4.212-2.106a2 2 0 0 0-1.788 0l-3.659 1.83A1 1 0 0 1 3 19.381V6.618a1 1 0 0 1 .553-.894l4.553-2.277a2 2 0 0 1 1.788 0z" }],
|
|
86
|
+
["path", { d: "M15 5.764v15" }],
|
|
87
|
+
["path", { d: "M9 3.236v15" }],
|
|
88
|
+
],
|
|
89
|
+
activity: [
|
|
90
|
+
["path", { d: "M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2" }],
|
|
91
|
+
],
|
|
77
92
|
"arrow-up": [["path", { d: "M12 19V5" }], ["path", { d: "m5 12 7-7 7 7" }]],
|
|
78
93
|
panel: [
|
|
79
94
|
["rect", { width: 18, height: 18, x: 3, y: 3, rx: 2, ry: 2 }],
|
package/dist/assets/js/feed.js
CHANGED
|
@@ -36,12 +36,16 @@ export function makeFeed() {
|
|
|
36
36
|
|
|
37
37
|
export function showEmptyFeed() {
|
|
38
38
|
msgs.replaceChildren(emptyView());
|
|
39
|
+
follow = true;
|
|
40
|
+
hideToLatest();
|
|
39
41
|
syncUrl();
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export function clearFeed(feed) {
|
|
43
45
|
if (!feed) return;
|
|
44
46
|
feed.replaceChildren(emptyView());
|
|
47
|
+
follow = true;
|
|
48
|
+
hideToLatest();
|
|
45
49
|
const tab = S.tabs.find((x) => x.feed === feed);
|
|
46
50
|
if (tab) tab.planCard = null;
|
|
47
51
|
if (feed === activeFeed()) scroll();
|
|
@@ -71,10 +75,80 @@ function nearBottom() {
|
|
|
71
75
|
return msgs.scrollHeight - msgs.scrollTop - msgs.clientHeight < 140;
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
78
|
+
// Sticky follow: while the reader is at the live edge we pin to the bottom
|
|
79
|
+
// on every append; the moment they scroll up we leave them alone (the
|
|
80
|
+
// "jump to latest" pill offers the trip back). A new user turn always
|
|
81
|
+
// force-scrolls — otherwise one missed pin cascades into "no autoscroll"
|
|
82
|
+
// for the whole turn, since every later `scroll()` would see `nearBottom()`
|
|
83
|
+
// as false and skip.
|
|
84
|
+
let follow = true;
|
|
85
|
+
|
|
86
|
+
function scroll(force) {
|
|
87
|
+
if (force || follow || nearBottom()) {
|
|
88
|
+
msgs.scrollTop = msgs.scrollHeight;
|
|
89
|
+
follow = true;
|
|
90
|
+
hideToLatest();
|
|
91
|
+
} else {
|
|
92
|
+
maybeShowToLatest();
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Force-pin to the live edge (send / history load / pill click).
|
|
97
|
+
export function scrollToBottom(smooth) {
|
|
98
|
+
follow = true;
|
|
99
|
+
hideToLatest();
|
|
100
|
+
if (smooth && typeof msgs.scrollTo === "function") {
|
|
101
|
+
try {
|
|
102
|
+
msgs.scrollTo({ top: msgs.scrollHeight, behavior: "smooth" });
|
|
103
|
+
return;
|
|
104
|
+
} catch (err) {
|
|
105
|
+
/* fall through to instant */
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
msgs.scrollTop = msgs.scrollHeight;
|
|
76
109
|
}
|
|
77
110
|
|
|
111
|
+
export function isFollowing() {
|
|
112
|
+
return follow;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Floating "jump to latest" pill: appears only when the reader scrolled up
|
|
116
|
+
// and new content landed below. Created lazily so tests/SSR without `.chat`
|
|
117
|
+
// never break.
|
|
118
|
+
let toLatestBtn = null;
|
|
119
|
+
function ensureToLatest() {
|
|
120
|
+
if (toLatestBtn) return toLatestBtn;
|
|
121
|
+
const chat = document.querySelector(".chat");
|
|
122
|
+
if (!chat) return null;
|
|
123
|
+
toLatestBtn = el("button", "to-latest");
|
|
124
|
+
toLatestBtn.type = "button";
|
|
125
|
+
toLatestBtn.hidden = true;
|
|
126
|
+
toLatestBtn.addEventListener("click", () => scrollToBottom(true));
|
|
127
|
+
chat.appendChild(toLatestBtn);
|
|
128
|
+
return toLatestBtn;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function maybeShowToLatest() {
|
|
132
|
+
const btn = ensureToLatest();
|
|
133
|
+
if (!btn) return;
|
|
134
|
+
btn.textContent = "↓ " + t("web.scroll.latest");
|
|
135
|
+
btn.hidden = false;
|
|
136
|
+
msgs.classList.add("unfollowed");
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function hideToLatest() {
|
|
140
|
+
if (toLatestBtn) toLatestBtn.hidden = true;
|
|
141
|
+
msgs.classList.remove("unfollowed");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
msgs.addEventListener("scroll", () => {
|
|
145
|
+
const nb = nearBottom();
|
|
146
|
+
follow = nb;
|
|
147
|
+
msgs.classList.toggle("scrolled", msgs.scrollTop > 24);
|
|
148
|
+
if (nb) hideToLatest();
|
|
149
|
+
else if (liveFeed() && feedHasContent(liveFeed())) maybeShowToLatest();
|
|
150
|
+
});
|
|
151
|
+
|
|
78
152
|
export function appendTo(feed, node) {
|
|
79
153
|
if (!feed) return node;
|
|
80
154
|
feed.appendChild(node);
|
|
@@ -109,6 +183,9 @@ function clearBusy(feed) {
|
|
|
109
183
|
export function showBusy(feed) {
|
|
110
184
|
feed = feed || liveFeed();
|
|
111
185
|
if (!feed) return;
|
|
186
|
+
// The delayed post-send call must not flash a spinner over a stream that
|
|
187
|
+
// already started (fast first token).
|
|
188
|
+
if (S.curMd || S.curThink) return;
|
|
112
189
|
clearEmpty(feed);
|
|
113
190
|
if (feed.querySelector(".busy")) return;
|
|
114
191
|
if (S.busyTimer) {
|
|
@@ -142,7 +219,10 @@ function flushRun() {
|
|
|
142
219
|
// replacing the per-action lines (mirrors `Renderer.flushRecon`). `normal`
|
|
143
220
|
// and `verbose` keep the individual action lines and add nothing.
|
|
144
221
|
if (verbosity() === "quiet" && S.runReconOnly && S.runFails === 0 && S.runCount > 1) {
|
|
145
|
-
|
|
222
|
+
// Fade the folded lines out instead of snapping them away.
|
|
223
|
+
const folding = S.runNodes.slice();
|
|
224
|
+
folding.forEach((node) => node.classList.add("fold-out"));
|
|
225
|
+
window.setTimeout(() => folding.forEach((node) => node.remove()), 160);
|
|
146
226
|
const line = el("div", "grouped");
|
|
147
227
|
line.appendChild(el("span", "pipe", "│"));
|
|
148
228
|
const dur = formatMs(S.runDuration);
|
|
@@ -177,6 +257,7 @@ function mdOpts() {
|
|
|
177
257
|
// Close the active Markdown segment (if any) and clear the text S.state.
|
|
178
258
|
export function finishMd() {
|
|
179
259
|
if (S.curMd) {
|
|
260
|
+
S.curMd.root.classList.remove("streaming");
|
|
180
261
|
S.curMd.end();
|
|
181
262
|
S.curMd = null;
|
|
182
263
|
}
|
|
@@ -195,6 +276,7 @@ export function onText(chunk, sid) {
|
|
|
195
276
|
flushRun();
|
|
196
277
|
S.curText = "";
|
|
197
278
|
S.curMd = mountMarkdown(feed, mdOpts());
|
|
279
|
+
S.curMd.root.classList.add("streaming");
|
|
198
280
|
S.turnRoots.push(S.curMd.root);
|
|
199
281
|
}
|
|
200
282
|
S.curText += chunk;
|
|
@@ -204,17 +286,21 @@ export function onText(chunk, sid) {
|
|
|
204
286
|
}
|
|
205
287
|
|
|
206
288
|
export function finishThink() {
|
|
207
|
-
if (S.curThink
|
|
208
|
-
|
|
209
|
-
if (
|
|
289
|
+
if (S.curThink) {
|
|
290
|
+
if (S.curThink.wrap) S.curThink.wrap.classList.remove("live");
|
|
291
|
+
if (S.curThink.startedAt) {
|
|
292
|
+
const dur = S.curThink.head.querySelector(".dur");
|
|
293
|
+
if (dur) dur.textContent = "· " + ((Date.now() - S.curThink.startedAt) / 1000).toFixed(1) + "s";
|
|
294
|
+
}
|
|
210
295
|
}
|
|
211
296
|
S.curThink = null;
|
|
212
297
|
}
|
|
213
298
|
|
|
214
299
|
// Build the collapsible reasoning block. The global `.think-h` click handler
|
|
215
300
|
// depends on this exact structure: wrap > [head, body], body carries `hidden`.
|
|
216
|
-
|
|
217
|
-
|
|
301
|
+
// `live` marks an in-progress block (pulsing label); history renders static.
|
|
302
|
+
function buildThinkBlock(withDur, live) {
|
|
303
|
+
const wrap = el("div", "think" + (live === false ? "" : " live"));
|
|
218
304
|
const head = el("button", "think-h");
|
|
219
305
|
head.type = "button";
|
|
220
306
|
head.setAttribute("aria-expanded", "false");
|
|
@@ -264,6 +350,31 @@ export function onMeta(chunk, sid, kind) {
|
|
|
264
350
|
if (feed === activeFeed()) scroll();
|
|
265
351
|
}
|
|
266
352
|
|
|
353
|
+
// Гейт забраковал уже отстримленный черновик (SSE `retract`): убираем его
|
|
354
|
+
// из ленты до переответа, иначе финал встанет дублем следом
|
|
355
|
+
// (ses_munvrcfz_na9ze3). Копипаст хода тоже чистим — отклонённого текста
|
|
356
|
+
// в «copy / copy as markdown» быть не должно. Без видимого черновика
|
|
357
|
+
// (пустой ответ, только тулы) — тихо, без шумовой строки.
|
|
358
|
+
export function onRetract(reason, sid) {
|
|
359
|
+
const feed = feedForEvent(sid);
|
|
360
|
+
if (!feed) return;
|
|
361
|
+
clearBusy(feed);
|
|
362
|
+
const draftRoot = S.curMd ? S.curMd.root : null;
|
|
363
|
+
const hadDraft = !!draftRoot || (S.curText != null && S.curText !== "");
|
|
364
|
+
const draftLen = S.curText ? S.curText.length : 0;
|
|
365
|
+
finishMd();
|
|
366
|
+
finishThink();
|
|
367
|
+
if (draftRoot) {
|
|
368
|
+
const idx = S.turnRoots.indexOf(draftRoot);
|
|
369
|
+
if (idx !== -1) S.turnRoots.splice(idx, 1);
|
|
370
|
+
draftRoot.remove();
|
|
371
|
+
}
|
|
372
|
+
if (draftLen > 0) S.turnMd = S.turnMd.slice(0, Math.max(0, S.turnMd.length - draftLen));
|
|
373
|
+
if (!hadDraft) return;
|
|
374
|
+
if (reason) appendTo(feed, el("div", "metaln", reason));
|
|
375
|
+
if (feed === activeFeed()) scroll();
|
|
376
|
+
}
|
|
377
|
+
|
|
267
378
|
export function onPhase(phase, sid) {
|
|
268
379
|
const feed = feedForEvent(sid);
|
|
269
380
|
if (phase === "thinking") {
|
|
@@ -291,6 +402,7 @@ export function onToolStart(data) {
|
|
|
291
402
|
}
|
|
292
403
|
const status = el("span", "ok", "");
|
|
293
404
|
line.appendChild(status);
|
|
405
|
+
line.classList.add("running");
|
|
294
406
|
appendTo(feed, line);
|
|
295
407
|
S.toolNodes.set(data.toolCallId, { status, line });
|
|
296
408
|
if (categoryOf(data.name) !== "recon") S.runReconOnly = false;
|
|
@@ -332,56 +444,91 @@ function safeUrl(url) {
|
|
|
332
444
|
return null;
|
|
333
445
|
}
|
|
334
446
|
|
|
335
|
-
function
|
|
447
|
+
function maybeGroupFilerow(feed, row) {
|
|
448
|
+
const prev = row.previousElementSibling;
|
|
449
|
+
const group = prev && prev.classList && prev.classList.contains("filegroup") ? prev : null;
|
|
450
|
+
const prevRow = prev && prev.classList && prev.classList.contains("filerow") ? prev : null;
|
|
451
|
+
if (group && group.dataset.open !== "1") {
|
|
452
|
+
group.querySelector(".items").appendChild(row);
|
|
453
|
+
if (row.dataset.fail === "1") group.dataset.fail = "1";
|
|
454
|
+
const n = group.querySelectorAll(".items > .filerow").length;
|
|
455
|
+
group.querySelector(".count").textContent = pluralCount("web.card.files", n);
|
|
456
|
+
group.querySelector(".ok").textContent = group.dataset.fail === "1" ? " ✗" : " ✓";
|
|
457
|
+
return group;
|
|
458
|
+
}
|
|
459
|
+
if (prevRow) {
|
|
460
|
+
const wrap = el("div", "filegroup");
|
|
461
|
+
const head = el("button", "head");
|
|
462
|
+
head.setAttribute("aria-expanded", "false");
|
|
463
|
+
head.appendChild(el("span", "g", "│ "));
|
|
464
|
+
head.appendChild(el("span", "count", pluralCount("web.card.files", 2)));
|
|
465
|
+
const fail = prevRow.dataset.fail === "1" || row.dataset.fail === "1";
|
|
466
|
+
if (fail) wrap.dataset.fail = "1";
|
|
467
|
+
head.appendChild(el("span", "ok", fail ? " ✗" : " ✓"));
|
|
468
|
+
const chev = icon("chevron-down");
|
|
469
|
+
head.appendChild(chev);
|
|
470
|
+
const items = el("div", "items hidden");
|
|
471
|
+
feed.replaceChild(wrap, prevRow);
|
|
472
|
+
items.appendChild(prevRow);
|
|
473
|
+
items.appendChild(row);
|
|
474
|
+
wrap.appendChild(head);
|
|
475
|
+
wrap.appendChild(items);
|
|
476
|
+
head.addEventListener("click", () => {
|
|
477
|
+
const open = wrap.dataset.open === "1";
|
|
478
|
+
wrap.dataset.open = open ? "" : "1";
|
|
479
|
+
head.setAttribute("aria-expanded", open ? "false" : "true");
|
|
480
|
+
items.classList.toggle("hidden", open);
|
|
481
|
+
});
|
|
482
|
+
return wrap;
|
|
483
|
+
}
|
|
484
|
+
return null;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function cardFor(name, args, sid, ok) {
|
|
336
488
|
const feed = feedForEvent(sid);
|
|
337
489
|
if (!feed) return null;
|
|
338
490
|
if (FILE_TOOLS.indexOf(name) !== -1) {
|
|
339
491
|
const path = args.path || args.file || args.dir;
|
|
340
492
|
if (!path) return null;
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
nameEl.title = path;
|
|
346
|
-
top.appendChild(nameEl);
|
|
347
|
-
card.appendChild(top);
|
|
348
|
-
const bottom = el("div", "fc-bottom");
|
|
349
|
-
bottom.appendChild(el("span", "lab", labelOf(name)));
|
|
350
|
-
const actions = el("span", "fc-actions");
|
|
351
|
-
const baseName = String(path.split(/[\\/]/).pop() || path);
|
|
493
|
+
const row = el("div", "filerow " + categoryOf(name));
|
|
494
|
+
row.appendChild(el("span", "g", "◇"));
|
|
495
|
+
row.appendChild(el("span", "lab", labelOf(name)));
|
|
496
|
+
row.appendChild(el("span", "sep", " · "));
|
|
352
497
|
if (OPENABLE_TOOLS.indexOf(name) !== -1) {
|
|
353
|
-
const openLink = el("a", "
|
|
498
|
+
const openLink = el("a", "path", String(path).split(/[\\/]/).pop() || path);
|
|
354
499
|
openLink.href = "/api/file?path=" + encodeURIComponent(path);
|
|
355
500
|
openLink.target = "_blank";
|
|
356
501
|
openLink.rel = "noopener noreferrer";
|
|
357
|
-
openLink.title = t("web.card.open_title");
|
|
358
|
-
|
|
359
|
-
const
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
502
|
+
openLink.title = t("web.card.open_title") + " · " + path;
|
|
503
|
+
row.appendChild(openLink);
|
|
504
|
+
const tail = String(path).split(/[\\/]/).slice(0, -1).join("/");
|
|
505
|
+
if (tail) row.appendChild(el("span", "tail", " · " + tail));
|
|
506
|
+
} else {
|
|
507
|
+
const s = el("span", "tail", path);
|
|
508
|
+
s.title = path;
|
|
509
|
+
row.appendChild(s);
|
|
364
510
|
}
|
|
365
|
-
const copyBtn = el("button", "
|
|
511
|
+
const copyBtn = el("button", "copy-btn");
|
|
366
512
|
copyBtn.type = "button";
|
|
367
513
|
copyBtn.title = t("web.card.copy_title");
|
|
514
|
+
copyBtn.setAttribute("aria-label", t("web.card.copy_title"));
|
|
515
|
+
copyBtn.appendChild(icon("copy"));
|
|
368
516
|
copyBtn.addEventListener("click", () => {
|
|
369
517
|
copyText(path).then((ok) => {
|
|
370
|
-
copyBtn.
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
}, 1400);
|
|
518
|
+
copyBtn.replaceChildren(icon(ok ? "check" : "copy"));
|
|
519
|
+
if (!ok) { const st = row.querySelector(".st"); if (st) st.textContent = " ⚠"; }
|
|
520
|
+
window.setTimeout(() => copyBtn.replaceChildren(icon("copy")), 1400);
|
|
374
521
|
});
|
|
375
522
|
});
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
if (/\.drawio(\.xml)?$/i.test(path)) {
|
|
380
|
-
|
|
381
|
-
card.appendChild(hint);
|
|
523
|
+
row.appendChild(copyBtn);
|
|
524
|
+
row.appendChild(el("span", "st", ok === false ? " ✗" : ok === true ? " ✓" : ""));
|
|
525
|
+
if (ok === false) row.dataset.fail = "1";
|
|
526
|
+
if (/\.drawio(\.xml)?$/i.test(path) && verbosity() === "verbose") {
|
|
527
|
+
row.appendChild(el("span", "detail", "↳ " + t("web.card.drawio_hint")));
|
|
382
528
|
}
|
|
383
|
-
appendTo(feed,
|
|
384
|
-
|
|
529
|
+
appendTo(feed, row);
|
|
530
|
+
try { maybeGroupFilerow(feed, row); } catch (err) { /* fail-open: plain row stays */ }
|
|
531
|
+
return row;
|
|
385
532
|
}
|
|
386
533
|
if (LINK_TOOLS.indexOf(name) !== -1 && args.url) {
|
|
387
534
|
const line = el("div", "linkline");
|
|
@@ -419,6 +566,7 @@ export function onToolEnd(data) {
|
|
|
419
566
|
const node = S.toolNodes.get(data.toolCallId);
|
|
420
567
|
const line = node ? node.line : null;
|
|
421
568
|
if (node) {
|
|
569
|
+
node.line.classList.remove("running");
|
|
422
570
|
const dur = formatMs(data.durationMs);
|
|
423
571
|
node.status.textContent = data.success ? " ✓" + (dur ? " " + dur : "") : " ✗" + (dur ? " " + dur : "");
|
|
424
572
|
if (!data.success) {
|
|
@@ -436,7 +584,7 @@ export function onToolEnd(data) {
|
|
|
436
584
|
// a single `↳` cause instead of the whole model-facing body.
|
|
437
585
|
if (data.success) {
|
|
438
586
|
if (line) S.runNodes.push(line);
|
|
439
|
-
const card = cardFor(data.name, args, data.sessionId);
|
|
587
|
+
const card = cardFor(data.name, args, data.sessionId, data.success);
|
|
440
588
|
if (card) S.runNodes.push(card);
|
|
441
589
|
S.runDuration += typeof data.durationMs === "number" ? data.durationMs : 0;
|
|
442
590
|
} else {
|
|
@@ -445,7 +593,7 @@ export function onToolEnd(data) {
|
|
|
445
593
|
}
|
|
446
594
|
return;
|
|
447
595
|
}
|
|
448
|
-
if (data.success) cardFor(data.name, args, data.sessionId);
|
|
596
|
+
if (data.success) cardFor(data.name, args, data.sessionId, data.success);
|
|
449
597
|
renderToolOutput(line, data.output);
|
|
450
598
|
}
|
|
451
599
|
|
|
@@ -456,6 +604,11 @@ export function onTurnEnd(data) {
|
|
|
456
604
|
finishThink();
|
|
457
605
|
finishMd();
|
|
458
606
|
flushRun();
|
|
607
|
+
// Interrupted tools never get `tool_end`: drop their pulse so nothing
|
|
608
|
+
// looks alive after the turn is over.
|
|
609
|
+
S.toolNodes.forEach((n) => {
|
|
610
|
+
if (n && n.line && feed && n.line.parentNode === feed) n.line.classList.remove("running");
|
|
611
|
+
});
|
|
459
612
|
const owner = S.tabs.find((x) => x.id === data.sessionId);
|
|
460
613
|
if (owner) owner.planCard = null;
|
|
461
614
|
if (!feed) {
|
|
@@ -562,7 +715,7 @@ function renderAssistantText(feed, text) {
|
|
|
562
715
|
}
|
|
563
716
|
|
|
564
717
|
function renderThinkBlock(feed, text) {
|
|
565
|
-
const { wrap, body } = buildThinkBlock(false);
|
|
718
|
+
const { wrap, body } = buildThinkBlock(false, false);
|
|
566
719
|
body.textContent = text || "";
|
|
567
720
|
feed.appendChild(wrap);
|
|
568
721
|
}
|
|
@@ -659,7 +812,8 @@ export async function loadHistory(tab) {
|
|
|
659
812
|
if (feedHasContent(tab.feed)) return;
|
|
660
813
|
renderHistory(tab.feed, r.data.history);
|
|
661
814
|
if (!r.data.history.length) tab.feed.appendChild(el("div", "detail", t("web.history.empty")));
|
|
662
|
-
if (tab.feed === activeFeed())
|
|
815
|
+
if (tab.feed === activeFeed()) scrollToBottom();
|
|
816
|
+
else if (tab.feed === liveFeed()) scrollToBottom();
|
|
663
817
|
}
|
|
664
818
|
|
|
665
819
|
export function syncUrl() {
|