@animalabs/connectome-host 0.6.0 → 0.7.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 +37 -0
- package/package.json +1 -1
- package/src/modules/web-ui-module.ts +22 -0
- package/web/src/App.tsx +7 -1
- package/web/src/Health.tsx +210 -0
- package/web/src/Pins.tsx +53 -50
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,43 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
## 0.7.0 — 2026-07-26
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Health panel: recent LLM call stats, split main vs compression.** Aggregates
|
|
10
|
+
the call ledger the client already receives — fresh input, cache read/write,
|
|
11
|
+
**cached share** (cacheRead ÷ input+cacheRead: what fraction of the prompt was
|
|
12
|
+
reused rather than re-read), prefix-reuse rate, output, average cache
|
|
13
|
+
breakpoints, cost, and errors/refusals — separately for `turn~` (main) and
|
|
14
|
+
`aux~` (compression/summarizer). Includes the last 8 fresh-input values per
|
|
15
|
+
group, so a budget descent can be seen trending down.
|
|
16
|
+
- The `~` is honest: origin is inferred from stream-vs-complete (turns stream,
|
|
17
|
+
compression uses `complete()`), not a definitive tag. Stated in the panel.
|
|
18
|
+
- **Health panel: context composition of the last compile** — head / raw middle /
|
|
19
|
+
summaries by level / tail, with shares and bars. Sourced from `/healthz`, which
|
|
20
|
+
now carries the strategy's in-process render stats: unlike
|
|
21
|
+
`/debug/context/makeup` this costs nothing and makes no `count_tokens` network
|
|
22
|
+
call, so it is safe on the 15s health poll.
|
|
23
|
+
|
|
24
|
+
## Unreleased
|
|
25
|
+
|
|
26
|
+
## 0.6.1 — 2026-07-26
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
|
|
30
|
+
- **Pin id picker sourced the wrong ids.** It read `/debug/context/curve`, which
|
|
31
|
+
looked right but isn't: on a live store 0 of 208 raw entries carried a
|
|
32
|
+
`sourceMessageId`, and the 26 entries that *did* have an `id` were summaries,
|
|
33
|
+
whose ids (`L3-544`) are not message ids. Pinning with one would have created a
|
|
34
|
+
pin matching no message and silently done nothing. The picker now uses the
|
|
35
|
+
client's own message list, where `WelcomeMessageEntry.id` is the store id and
|
|
36
|
+
server-sourced rows are exactly those carrying a store `index`; it also gains a
|
|
37
|
+
text/id filter. Caught by checking the endpoint against a real store before
|
|
38
|
+
anyone used the panel.
|
|
39
|
+
|
|
40
|
+
## Unreleased
|
|
41
|
+
|
|
5
42
|
## 0.6.0 — 2026-07-26
|
|
6
43
|
|
|
7
44
|
### Added
|
package/package.json
CHANGED
|
@@ -1118,6 +1118,28 @@ export class WebUiModule implements Module {
|
|
|
1118
1118
|
} catch {
|
|
1119
1119
|
// Health reads never throw.
|
|
1120
1120
|
}
|
|
1121
|
+
// Rendered context COMPOSITION per agent — head / raw middle / summaries
|
|
1122
|
+
// by level / tail, as actually emitted by the last compile.
|
|
1123
|
+
//
|
|
1124
|
+
// Sourced from the strategy's own render stats, which are already
|
|
1125
|
+
// computed in-process: unlike /debug/context/makeup this costs nothing
|
|
1126
|
+
// and makes no count_tokens network call, so it is safe on the 15s
|
|
1127
|
+
// /healthz poll. Answers "how much of what was actually sent" without
|
|
1128
|
+
// recompiling.
|
|
1129
|
+
try {
|
|
1130
|
+
const composition: Record<string, unknown> = {};
|
|
1131
|
+
for (const agent of app.framework.getAllAgents()) {
|
|
1132
|
+
const name = (agent as unknown as { name: string }).name;
|
|
1133
|
+
const cm = agent.getContextManager() as unknown as {
|
|
1134
|
+
getRenderStats?: () => unknown;
|
|
1135
|
+
};
|
|
1136
|
+
const rs = cm.getRenderStats?.();
|
|
1137
|
+
if (rs) composition[name] = rs;
|
|
1138
|
+
}
|
|
1139
|
+
(snapshot as Record<string, unknown>).contextComposition = composition;
|
|
1140
|
+
} catch {
|
|
1141
|
+
// Health reads never throw.
|
|
1142
|
+
}
|
|
1121
1143
|
// Per-agent runtime settings (context budget, tail, transition pace +
|
|
1122
1144
|
// convergence state) — the same numbers `agent_settings get` returns,
|
|
1123
1145
|
// exposed externally so the fleet hub / connectome-doctor can watch
|
package/web/src/App.tsx
CHANGED
|
@@ -11,7 +11,7 @@ import { LessonsPanel, type LessonRow } from './Lessons';
|
|
|
11
11
|
import { McplPanel, type McplServerRow } from './Mcpl';
|
|
12
12
|
import { SettingsPanel, type SettingsState } from './Settings';
|
|
13
13
|
import { DryContext, type DryContextData } from './DryContext';
|
|
14
|
-
import { PinsPanel, type PinsState } from './Pins';
|
|
14
|
+
import { PinsPanel, type PinsState, type PinCandidate } from './Pins';
|
|
15
15
|
import { FilesPanel, FileViewerModal, type Mount, type FlatEntry, type FileViewer } from './Files';
|
|
16
16
|
import { ContextPanel } from './Context';
|
|
17
17
|
import { ContextDocument } from './ContextDocument';
|
|
@@ -1271,6 +1271,11 @@ export function App() {
|
|
|
1271
1271
|
loaded={pinsLoaded()}
|
|
1272
1272
|
state={pinsState()}
|
|
1273
1273
|
agent={pinsState()?.agent}
|
|
1274
|
+
candidates={messages
|
|
1275
|
+
.filter((m) => m.index !== undefined && m.id)
|
|
1276
|
+
.map<PinCandidate>((m) => ({
|
|
1277
|
+
id: m.id, index: m.index!, participant: m.participant, text: m.text ?? '',
|
|
1278
|
+
}))}
|
|
1274
1279
|
onRefresh={refreshPins}
|
|
1275
1280
|
onAdd={(input) => wire.send({ type: 'pin-add', ...input })}
|
|
1276
1281
|
onRemove={(pinId) => wire.send({ type: 'pin-remove', pinId })}
|
|
@@ -1299,6 +1304,7 @@ export function App() {
|
|
|
1299
1304
|
<HealthPanel
|
|
1300
1305
|
health={health()}
|
|
1301
1306
|
error={healthErr()}
|
|
1307
|
+
ledger={callLedger()?.rows}
|
|
1302
1308
|
onRefresh={() => void loadHealth(true)}
|
|
1303
1309
|
/>
|
|
1304
1310
|
</Show>
|
package/web/src/Health.tsx
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { For, Show } from 'solid-js';
|
|
20
|
+
import type { CallLedgerRow } from '@conhost/web/protocol';
|
|
20
21
|
|
|
21
22
|
/** One active operator alert, keyed `${agent}:${kind}`. `count` increments on
|
|
22
23
|
* every re-fire of the same key so a repeating klaxon reads as one row. */
|
|
@@ -33,8 +34,22 @@ export interface OpsAlert {
|
|
|
33
34
|
/** Shape of GET /healthz — framework healthSnapshot() plus the host's
|
|
34
35
|
* compressionQuarantine / runtimeSettings extensions. All fields optional
|
|
35
36
|
* and defensively read: health rendering must survive version skew. */
|
|
37
|
+
/** Rendered composition of the last compile — what was actually SENT. */
|
|
38
|
+
export interface ContextComposition {
|
|
39
|
+
head?: { messages: number; tokens: number };
|
|
40
|
+
tail?: { messages: number; tokens: number };
|
|
41
|
+
middleRaw?: { messages: number; tokens: number };
|
|
42
|
+
summaries?: Record<string, { count: number; tokens: number }>;
|
|
43
|
+
total?: { messages: number; tokens: number };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** One provider call. Aliased to the wire type so the two cannot drift; only
|
|
47
|
+
* the fields this panel aggregates are read. */
|
|
48
|
+
export type LedgerRow = CallLedgerRow;
|
|
49
|
+
|
|
36
50
|
export interface HealthSnapshot {
|
|
37
51
|
at?: string;
|
|
52
|
+
contextComposition?: Record<string, ContextComposition>;
|
|
38
53
|
uptimeSec?: number;
|
|
39
54
|
gate?: Record<string, unknown> | null;
|
|
40
55
|
pendingRequests?: number;
|
|
@@ -126,8 +141,185 @@ export function OpsAlertStrip(props: {
|
|
|
126
141
|
);
|
|
127
142
|
}
|
|
128
143
|
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Aggregate the call ledger by origin.
|
|
147
|
+
*
|
|
148
|
+
* `originEstimate` is the main-vs-compression split, and the `~` is honest: it
|
|
149
|
+
* is derived from stream-vs-complete (agent turns stream; compression and
|
|
150
|
+
* summarizer calls use complete()), NOT from a definitive origin tag. It is a
|
|
151
|
+
* reliable proxy in practice, but it is an inference.
|
|
152
|
+
*/
|
|
153
|
+
interface CallAgg {
|
|
154
|
+
calls: number;
|
|
155
|
+
input: number;
|
|
156
|
+
output: number;
|
|
157
|
+
cacheRead: number;
|
|
158
|
+
cacheWrite: number;
|
|
159
|
+
cost: number;
|
|
160
|
+
hits: number;
|
|
161
|
+
errors: number;
|
|
162
|
+
refusals: number;
|
|
163
|
+
breakpoints: number;
|
|
164
|
+
lastInputs: number[];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const EMPTY_AGG = (): CallAgg => ({
|
|
168
|
+
calls: 0, input: 0, output: 0, cacheRead: 0, cacheWrite: 0,
|
|
169
|
+
cost: 0, hits: 0, errors: 0, refusals: 0, breakpoints: 0, lastInputs: [],
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** Verdicts that mean the prefix was actually reused. */
|
|
173
|
+
const HIT_VERDICTS = new Set(['HIT', 'hit+extend']);
|
|
174
|
+
|
|
175
|
+
function aggregate(rows: LedgerRow[], origin: 'turn~' | 'aux~'): CallAgg {
|
|
176
|
+
const a = EMPTY_AGG();
|
|
177
|
+
for (const r of rows) {
|
|
178
|
+
if (r.originEstimate !== origin) continue;
|
|
179
|
+
a.calls++;
|
|
180
|
+
a.input += r.tokens.input;
|
|
181
|
+
a.output += r.tokens.output;
|
|
182
|
+
a.cacheRead += r.tokens.cacheRead;
|
|
183
|
+
a.cacheWrite += r.tokens.cacheWrite;
|
|
184
|
+
a.cost += r.cost?.total ?? 0;
|
|
185
|
+
if (HIT_VERDICTS.has(r.verdict)) a.hits++;
|
|
186
|
+
if (r.error) a.errors++;
|
|
187
|
+
if (r.stopReason === 'refusal') a.refusals++;
|
|
188
|
+
a.breakpoints += r.cache.breakpoints ?? 0;
|
|
189
|
+
a.lastInputs.push(r.tokens.input);
|
|
190
|
+
}
|
|
191
|
+
a.lastInputs = a.lastInputs.slice(-8);
|
|
192
|
+
return a;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const n0 = (v: number) => v.toLocaleString();
|
|
196
|
+
const pct = (num: number, den: number) => (den > 0 ? `${Math.round((100 * num) / den)}%` : '—');
|
|
197
|
+
|
|
198
|
+
/** Cached share of what was sent — the number that tells you whether the prefix
|
|
199
|
+
* is being reused or re-read. */
|
|
200
|
+
const cachedShare = (a: CallAgg) => {
|
|
201
|
+
const sent = a.input + a.cacheRead;
|
|
202
|
+
return sent > 0 ? `${Math.round((100 * a.cacheRead) / sent)}%` : '—';
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
function CallStats(props: { rows: LedgerRow[] }) {
|
|
206
|
+
const main = () => aggregate(props.rows, 'turn~');
|
|
207
|
+
const aux = () => aggregate(props.rows, 'aux~');
|
|
208
|
+
|
|
209
|
+
const col = (label: string, a: () => CallAgg, tone: string) => (
|
|
210
|
+
<div class="flex-1 min-w-[9rem]">
|
|
211
|
+
<div class={`text-[10px] uppercase tracking-wider font-semibold mb-1 ${tone}`}>
|
|
212
|
+
{label} <span class="text-neutral-600">({a().calls})</span>
|
|
213
|
+
</div>
|
|
214
|
+
<table class="w-full font-mono text-[10px]">
|
|
215
|
+
<tbody>
|
|
216
|
+
<For each={[
|
|
217
|
+
['fresh input', n0(a().input)],
|
|
218
|
+
['cache read', n0(a().cacheRead)],
|
|
219
|
+
['cache write', n0(a().cacheWrite)],
|
|
220
|
+
['cached share', cachedShare(a())],
|
|
221
|
+
['prefix reused', pct(a().hits, a().calls)],
|
|
222
|
+
['output', n0(a().output)],
|
|
223
|
+
['avg breakpoints', a().calls ? (a().breakpoints / a().calls).toFixed(1) : '—'],
|
|
224
|
+
['cost', a().cost ? `$${a().cost.toFixed(4)}` : '—'],
|
|
225
|
+
] as Array<[string, string]>}>
|
|
226
|
+
{([k, v]) => (
|
|
227
|
+
<tr>
|
|
228
|
+
<td class="text-neutral-500 pr-2">{k}</td>
|
|
229
|
+
<td class="text-neutral-200 text-right tabular-nums">{v}</td>
|
|
230
|
+
</tr>
|
|
231
|
+
)}
|
|
232
|
+
</For>
|
|
233
|
+
<Show when={a().errors > 0 || a().refusals > 0}>
|
|
234
|
+
<tr>
|
|
235
|
+
<td class="text-neutral-500 pr-2">errors / refusals</td>
|
|
236
|
+
<td class="text-right tabular-nums text-red-300">
|
|
237
|
+
{a().errors} / {a().refusals}
|
|
238
|
+
</td>
|
|
239
|
+
</tr>
|
|
240
|
+
</Show>
|
|
241
|
+
</tbody>
|
|
242
|
+
</table>
|
|
243
|
+
<Show when={a().lastInputs.length > 1}>
|
|
244
|
+
<div class="text-[9px] text-neutral-600 mt-1 font-mono break-all"
|
|
245
|
+
title="fresh input tokens, oldest → newest — a descent should trend down">
|
|
246
|
+
{a().lastInputs.map(n0).join(' → ')}
|
|
247
|
+
</div>
|
|
248
|
+
</Show>
|
|
249
|
+
</div>
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
return (
|
|
253
|
+
<div>
|
|
254
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
|
|
255
|
+
recent llm calls ({props.rows.length})
|
|
256
|
+
</div>
|
|
257
|
+
<div class="flex gap-4 flex-wrap">
|
|
258
|
+
{col('main turns', main, 'text-cyan-400')}
|
|
259
|
+
{col('compression', aux, 'text-orange-400')}
|
|
260
|
+
</div>
|
|
261
|
+
<div class="text-[9px] text-neutral-600 mt-1 leading-relaxed">
|
|
262
|
+
Origin is inferred from stream-vs-complete (turns stream; compression uses
|
|
263
|
+
complete) — a reliable proxy, not a definitive tag, hence <span class="font-mono">~</span>.
|
|
264
|
+
“cached share” is cacheRead ÷ (input+cacheRead) — what fraction of the prompt
|
|
265
|
+
was reused rather than re-read.
|
|
266
|
+
</div>
|
|
267
|
+
</div>
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function CompositionBlock(props: { c: ContextComposition }) {
|
|
272
|
+
const rows = () => {
|
|
273
|
+
const c = props.c;
|
|
274
|
+
const out: Array<[string, number]> = [
|
|
275
|
+
['head (verbatim)', c.head?.tokens ?? 0],
|
|
276
|
+
['middle raw', c.middleRaw?.tokens ?? 0],
|
|
277
|
+
];
|
|
278
|
+
for (const [lvl, v] of Object.entries(c.summaries ?? {})) {
|
|
279
|
+
out.push([`summaries ${lvl.toUpperCase()}`, v?.tokens ?? 0]);
|
|
280
|
+
}
|
|
281
|
+
out.push(['tail (verbatim)', c.tail?.tokens ?? 0]);
|
|
282
|
+
return out;
|
|
283
|
+
};
|
|
284
|
+
const total = () => props.c.total?.tokens ?? rows().reduce((s, [, v]) => s + v, 0);
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div>
|
|
288
|
+
<div class="text-[10px] uppercase tracking-wider text-neutral-600 mb-1">
|
|
289
|
+
context composition (last compile)
|
|
290
|
+
</div>
|
|
291
|
+
<table class="w-full font-mono text-[10px]">
|
|
292
|
+
<tbody>
|
|
293
|
+
<For each={rows()}>
|
|
294
|
+
{([k, v]) => (
|
|
295
|
+
<tr>
|
|
296
|
+
<td class="text-neutral-500 pr-2">{k}</td>
|
|
297
|
+
<td class="text-neutral-200 text-right tabular-nums">{n0(v)}</td>
|
|
298
|
+
<td class="text-neutral-600 text-right pl-2 w-10">
|
|
299
|
+
{total() > 0 ? `${Math.round((100 * v) / total())}%` : ''}
|
|
300
|
+
</td>
|
|
301
|
+
<td class="pl-2 w-1/3">
|
|
302
|
+
<span class="inline-block h-1.5 bg-cyan-800 rounded"
|
|
303
|
+
style={{ width: `${total() > 0 ? Math.round((100 * v) / total()) : 0}%` }} />
|
|
304
|
+
</td>
|
|
305
|
+
</tr>
|
|
306
|
+
)}
|
|
307
|
+
</For>
|
|
308
|
+
<tr class="border-t border-neutral-800">
|
|
309
|
+
<td class="text-neutral-400 pr-2">total rendered</td>
|
|
310
|
+
<td class="text-neutral-100 text-right tabular-nums">{n0(total())}</td>
|
|
311
|
+
<td colSpan={2} />
|
|
312
|
+
</tr>
|
|
313
|
+
</tbody>
|
|
314
|
+
</table>
|
|
315
|
+
</div>
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
129
319
|
export function HealthPanel(props: {
|
|
130
320
|
health: HealthSnapshot | null;
|
|
321
|
+
/** Recent provider calls. Already on the client via the call-ledger frame. */
|
|
322
|
+
ledger?: LedgerRow[];
|
|
131
323
|
/** Non-null when the last /healthz fetch failed; '403' means scope-denied. */
|
|
132
324
|
error: string | null;
|
|
133
325
|
onRefresh(): void;
|
|
@@ -135,6 +327,7 @@ export function HealthPanel(props: {
|
|
|
135
327
|
const agents = () => props.health?.agents ?? [];
|
|
136
328
|
const quarantine = (name: string) => props.health?.compressionQuarantine?.[name];
|
|
137
329
|
const settings = (name: string) => props.health?.runtimeSettings?.[name];
|
|
330
|
+
const composition = (name: string) => props.health?.contextComposition?.[name];
|
|
138
331
|
|
|
139
332
|
const statusTone = (status?: string): string => {
|
|
140
333
|
switch (status) {
|
|
@@ -262,6 +455,23 @@ export function HealthPanel(props: {
|
|
|
262
455
|
</div>
|
|
263
456
|
)}
|
|
264
457
|
</Show>
|
|
458
|
+
|
|
459
|
+
{/* What was actually SENT, and how it split. Composition is
|
|
460
|
+
in-process render stats (free); call stats come from the ledger
|
|
461
|
+
the client already holds. */}
|
|
462
|
+
<Show when={composition(a.name)}>
|
|
463
|
+
{(c) => (
|
|
464
|
+
<div class="border-t border-neutral-900 pt-1.5">
|
|
465
|
+
<CompositionBlock c={c()} />
|
|
466
|
+
</div>
|
|
467
|
+
)}
|
|
468
|
+
</Show>
|
|
469
|
+
|
|
470
|
+
<Show when={(props.ledger?.length ?? 0) > 0}>
|
|
471
|
+
<div class="border-t border-neutral-900 pt-1.5">
|
|
472
|
+
<CallStats rows={props.ledger!} />
|
|
473
|
+
</div>
|
|
474
|
+
</Show>
|
|
265
475
|
</section>
|
|
266
476
|
)}</For>
|
|
267
477
|
</Show>
|
package/web/src/Pins.tsx
CHANGED
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
* degrades to raw — a safe superset, but not what was asked for — so the panel
|
|
13
13
|
* warns rather than letting it pass silently.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
15
|
+
* Ids are picked from the client's own message list (real store ids) rather than
|
|
16
|
+
* typed. See PinCandidate for why /debug/context/curve is NOT a usable source.
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
19
|
import { createSignal, For, Show } from 'solid-js';
|
|
@@ -37,14 +37,22 @@ export interface PinsState {
|
|
|
37
37
|
deepestLevel?: number;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
40
|
+
/**
|
|
41
|
+
* A pinnable message.
|
|
42
|
+
*
|
|
43
|
+
* MUST be a real message-store id. /debug/context/curve looked like the natural
|
|
44
|
+
* source but is not: on a live store its raw entries carry no `sourceMessageId`
|
|
45
|
+
* (0 of 208 had one) and the entries that DO have an `id` are summaries, whose
|
|
46
|
+
* ids (`L3-544`) are not message ids at all. Pinning with one would create a pin
|
|
47
|
+
* matching no message and silently do nothing. The client's own message list is
|
|
48
|
+
* authoritative — `WelcomeMessageEntry.id` is the store id, and server-sourced
|
|
49
|
+
* rows are exactly those with a store `index`.
|
|
50
|
+
*/
|
|
51
|
+
export interface PinCandidate {
|
|
52
|
+
id: string;
|
|
53
|
+
index: number;
|
|
54
|
+
participant: string;
|
|
55
|
+
text: string;
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
const ago = (ms: number) => {
|
|
@@ -67,6 +75,8 @@ export function PinsPanel(props: {
|
|
|
67
75
|
loaded: boolean;
|
|
68
76
|
state: PinsState | null;
|
|
69
77
|
agent?: string;
|
|
78
|
+
/** Pinnable messages — server-sourced rows only (real store ids). */
|
|
79
|
+
candidates?: PinCandidate[];
|
|
70
80
|
onRefresh(): void;
|
|
71
81
|
onAdd(input: {
|
|
72
82
|
kind: 'pin' | 'document';
|
|
@@ -85,26 +95,16 @@ export function PinsPanel(props: {
|
|
|
85
95
|
const [lvl, setLvl] = createSignal('1');
|
|
86
96
|
const [asDoc, setAsDoc] = createSignal(false);
|
|
87
97
|
|
|
88
|
-
const [
|
|
89
|
-
const [
|
|
90
|
-
const [picking, setPicking] = createSignal(false);
|
|
98
|
+
const [showPicker, setShowPicker] = createSignal(false);
|
|
99
|
+
const [filter, setFilter] = createSignal('');
|
|
91
100
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
const res = await fetch(`/debug/context/curve${qs}`, { credentials: 'same-origin' });
|
|
100
|
-
const body = await res.json();
|
|
101
|
-
if (!res.ok) { setCurveErr(body?.error ?? `HTTP ${res.status}`); return; }
|
|
102
|
-
setCurve((body.entries ?? []) as CurveEntry[]);
|
|
103
|
-
} catch (e) {
|
|
104
|
-
setCurveErr(e instanceof Error ? e.message : String(e));
|
|
105
|
-
} finally {
|
|
106
|
-
setPicking(false);
|
|
107
|
-
}
|
|
101
|
+
const candidates = () => {
|
|
102
|
+
const q = filter().trim().toLowerCase();
|
|
103
|
+
const all = props.candidates ?? [];
|
|
104
|
+
const hit = q ? all.filter((c) => c.text.toLowerCase().includes(q) || c.id.includes(q)) : all;
|
|
105
|
+
// Newest last is how the operator reads the conversation; cap the list so a
|
|
106
|
+
// long history doesn't build thousands of rows.
|
|
107
|
+
return hit.slice(-200);
|
|
108
108
|
};
|
|
109
109
|
|
|
110
110
|
const submit = () => {
|
|
@@ -273,43 +273,46 @@ export function PinsPanel(props: {
|
|
|
273
273
|
</button>
|
|
274
274
|
<button type="button"
|
|
275
275
|
class="px-2 py-0.5 text-[10px] rounded font-mono bg-neutral-800 hover:bg-neutral-700 text-neutral-300"
|
|
276
|
-
onClick={() =>
|
|
277
|
-
{
|
|
276
|
+
onClick={() => setShowPicker((v) => !v)}>
|
|
277
|
+
{showPicker() ? 'hide messages' : `pick from messages (${(props.candidates ?? []).length})`}
|
|
278
278
|
</button>
|
|
279
279
|
</div>
|
|
280
280
|
|
|
281
|
-
<Show when={
|
|
282
|
-
<
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
281
|
+
<Show when={showPicker()}>
|
|
282
|
+
<input value={filter()} onInput={(e) => setFilter(e.currentTarget.value)}
|
|
283
|
+
placeholder="filter by text or id"
|
|
284
|
+
class="w-full mb-1 bg-neutral-900 border border-neutral-700 rounded px-1.5 py-0.5
|
|
285
|
+
font-mono text-[10px] text-neutral-100" />
|
|
286
|
+
<Show when={candidates().length === 0}>
|
|
287
|
+
<div class="text-[10px] text-neutral-600 italic">
|
|
288
|
+
No pinnable messages loaded. Pins need a real store id, so only
|
|
289
|
+
server-sourced rows qualify — scroll the chat to load history.
|
|
290
|
+
</div>
|
|
291
|
+
</Show>
|
|
287
292
|
<div class="max-h-64 overflow-y-auto border border-neutral-800 rounded">
|
|
288
|
-
<For each={
|
|
289
|
-
{(
|
|
293
|
+
<For each={candidates()}>
|
|
294
|
+
{(c) => (
|
|
290
295
|
<div class="flex items-start gap-1.5 px-1.5 py-1 border-b border-neutral-900
|
|
291
296
|
hover:bg-neutral-900/60">
|
|
292
|
-
<span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{
|
|
293
|
-
<span class=
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
<span class="text-[10px] text-neutral-400 flex-1 truncate"
|
|
297
|
-
|
|
297
|
+
<span class="text-[9px] font-mono text-neutral-600 w-8 shrink-0">#{c.index}</span>
|
|
298
|
+
<span class="text-[9px] font-mono text-cyan-400/70 w-12 shrink-0 truncate">
|
|
299
|
+
{c.participant}
|
|
300
|
+
</span>
|
|
301
|
+
<span class="text-[10px] text-neutral-400 flex-1 truncate" title={c.text}>
|
|
302
|
+
{c.text.slice(0, 90)}
|
|
303
|
+
</span>
|
|
298
304
|
<button type="button"
|
|
299
305
|
class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
|
|
300
306
|
text-neutral-400 shrink-0"
|
|
301
|
-
onClick={() => setFirst(
|
|
307
|
+
onClick={() => setFirst(c.id)}>from</button>
|
|
302
308
|
<button type="button" disabled={asDoc()}
|
|
303
309
|
class="text-[9px] font-mono px-1 rounded bg-neutral-800 hover:bg-neutral-700
|
|
304
310
|
text-neutral-400 shrink-0 disabled:opacity-30"
|
|
305
|
-
onClick={() => setLast(
|
|
311
|
+
onClick={() => setLast(c.id)}>to</button>
|
|
306
312
|
</div>
|
|
307
313
|
)}
|
|
308
314
|
</For>
|
|
309
315
|
</div>
|
|
310
|
-
<div class="text-[10px] text-neutral-600 mt-1">
|
|
311
|
-
Entries without a store id (merged summaries) are omitted — a pin needs a message id.
|
|
312
|
-
</div>
|
|
313
316
|
</Show>
|
|
314
317
|
</div>
|
|
315
318
|
</Show>
|