latticesql 2.1.0 → 2.1.1
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/README.md +7 -3
- package/dist/cli.js +385 -314
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2119,7 +2119,11 @@ Postgres URL with no root.
|
|
|
2119
2119
|
### Assistant sidebar (v2.0+)
|
|
2120
2120
|
|
|
2121
2121
|
The GUI has a fixed right sidebar with a live **activity feed** — every change
|
|
2122
|
-
(yours, the assistant's, or an ingest) streams in as it happens
|
|
2122
|
+
(yours, the assistant's, or an ingest) streams in as it happens, collapsed by
|
|
2123
|
+
type so a bulk run shows a single card ("Deleted 19 tables", "Removed 49 rows
|
|
2124
|
+
across 9 tables") instead of a wall of near-identical rows. The feed is scoped to
|
|
2125
|
+
the open conversation: the assistant's data changes are saved with each turn and
|
|
2126
|
+
replayed as those cards when you reopen the chat. When the assistant references a record, it emits an inline object-link pill — a clickable chip that opens that row in the mode-aware navigator.
|
|
2123
2127
|
|
|
2124
2128
|
Add a Claude API token in **User Settings → Assistant** (or set
|
|
2125
2129
|
`ANTHROPIC_API_KEY`) to enable the **AI assistant**: ask questions about your
|
|
@@ -2372,7 +2376,7 @@ The full architecture, schema, and HTTP surface live in [docs/teams.md](./docs/t
|
|
|
2372
2376
|
Lattice Teams + the GUI's Database panel now flow through a state machine:
|
|
2373
2377
|
|
|
2374
2378
|
```
|
|
2375
|
-
LOCAL → CLOUD WORKSPACE (owner | member
|
|
2379
|
+
LOCAL → CLOUD WORKSPACE (owner | member)
|
|
2376
2380
|
(migrate / connect)
|
|
2377
2381
|
```
|
|
2378
2382
|
|
|
@@ -2438,7 +2442,7 @@ HTTP surface (all under `/api/dbconfig/*`, localhost-only, same auth model as th
|
|
|
2438
2442
|
| POST | `/api/dbconfig/connect-existing` | `TeamsClient.connectToExistingCloud` |
|
|
2439
2443
|
| POST | `/api/dbconfig/save` / `connect` / `test` | unchanged from v1.12 |
|
|
2440
2444
|
|
|
2441
|
-
The `state` field on `GET /api/dbconfig` is one of: `local`, `team-cloud-creator`, `team-cloud-member
|
|
2445
|
+
The `state` field on `GET /api/dbconfig` is one of: `local`, `team-cloud-creator`, `team-cloud-member` (the `cloud-connected` state was removed in 1.16.3; the `team-cloud-needs-invite` state was removed in 2.1.1 — a connected cloud is always a member workspace). The SPA badge color-codes them (labeled "CLOUD · OWNER / MEMBER"); the routes use them only for response shape.
|
|
2442
2446
|
|
|
2443
2447
|
---
|
|
2444
2448
|
|
package/dist/cli.js
CHANGED
|
@@ -417,12 +417,6 @@ function assertSafeLabel(label) {
|
|
|
417
417
|
throw new Error(`Invalid label "${label}": must match [A-Za-z0-9._-]+ and not start with .`);
|
|
418
418
|
}
|
|
419
419
|
}
|
|
420
|
-
function readToken(label) {
|
|
421
|
-
assertSafeLabel(label);
|
|
422
|
-
const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
|
|
423
|
-
if (!existsSync2(path2)) return null;
|
|
424
|
-
return readFileSync(path2, "utf8").trim();
|
|
425
|
-
}
|
|
426
420
|
function writeToken(label, token) {
|
|
427
421
|
assertSafeLabel(label);
|
|
428
422
|
const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
|
|
@@ -7086,6 +7080,8 @@ var css = `
|
|
|
7086
7080
|
}
|
|
7087
7081
|
a.chip-link { cursor: pointer; }
|
|
7088
7082
|
a.chip-link:hover { background: var(--accent); color: white; }
|
|
7083
|
+
/* Inline object-reference pills the assistant emits \u2014 render flush in prose. */
|
|
7084
|
+
a.lattice-ref { text-decoration: none; vertical-align: baseline; }
|
|
7089
7085
|
.empty-row td {
|
|
7090
7086
|
color: var(--text-muted); font-style: italic; text-align: center;
|
|
7091
7087
|
padding: 24px;
|
|
@@ -7812,20 +7808,9 @@ var css = `
|
|
|
7812
7808
|
padding: 8px; margin: 0 0 8px; overflow-x: auto;
|
|
7813
7809
|
}
|
|
7814
7810
|
.chat-bubble.assistant pre code { background: none; border: none; padding: 0; white-space: pre; }
|
|
7815
|
-
|
|
7816
|
-
|
|
7817
|
-
|
|
7818
|
-
border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500;
|
|
7819
|
-
background: var(--accent-soft); color: var(--accent);
|
|
7820
|
-
box-shadow: var(--glow-accent-soft);
|
|
7821
|
-
}
|
|
7822
|
-
.tool-pill.done { background: var(--surface-2); color: var(--text-muted); box-shadow: none; }
|
|
7823
|
-
.tool-pill.error { background: rgba(251,146,60,0.14); color: var(--warn); box-shadow: none; }
|
|
7824
|
-
.tool-pill .spin { display: inline-block; width: 9px; height: 9px;
|
|
7825
|
-
border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%;
|
|
7826
|
-
animation: pillspin 0.7s linear infinite; }
|
|
7827
|
-
@keyframes pillspin { to { transform: rotate(360deg); } }
|
|
7828
|
-
/* Typing indicator: three pulsing dots shown in an assistant bubble while
|
|
7811
|
+
/* The assistant's data changes render as activity-feed cards (.feed-item) in
|
|
7812
|
+
the rail \u2014 there is no separate inline pill style. Reads emit no card.
|
|
7813
|
+
Typing indicator: three pulsing dots shown in an assistant bubble while
|
|
7829
7814
|
the model is generating (before the first text delta of a turn). */
|
|
7830
7815
|
.chat-typing { display: inline-flex; align-items: center; gap: 4px; padding: 1px 0; }
|
|
7831
7816
|
.chat-typing i {
|
|
@@ -7963,6 +7948,13 @@ var appJs = `
|
|
|
7963
7948
|
return !!(t && t[colName] && t[colName].secret);
|
|
7964
7949
|
}
|
|
7965
7950
|
var SECRET_MASK = '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022'; // \u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022
|
|
7951
|
+
// An encrypted-at-rest value (native secrets etc.) is stored with an "enc:"
|
|
7952
|
+
// sentinel prefix (see framework/native-entities decrypt). It is never
|
|
7953
|
+
// plaintext, so the GUI must never render the raw ciphertext \u2014 mask it the
|
|
7954
|
+
// same way an operator-flagged secret column is masked.
|
|
7955
|
+
function looksEncrypted(v) {
|
|
7956
|
+
return typeof v === 'string' && v.slice(0, 4) === 'enc:';
|
|
7957
|
+
}
|
|
7966
7958
|
|
|
7967
7959
|
function displayFor(name) {
|
|
7968
7960
|
var override = state.iconOverrides[name];
|
|
@@ -8794,9 +8786,16 @@ var appJs = `
|
|
|
8794
8786
|
headers: { 'content-type': 'application/json' },
|
|
8795
8787
|
body: JSON.stringify({ id: id }),
|
|
8796
8788
|
}).then(function () {
|
|
8797
|
-
menu
|
|
8789
|
+
// Keep the menu OPEN with the item's spinner through the reload \u2014
|
|
8790
|
+
// for a CLOUD workspace the slow part (connecting + fetching
|
|
8791
|
+
// against the remote DB) happens here in reloadEverything, AFTER
|
|
8792
|
+
// the switch POST. Hiding the menu now (the old behavior) hid the
|
|
8793
|
+
// only progress signal, so a cloud switch looked unresponsive.
|
|
8794
|
+
// renderWsSwitcher (inside reloadEverything) only re-binds the
|
|
8795
|
+
// toggle + updates the label, so the spinning item survives.
|
|
8798
8796
|
return reloadEverything();
|
|
8799
8797
|
}).then(function () {
|
|
8798
|
+
menu.hidden = true;
|
|
8800
8799
|
// Conversations + activity both live in the workspace DB. Drop
|
|
8801
8800
|
// the old workspace's thread + activity cards, reconnect the feed
|
|
8802
8801
|
// to THIS workspace, and reload its thread list (+ latest convo).
|
|
@@ -8805,7 +8804,7 @@ var appJs = `
|
|
|
8805
8804
|
startFeed();
|
|
8806
8805
|
refreshThreadList(true);
|
|
8807
8806
|
showToast('Switched workspace', {});
|
|
8808
|
-
}).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
|
|
8807
|
+
}).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
|
|
8809
8808
|
});
|
|
8810
8809
|
});
|
|
8811
8810
|
});
|
|
@@ -9210,7 +9209,7 @@ var appJs = `
|
|
|
9210
9209
|
} else {
|
|
9211
9210
|
bodyRows = rows.map(function (r) {
|
|
9212
9211
|
var tds = intrinsic.map(function (c) {
|
|
9213
|
-
if (isSecretColumn(tableName, c) && r[c] != null && r[c] !== '') {
|
|
9212
|
+
if ((isSecretColumn(tableName, c) || looksEncrypted(r[c])) && r[c] != null && r[c] !== '') {
|
|
9214
9213
|
return '<td class="muted">' + SECRET_MASK + '</td>';
|
|
9215
9214
|
}
|
|
9216
9215
|
return '<td><div class="cell-clip">' + escapeHtml(truncate(r[c], 120)) + '</div></td>';
|
|
@@ -9495,7 +9494,7 @@ var appJs = `
|
|
|
9495
9494
|
function paint(editing) {
|
|
9496
9495
|
var rows = [];
|
|
9497
9496
|
intrinsic.forEach(function (c) {
|
|
9498
|
-
var secret = isSecretColumn(tableName, c);
|
|
9497
|
+
var secret = isSecretColumn(tableName, c) || looksEncrypted(row[c]);
|
|
9499
9498
|
var dd;
|
|
9500
9499
|
if (editing) {
|
|
9501
9500
|
dd = fieldFor(c, row[c], t);
|
|
@@ -9863,7 +9862,7 @@ var appJs = `
|
|
|
9863
9862
|
function fsValInner(table, row, col) {
|
|
9864
9863
|
var raw = row[col];
|
|
9865
9864
|
if (raw == null || raw === '') return '<span class="fs-empty-val">\u2014</span>';
|
|
9866
|
-
if (isSecretColumn(table, col)) return '<span class="muted">' + SECRET_MASK + '</span>';
|
|
9865
|
+
if (isSecretColumn(table, col) || looksEncrypted(raw)) return '<span class="muted">' + SECRET_MASK + '</span>';
|
|
9867
9866
|
var s = String(raw);
|
|
9868
9867
|
if (FS_LONGFORM.indexOf(col) >= 0 || s.indexOf('\\n') >= 0) {
|
|
9869
9868
|
return '<div class="md-body">' + mdToHtml(s.slice(0, 40000)) + '</div>';
|
|
@@ -11865,20 +11864,29 @@ var appJs = `
|
|
|
11865
11864
|
'</div>' +
|
|
11866
11865
|
'</div>';
|
|
11867
11866
|
}
|
|
11867
|
+
// Only the selected provider's key input is shown (declutter). 'auto'
|
|
11868
|
+
// ("Select provider\u2026") shows no key row until a provider is chosen.
|
|
11869
|
+
function voiceRowHtml(provider) {
|
|
11870
|
+
if (provider === 'openai') {
|
|
11871
|
+
return rowHtml('asst-openai', 'OpenAI Whisper key', !!cfg.hasOpenaiKey, 'sk-\u2026');
|
|
11872
|
+
}
|
|
11873
|
+
if (provider === 'elevenlabs') {
|
|
11874
|
+
return rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026');
|
|
11875
|
+
}
|
|
11876
|
+
return '';
|
|
11877
|
+
}
|
|
11868
11878
|
host.innerHTML =
|
|
11869
11879
|
'<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
|
|
11870
11880
|
'<h3 style="margin:0 0 10px">Assistant</h3>' +
|
|
11871
11881
|
'<p class="lead" style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
|
|
11872
|
-
'Keys are stored encrypted in the <code>secrets</code> table
|
|
11873
|
-
'saved. Environment variables (<code>ANTHROPIC_API_KEY</code>, <code>OPENAI_API_KEY</code>, ' +
|
|
11874
|
-
'<code>ELEVENLABS_API_KEY</code>) also work.' +
|
|
11882
|
+
'Keys are stored encrypted in the <code>secrets</code> table.' +
|
|
11875
11883
|
'</p>' +
|
|
11876
11884
|
rowHtml('asst-anthropic', 'Claude API token (chat)', !!cfg.hasAnthropicKey, 'sk-ant-\u2026') +
|
|
11877
|
-
|
|
11878
|
-
(
|
|
11879
|
-
|
|
11880
|
-
|
|
11881
|
-
|
|
11885
|
+
(cfg.oauthEnabled
|
|
11886
|
+
? '<div style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
|
|
11887
|
+
'Or <a href="/api/assistant/oauth/start" style="color:var(--accent)">connect your Claude subscription</a>.' +
|
|
11888
|
+
'</div>'
|
|
11889
|
+
: '') +
|
|
11882
11890
|
'<div style="margin:6px 0 12px">' +
|
|
11883
11891
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
|
11884
11892
|
'<strong style="font-size:13px">Inference aggressiveness</strong>' +
|
|
@@ -11895,17 +11903,16 @@ var appJs = `
|
|
|
11895
11903
|
'auto-creates link tables) when you drop in files. Higher extrapolates more.' +
|
|
11896
11904
|
'</p>' +
|
|
11897
11905
|
'</div>' +
|
|
11898
|
-
'<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text
|
|
11899
|
-
|
|
11900
|
-
rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026') +
|
|
11901
|
-
'<div style="margin:6px 0 2px;display:flex;align-items:center;gap:8px">' +
|
|
11906
|
+
'<div style="font-size:11px;color:var(--text-muted);margin:10px 0 8px;text-transform:uppercase;letter-spacing:0.05em">Voice \u2014 speech to text</div>' +
|
|
11907
|
+
'<div style="margin:6px 0 8px;display:flex;align-items:center;gap:8px">' +
|
|
11902
11908
|
'<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
|
|
11903
11909
|
'<select id="asst-stt" style="background:var(--surface-2);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px;padding:3px 6px">' +
|
|
11904
|
-
'<option value="auto">
|
|
11905
|
-
'<option value="openai">OpenAI
|
|
11910
|
+
'<option value="auto">Select provider\u2026</option>' +
|
|
11911
|
+
'<option value="openai">OpenAI</option>' +
|
|
11906
11912
|
'<option value="elevenlabs">ElevenLabs</option>' +
|
|
11907
11913
|
'</select>' +
|
|
11908
11914
|
'</div>' +
|
|
11915
|
+
'<div id="asst-voice-key">' + voiceRowHtml(cfg.sttPreference || 'auto') + '</div>' +
|
|
11909
11916
|
'<div id="assistant-msg" style="margin-top:4px;font-size:12px;color:var(--text-muted)"></div>' +
|
|
11910
11917
|
'</div>';
|
|
11911
11918
|
var msg = host.querySelector('#assistant-msg');
|
|
@@ -11935,12 +11942,18 @@ var appJs = `
|
|
|
11935
11942
|
});
|
|
11936
11943
|
}
|
|
11937
11944
|
wire('asst-anthropic', 'anthropic');
|
|
11938
|
-
wire('asst-openai', 'openai');
|
|
11939
|
-
wire('asst-elevenlabs', 'elevenlabs');
|
|
11940
11945
|
var sttSel = host.querySelector('#asst-stt');
|
|
11946
|
+
var voiceKeyHost = host.querySelector('#asst-voice-key');
|
|
11947
|
+
function wireVoiceKey(provider) {
|
|
11948
|
+
if (provider === 'openai') wire('asst-openai', 'openai');
|
|
11949
|
+
else if (provider === 'elevenlabs') wire('asst-elevenlabs', 'elevenlabs');
|
|
11950
|
+
}
|
|
11941
11951
|
if (sttSel) {
|
|
11942
11952
|
sttSel.value = cfg.sttPreference || 'auto';
|
|
11953
|
+
wireVoiceKey(sttSel.value);
|
|
11943
11954
|
sttSel.addEventListener('change', function () {
|
|
11955
|
+
if (voiceKeyHost) voiceKeyHost.innerHTML = voiceRowHtml(sttSel.value);
|
|
11956
|
+
wireVoiceKey(sttSel.value);
|
|
11944
11957
|
msg.textContent = 'Saving\u2026';
|
|
11945
11958
|
fetch('/api/assistant/stt-provider', {
|
|
11946
11959
|
method: 'PUT',
|
|
@@ -12331,9 +12344,10 @@ var appJs = `
|
|
|
12331
12344
|
|
|
12332
12345
|
// State-machine Database panel (v1.13+). Renders a different body
|
|
12333
12346
|
// per info.state: local -> Migrate / Connect-existing wizards;
|
|
12334
|
-
//
|
|
12335
|
-
//
|
|
12336
|
-
//
|
|
12347
|
+
// team-cloud-creator/member -> connection details + members. A connected
|
|
12348
|
+
// cloud workspace is always a member workspace (created or invited), so
|
|
12349
|
+
// there is no in-settings "join via invite" \u2014 that lives in the Join
|
|
12350
|
+
// Workspace flow only.
|
|
12337
12351
|
function renderDatabasePanel(host) {
|
|
12338
12352
|
fetchJson('/api/dbconfig').then(function (info) {
|
|
12339
12353
|
var badge = renderStateBadge(info);
|
|
@@ -12369,10 +12383,6 @@ var appJs = `
|
|
|
12369
12383
|
label = 'CLOUD \xB7 MEMBER';
|
|
12370
12384
|
color = 'var(--accent)';
|
|
12371
12385
|
break;
|
|
12372
|
-
case 'team-cloud-needs-invite':
|
|
12373
|
-
label = 'CLOUD \xB7 NEEDS INVITE';
|
|
12374
|
-
color = 'var(--warn)';
|
|
12375
|
-
break;
|
|
12376
12386
|
default:
|
|
12377
12387
|
label = String(info.state || 'UNKNOWN').toUpperCase();
|
|
12378
12388
|
}
|
|
@@ -12408,21 +12418,6 @@ var appJs = `
|
|
|
12408
12418
|
'<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
|
|
12409
12419
|
);
|
|
12410
12420
|
}
|
|
12411
|
-
if (info.state === 'team-cloud-needs-invite') {
|
|
12412
|
-
return (
|
|
12413
|
-
renderConnectionSummary(info) +
|
|
12414
|
-
'<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
|
|
12415
|
-
'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
|
|
12416
|
-
'</p>' +
|
|
12417
|
-
'<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
|
|
12418
|
-
'<div><label class="field-label">Invite token</label>' +
|
|
12419
|
-
'<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
|
|
12420
|
-
'</div>' +
|
|
12421
|
-
'<div class="team-actions" style="margin-top:10px">' +
|
|
12422
|
-
'<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
|
|
12423
|
-
'</div>'
|
|
12424
|
-
);
|
|
12425
|
-
}
|
|
12426
12421
|
return '<p style="color:var(--text-muted)">Unknown database state.</p>';
|
|
12427
12422
|
}
|
|
12428
12423
|
|
|
@@ -12494,32 +12489,6 @@ var appJs = `
|
|
|
12494
12489
|
}).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
|
|
12495
12490
|
}
|
|
12496
12491
|
|
|
12497
|
-
var rejoinBtn = host.querySelector('[data-act="rejoin-with-token"]');
|
|
12498
|
-
if (rejoinBtn) rejoinBtn.addEventListener('click', function () {
|
|
12499
|
-
var token = (document.getElementById('db-rejoin-token').value || '').trim();
|
|
12500
|
-
if (!token) { setMsg('Invite token required.', false); return; }
|
|
12501
|
-
// Without form re-entry the credentials are already saved; we
|
|
12502
|
-
// call the connect-existing endpoint with just the invite
|
|
12503
|
-
// token. The handler reads credentials from db-credentials.enc
|
|
12504
|
-
// via the active configPath's label.
|
|
12505
|
-
setMsg('Joining workspace\u2026');
|
|
12506
|
-
fetch('/api/dbconfig/connect-existing', {
|
|
12507
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
12508
|
-
body: JSON.stringify({
|
|
12509
|
-
type: 'postgres',
|
|
12510
|
-
label: info.label,
|
|
12511
|
-
host: info.host, port: info.port, dbname: info.dbname,
|
|
12512
|
-
user: info.user, password: '', // password lives in db-credentials.enc; backend will pull
|
|
12513
|
-
invite_token: token,
|
|
12514
|
-
}),
|
|
12515
|
-
})
|
|
12516
|
-
.then(function (r) { return r.json(); })
|
|
12517
|
-
.then(function (d) {
|
|
12518
|
-
if (d.error) { setMsg('Failed: ' + d.error, false); return; }
|
|
12519
|
-
setMsg('Joined.', true); rerender();
|
|
12520
|
-
})
|
|
12521
|
-
.catch(function (e) { setMsg('Failed: ' + e.message, false); });
|
|
12522
|
-
});
|
|
12523
12492
|
}
|
|
12524
12493
|
|
|
12525
12494
|
// \u2500\u2500 v1.13 wizards \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
@@ -12895,64 +12864,103 @@ var appJs = `
|
|
|
12895
12864
|
insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
|
|
12896
12865
|
link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
|
|
12897
12866
|
};
|
|
12898
|
-
//
|
|
12899
|
-
//
|
|
12867
|
+
// Schema mutations reach the client in two shapes: the LIVE feed publishes the
|
|
12868
|
+
// coarse op:'schema', while the persisted audit log / per-thread replay carry
|
|
12869
|
+
// the fine-grained op:'schema.delete_entity' (etc.). Treat both as schema so
|
|
12870
|
+
// they collapse + pick the \u{1F6E0} icon identically (regression: backfilled schema
|
|
12871
|
+
// ops showed '\u2022' and never grouped).
|
|
12872
|
+
function isSchemaOp(op) { var o = String(op || ''); return o === 'schema' || o.indexOf('schema.') === 0; }
|
|
12873
|
+
function feedIcon(op) { return isSchemaOp(op) ? FEED_ICONS.schema : (FEED_ICONS[op] || '\u2022'); }
|
|
12874
|
+
// Ops whose runs collapse into one counted bubble (bulk row work spams N
|
|
12875
|
+
// near-identical rows otherwise). Undo/redo stay distinct.
|
|
12900
12876
|
var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
|
|
12901
|
-
|
|
12902
|
-
|
|
12903
|
-
//
|
|
12904
|
-
//
|
|
12905
|
-
//
|
|
12877
|
+
var ROW_VERB = { insert: 'Added', update: 'Updated', delete: 'Removed', link: 'Linked', unlink: 'Unlinked' };
|
|
12878
|
+
var ROW_PREP = { insert: 'to', update: 'in', delete: 'from', link: 'in', unlink: 'in' };
|
|
12879
|
+
// Schema events all arrive as op:'schema'; the specific action lives only in
|
|
12880
|
+
// the summary text. Map that text to a stable sub-action so a bulk run of
|
|
12881
|
+
// "Deleted table X" collapses into one "Deleted 19 tables" pill. Each entry
|
|
12882
|
+
// is [verb, singular, plural].
|
|
12883
|
+
var SCHEMA_GROUP = {
|
|
12884
|
+
'created-table': ['Created', 'table', 'tables'],
|
|
12885
|
+
'deleted-table': ['Deleted', 'table', 'tables'],
|
|
12886
|
+
'renamed-table': ['Renamed', 'table', 'tables'],
|
|
12887
|
+
'added-column': ['Added', 'column', 'columns'],
|
|
12888
|
+
'renamed-column': ['Renamed', 'column', 'columns'],
|
|
12889
|
+
'added-link': ['Added', 'link', 'links'],
|
|
12890
|
+
'deleted-link': ['Deleted', 'link', 'links'],
|
|
12891
|
+
'created-link': ['Created', 'link table', 'link tables'],
|
|
12892
|
+
};
|
|
12893
|
+
function schemaAction(summary) {
|
|
12894
|
+
var s = String(summary || '');
|
|
12895
|
+
if (/^Created link table/.test(s)) return 'created-link';
|
|
12896
|
+
if (/^Created table/.test(s)) return 'created-table';
|
|
12897
|
+
if (/^Deleted table/.test(s)) return 'deleted-table';
|
|
12898
|
+
if (/^Renamed table/.test(s)) return 'renamed-table';
|
|
12899
|
+
if (/^Added a column/.test(s)) return 'added-column';
|
|
12900
|
+
if (/^Renamed a column/.test(s)) return 'renamed-column';
|
|
12901
|
+
if (/^Added a link/.test(s)) return 'added-link';
|
|
12902
|
+
if (/^Deleted a link/.test(s)) return 'deleted-link';
|
|
12903
|
+
return null; // unknown schema op: keep it ungrouped (stay honest)
|
|
12904
|
+
}
|
|
12905
|
+
// Group identical-TYPE events into one counted pill regardless of which
|
|
12906
|
+
// object they touched, so a bulk run (delete N tables, remove rows across M
|
|
12907
|
+
// tables) shows a single bubble instead of overflowing the rail. Keyed by
|
|
12908
|
+
// op+source (+schema sub-action); the table is intentionally NOT in the key.
|
|
12909
|
+
// A group stays "open" for FEED_GROUP_WINDOW_MS after its last hit; later
|
|
12906
12910
|
// activity starts a fresh bubble so unrelated edits aren't merged in.
|
|
12907
|
-
|
|
12908
|
-
|
|
12909
|
-
|
|
12910
|
-
|
|
12911
|
-
|
|
12912
|
-
case 'insert': return 'Added ' + count + ' rows to ' + t;
|
|
12913
|
-
case 'update': return 'Updated ' + count + ' rows in ' + t;
|
|
12914
|
-
case 'delete': return 'Removed ' + count + ' rows from ' + t;
|
|
12915
|
-
case 'link': return 'Linked ' + count + ' rows in ' + t;
|
|
12916
|
-
case 'unlink': return 'Unlinked ' + count + ' rows in ' + t;
|
|
12917
|
-
default: return String(op || '') + ' ' + t;
|
|
12911
|
+
function feedGroupKey(ev) {
|
|
12912
|
+
var src = String(ev.source || '');
|
|
12913
|
+
if (isSchemaOp(ev.op)) {
|
|
12914
|
+
var a = schemaAction(ev.summary);
|
|
12915
|
+
return a ? 'schema|' + a + '|' + src : null;
|
|
12918
12916
|
}
|
|
12917
|
+
return GROUPABLE_OPS[ev.op] ? String(ev.op) + '|' + src : null;
|
|
12919
12918
|
}
|
|
12920
|
-
|
|
12921
|
-
|
|
12922
|
-
|
|
12923
|
-
|
|
12924
|
-
|
|
12925
|
-
|
|
12926
|
-
|
|
12927
|
-
|
|
12928
|
-
|
|
12929
|
-
|
|
12930
|
-
|
|
12931
|
-
|
|
12932
|
-
var
|
|
12933
|
-
if (
|
|
12934
|
-
|
|
12935
|
-
|
|
12936
|
-
|
|
12937
|
-
|
|
12938
|
-
|
|
12939
|
-
|
|
12940
|
-
|
|
12941
|
-
|
|
12942
|
-
|
|
12943
|
-
|
|
12944
|
-
|
|
12945
|
-
|
|
12946
|
-
|
|
12947
|
-
|
|
12948
|
-
|
|
12949
|
-
|
|
12950
|
-
|
|
12919
|
+
var feedGroups = {}; // key -> { op, count, tables, tableCount, schemaKey, firstSummary, item, summaryEl, timeEl, last, startMs, endMs, turnId }
|
|
12920
|
+
var FEED_GROUP_WINDOW_MS = 15000;
|
|
12921
|
+
// Assistant-turn scope for live activity-card grouping + duration. While a
|
|
12922
|
+
// turn is active, its same-type events all collapse into one card (no window
|
|
12923
|
+
// expiry); the card's timer measures from feedTurnStartMs to the last event.
|
|
12924
|
+
var feedTurnId = 0;
|
|
12925
|
+
var feedTurnActive = false;
|
|
12926
|
+
var feedTurnStartMs = 0;
|
|
12927
|
+
function onlyKey(obj) { for (var k in obj) { if (obj.hasOwnProperty(k)) return k; } return ''; }
|
|
12928
|
+
function groupedRowSummary(op, count, tables, tableCount) {
|
|
12929
|
+
var verb = ROW_VERB[op] || String(op || '');
|
|
12930
|
+
var noun = count === 1 ? 'row' : 'rows';
|
|
12931
|
+
var where = '';
|
|
12932
|
+
if (tableCount > 1) { where = ' across ' + tableCount + ' tables'; }
|
|
12933
|
+
else { var only = onlyKey(tables); if (only) where = ' ' + (ROW_PREP[op] || 'in') + ' ' + only; }
|
|
12934
|
+
return verb + ' ' + count + ' ' + noun + where;
|
|
12935
|
+
}
|
|
12936
|
+
function schemaGroupSummary(schemaKey, count, firstSummary) {
|
|
12937
|
+
var g = SCHEMA_GROUP[schemaKey];
|
|
12938
|
+
if (count <= 1 || !g) return firstSummary || '';
|
|
12939
|
+
return g[0] + ' ' + count + ' ' + g[2];
|
|
12940
|
+
}
|
|
12941
|
+
function groupedSummary(g) {
|
|
12942
|
+
return isSchemaOp(g.op)
|
|
12943
|
+
? schemaGroupSummary(g.schemaKey, g.count, g.firstSummary)
|
|
12944
|
+
: groupedRowSummary(g.op, g.count, g.tables, g.tableCount);
|
|
12945
|
+
}
|
|
12946
|
+
// While a chat turn is streaming, its typing bubble (the not-yet-arrived next
|
|
12947
|
+
// assistant message) must stay last; tool-driven activity cards belong ABOVE
|
|
12948
|
+
// it, not below \u2014 otherwise the "typing\u2026" dots land mid-conversation. Returns
|
|
12949
|
+
// the .chat-msg to insert before, or null when nothing is streaming.
|
|
12950
|
+
function feedTypingAnchor(feedEl) {
|
|
12951
|
+
var typing = feedEl.querySelector('.chat-bubble[data-typing="1"]');
|
|
12952
|
+
var msg = typing && typing.closest ? typing.closest('.chat-msg') : null;
|
|
12953
|
+
return (msg && msg.parentNode === feedEl) ? msg : null;
|
|
12954
|
+
}
|
|
12955
|
+
// Build one activity card (the shared full-width pill shape). Used by BOTH
|
|
12956
|
+
// the live feed and the per-thread replay so they look identical. Returns the
|
|
12957
|
+
// element plus the summary/time nodes a group mutates in place.
|
|
12958
|
+
function makeFeedCard(ev) {
|
|
12951
12959
|
var item = document.createElement('div');
|
|
12952
12960
|
item.className = 'feed-item';
|
|
12953
12961
|
var icon = document.createElement('div');
|
|
12954
12962
|
icon.className = 'feed-icon';
|
|
12955
|
-
icon.textContent =
|
|
12963
|
+
icon.textContent = feedIcon(ev.op);
|
|
12956
12964
|
var body = document.createElement('div');
|
|
12957
12965
|
body.className = 'feed-body';
|
|
12958
12966
|
var summary = document.createElement('div');
|
|
@@ -12968,11 +12976,13 @@ var appJs = `
|
|
|
12968
12976
|
body.appendChild(meta);
|
|
12969
12977
|
var time = document.createElement('div');
|
|
12970
12978
|
time.className = 'feed-time';
|
|
12971
|
-
|
|
12979
|
+
// Duration ("4s" / "4m 2s") is filled in by the caller once the group's
|
|
12980
|
+
// start/end span is known \u2014 not a relative "ago".
|
|
12981
|
+
time.textContent = '';
|
|
12972
12982
|
item.appendChild(icon);
|
|
12973
12983
|
item.appendChild(body);
|
|
12974
12984
|
item.appendChild(time);
|
|
12975
|
-
// Row events (insert/update/delete) carry a rowId \u2014 make the
|
|
12985
|
+
// Row events (insert/update/delete) carry a rowId \u2014 make the card a
|
|
12976
12986
|
// shortcut to that object. Link/unlink and schema events have no single
|
|
12977
12987
|
// row (rowId is null), so they stay non-clickable.
|
|
12978
12988
|
if (ev.rowId && ev.table) {
|
|
@@ -12980,18 +12990,117 @@ var appJs = `
|
|
|
12980
12990
|
item.setAttribute('role', 'button');
|
|
12981
12991
|
item.setAttribute('tabindex', '0');
|
|
12982
12992
|
item.title = 'Open this ' + String(ev.table);
|
|
12983
|
-
// _rowClickOff is set when the
|
|
12993
|
+
// _rowClickOff is set when the card becomes a group \u2014 clicks no-op then.
|
|
12984
12994
|
var openRow = function () { if (item._rowClickOff) return; openSearchHit(String(ev.table), String(ev.rowId)); };
|
|
12985
12995
|
item.addEventListener('click', openRow);
|
|
12986
12996
|
item.addEventListener('keydown', function (e) {
|
|
12987
12997
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
|
|
12988
12998
|
});
|
|
12989
12999
|
}
|
|
12990
|
-
|
|
13000
|
+
return { item: item, summaryEl: summary, timeEl: time };
|
|
13001
|
+
}
|
|
13002
|
+
// Fold another event into an existing group card: bump the count, track the
|
|
13003
|
+
// table, refresh the summary, and drop the single-row affordances (a grouped
|
|
13004
|
+
// card stands for many rows, so it's a status, not a clickable button).
|
|
13005
|
+
// The card timer shows the TASK DURATION (start \u2192 finish), not a relative
|
|
13006
|
+
// "ago": for a single op it's the time that op took; for a grouped run it's
|
|
13007
|
+
// from the first task's start to the last task's finish. startMs is anchored
|
|
13008
|
+
// to the assistant turn's start (so a one-event card still shows real time);
|
|
13009
|
+
// endMs tracks the latest event in the group.
|
|
13010
|
+
function setGroupTime(g) {
|
|
13011
|
+
if (g.timeEl) g.timeEl.textContent = formatElapsed(Math.max(0, g.endMs - g.startMs));
|
|
13012
|
+
}
|
|
13013
|
+
function applyGroupHit(g, ev, endMs) {
|
|
13014
|
+
g.count += 1;
|
|
13015
|
+
if (ev.table && !g.tables[ev.table]) { g.tables[ev.table] = 1; g.tableCount += 1; }
|
|
13016
|
+
if (typeof endMs === 'number' && endMs > g.endMs) g.endMs = endMs;
|
|
13017
|
+
g.summaryEl.textContent = groupedSummary(g);
|
|
13018
|
+
setGroupTime(g);
|
|
13019
|
+
g.item._rowClickOff = true;
|
|
13020
|
+
g.item.classList.remove('feed-clickable');
|
|
13021
|
+
g.item.removeAttribute('tabindex');
|
|
13022
|
+
g.item.removeAttribute('title');
|
|
13023
|
+
g.item.setAttribute('role', 'status');
|
|
13024
|
+
}
|
|
13025
|
+
function newGroup(ev, card, startMs, endMs) {
|
|
13026
|
+
var tbls = {}; var tc = 0;
|
|
13027
|
+
if (ev.table) { tbls[ev.table] = 1; tc = 1; }
|
|
13028
|
+
return {
|
|
13029
|
+
op: ev.op, count: 1, tables: tbls, tableCount: tc,
|
|
13030
|
+
schemaKey: isSchemaOp(ev.op) ? schemaAction(ev.summary) : null,
|
|
13031
|
+
firstSummary: ev.summary || '',
|
|
13032
|
+
item: card.item, summaryEl: card.summaryEl, timeEl: card.timeEl,
|
|
13033
|
+
startMs: startMs, endMs: endMs,
|
|
13034
|
+
};
|
|
13035
|
+
}
|
|
13036
|
+
function renderFeedItem(ev) {
|
|
13037
|
+
var feedEl = document.getElementById('rail-feed');
|
|
13038
|
+
if (!feedEl) return;
|
|
13039
|
+
var empty = document.getElementById('rail-empty');
|
|
13040
|
+
if (empty) empty.remove();
|
|
13041
|
+
// Coalesce same-TYPE events into one counted card within a recency window \u2014
|
|
13042
|
+
// even across different objects (op+source key, table excluded), so a bulk
|
|
13043
|
+
// run collapses to one card ("Removed 49 rows across 9 tables") instead of
|
|
13044
|
+
// spamming the rail. Distinct tables touched are tracked so a single-table
|
|
13045
|
+
// run still reads "\u2026 from <table>".
|
|
13046
|
+
var groupKey = feedGroupKey(ev);
|
|
13047
|
+
var nowMs = Date.now();
|
|
13048
|
+
if (groupKey) {
|
|
13049
|
+
var g = feedGroups[groupKey];
|
|
13050
|
+
// A group stays open to merge while: (a) we're inside the SAME assistant
|
|
13051
|
+
// turn that opened it \u2014 no time limit, so a slow bulk run (deleting many
|
|
13052
|
+
// tables against a remote DB) stays one card instead of splitting when a
|
|
13053
|
+
// 15s window lapses mid-run; or (b) outside a turn (manual edits / another
|
|
13054
|
+
// client), within the rolling window. Cross-turn events never merge.
|
|
13055
|
+
var open = g && g.item.parentNode === feedEl && (
|
|
13056
|
+
feedTurnActive ? (g.turnId === feedTurnId) : ((nowMs - g.last) < FEED_GROUP_WINDOW_MS)
|
|
13057
|
+
);
|
|
13058
|
+
if (open) {
|
|
13059
|
+
applyGroupHit(g, ev, nowMs);
|
|
13060
|
+
g.last = nowMs;
|
|
13061
|
+
feedEl.scrollTop = feedEl.scrollHeight;
|
|
13062
|
+
return;
|
|
13063
|
+
}
|
|
13064
|
+
}
|
|
13065
|
+
var card = makeFeedCard(ev);
|
|
13066
|
+
// Keep a streaming chat turn's typing bubble pinned to the bottom: insert
|
|
13067
|
+
// this card above it rather than appending below (the dots are the next
|
|
13068
|
+
// message, not done yet). No active turn \u2192 append as usual.
|
|
13069
|
+
var anchor = feedTypingAnchor(feedEl);
|
|
13070
|
+
if (anchor) feedEl.insertBefore(card.item, anchor); else feedEl.appendChild(card.item);
|
|
12991
13071
|
feedEl.scrollTop = feedEl.scrollHeight;
|
|
12992
|
-
//
|
|
13072
|
+
// Anchor the card's duration to the turn start (so even a single-op card
|
|
13073
|
+
// shows how long the task took); fall back to now for non-turn activity.
|
|
13074
|
+
var startMs = (feedTurnActive && feedTurnStartMs) ? feedTurnStartMs : nowMs;
|
|
12993
13075
|
if (groupKey) {
|
|
12994
|
-
|
|
13076
|
+
var grp = newGroup(ev, card, startMs, nowMs);
|
|
13077
|
+
grp.turnId = feedTurnId;
|
|
13078
|
+
grp.last = nowMs;
|
|
13079
|
+
feedGroups[groupKey] = grp;
|
|
13080
|
+
setGroupTime(grp);
|
|
13081
|
+
} else {
|
|
13082
|
+
card.timeEl.textContent = formatElapsed(Math.max(0, nowMs - startMs));
|
|
13083
|
+
}
|
|
13084
|
+
}
|
|
13085
|
+
// Replay a persisted assistant turn's data-change events as collapsed activity
|
|
13086
|
+
// cards. Grouping is PER-TURN (self-contained, independent of the live feed's
|
|
13087
|
+
// rolling window) so each turn's bulk run shows one card and stays tied to the
|
|
13088
|
+
// turn that produced it. Reads aren't persisted as events, so only mutations
|
|
13089
|
+
// appear. Appends in order; the caller positions them after the turn's text.
|
|
13090
|
+
function renderTurnEventCards(feedEl, events, startedMs) {
|
|
13091
|
+
if (!feedEl || !events || !events.length) return;
|
|
13092
|
+
var groups = {};
|
|
13093
|
+
for (var i = 0; i < events.length; i++) {
|
|
13094
|
+
var ev = events[i];
|
|
13095
|
+
var evMs = ev.ts ? new Date(ev.ts).getTime() : startedMs;
|
|
13096
|
+
if (typeof evMs !== 'number' || isNaN(evMs)) evMs = startedMs;
|
|
13097
|
+
var startMs = (typeof startedMs === 'number' && !isNaN(startedMs)) ? startedMs : evMs;
|
|
13098
|
+
var key = feedGroupKey(ev);
|
|
13099
|
+
if (key && groups[key]) { applyGroupHit(groups[key], ev, evMs); continue; }
|
|
13100
|
+
var card = makeFeedCard(ev);
|
|
13101
|
+
feedEl.appendChild(card.item);
|
|
13102
|
+
if (key) { var g = newGroup(ev, card, startMs, evMs); groups[key] = g; setGroupTime(g); }
|
|
13103
|
+
else { card.timeEl.textContent = formatElapsed(Math.max(0, evMs - startMs)); }
|
|
12995
13104
|
}
|
|
12996
13105
|
}
|
|
12997
13106
|
function startFeed() {
|
|
@@ -13076,12 +13185,13 @@ var appJs = `
|
|
|
13076
13185
|
chatHistory = [];
|
|
13077
13186
|
var feedEl = railFeedEl();
|
|
13078
13187
|
if (!feedEl) return;
|
|
13079
|
-
//
|
|
13080
|
-
//
|
|
13081
|
-
//
|
|
13082
|
-
//
|
|
13083
|
-
var
|
|
13084
|
-
for (var i = 0; i <
|
|
13188
|
+
// The rail is conversation-scoped: clearing or switching a conversation
|
|
13189
|
+
// drops both its chat bubbles AND its activity cards (each conversation
|
|
13190
|
+
// replays its own data-change cards from the persisted per-turn events).
|
|
13191
|
+
// Reset the grouping anchors so a freshly loaded thread starts clean.
|
|
13192
|
+
var nodes = feedEl.querySelectorAll('.chat-msg, .feed-item');
|
|
13193
|
+
for (var i = 0; i < nodes.length; i++) nodes[i].remove();
|
|
13194
|
+
feedGroups = {};
|
|
13085
13195
|
// Restore the empty hint only when the rail is now completely empty.
|
|
13086
13196
|
if (!feedEl.firstElementChild) {
|
|
13087
13197
|
feedEl.innerHTML = '<div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>';
|
|
@@ -13134,11 +13244,12 @@ var appJs = `
|
|
|
13134
13244
|
msgs.forEach(function (m) {
|
|
13135
13245
|
if (m.role === 'user') { appendUserBubble(m.text); chatHistory.push({ role: 'user', text: m.text }); }
|
|
13136
13246
|
else if (m.role === 'assistant') {
|
|
13137
|
-
// Rich replay: the saved per-turn structure (text +
|
|
13138
|
-
// matching the live stream. Falls back to
|
|
13139
|
-
// messages saved before turns were persisted.
|
|
13140
|
-
if (Array.isArray(m.turns) && m.turns.length > 0) {
|
|
13141
|
-
|
|
13247
|
+
// Rich replay: the saved per-turn structure (text + the data-change
|
|
13248
|
+
// activity cards it produced), matching the live stream. Falls back to
|
|
13249
|
+
// a plain text bubble for messages saved before turns were persisted.
|
|
13250
|
+
if (Array.isArray(m.turns) && m.turns.length > 0) {
|
|
13251
|
+
m.turns.forEach(function (t) { appendAssistantTurn(t, m.created_at, m.startedAt); });
|
|
13252
|
+
} else { var c = newAssistantBubble(); setBubbleText(c, m.text); }
|
|
13142
13253
|
chatHistory.push({ role: 'assistant', text: m.text });
|
|
13143
13254
|
}
|
|
13144
13255
|
});
|
|
@@ -13162,126 +13273,88 @@ var appJs = `
|
|
|
13162
13273
|
railEmptyGone();
|
|
13163
13274
|
var feedEl = railFeedEl();
|
|
13164
13275
|
var msg = document.createElement('div'); msg.className = 'chat-msg assistant';
|
|
13165
|
-
var wrap = document.createElement('div');
|
|
13166
|
-
var tools = document.createElement('div'); tools.className = 'chat-tools';
|
|
13167
13276
|
var b = document.createElement('div'); b.className = 'chat-bubble assistant';
|
|
13168
13277
|
// Show an animated typing indicator until the first text delta arrives.
|
|
13169
13278
|
b.innerHTML = '<span class="chat-typing"><i></i><i></i><i></i></span>';
|
|
13170
13279
|
b.setAttribute('data-typing', '1');
|
|
13171
|
-
|
|
13172
|
-
msg
|
|
13173
|
-
// lastTool anchors the current run of identical tool calls so consecutive
|
|
13174
|
-
// same-name calls coalesce into one counted pill (see addToolPill).
|
|
13175
|
-
return { bubble: b, tools: tools, pills: {}, lastTool: null, msg: msg };
|
|
13280
|
+
msg.appendChild(b); feedEl.appendChild(msg); feedEl.scrollTop = feedEl.scrollHeight;
|
|
13281
|
+
return { bubble: b, msg: msg };
|
|
13176
13282
|
}
|
|
13177
13283
|
/** Set an assistant bubble's text, clearing the typing indicator. */
|
|
13284
|
+
// Turn [label](lattice://table/id) object references the assistant emits into
|
|
13285
|
+
// clickable pills that open the row (mode-aware, via openSearchHit). The
|
|
13286
|
+
// links are pulled out into placeholders BEFORE markdown rendering and the
|
|
13287
|
+
// pill HTML is swapped back in AFTER \u2014 so it's independent of mdToHtml's own
|
|
13288
|
+
// link handling and survives HTML-escaping. Labels/ids are re-escaped.
|
|
13289
|
+
function renderAssistantHtml(text) {
|
|
13290
|
+
var pills = [];
|
|
13291
|
+
// U+0002 sentinel survives mdToHtml's escape + inline passes untouched.
|
|
13292
|
+
// Use a unicode-escape string literal for insertion and a REGEX LITERAL for
|
|
13293
|
+
// the swap (one escaping level each) \u2014 a new RegExp('(\\d+)') here would be
|
|
13294
|
+
// double-collapsed by the template literal into a literal "d", silently
|
|
13295
|
+
// breaking the swap (the pill rendered as a bare index).
|
|
13296
|
+
var pre = String(text == null ? '' : text).replace(
|
|
13297
|
+
/\\[([^\\]]+)\\]\\(lattice:\\/\\/([a-zA-Z0-9_]+)\\/([^)\\s]+)\\)/g,
|
|
13298
|
+
function (_, label, table, id) {
|
|
13299
|
+
pills.push({ label: label, table: table, id: id });
|
|
13300
|
+
return '\\u0002' + (pills.length - 1) + '\\u0002';
|
|
13301
|
+
}
|
|
13302
|
+
);
|
|
13303
|
+
var html = mdToHtml(pre);
|
|
13304
|
+
return html.replace(/\\u0002([0-9]+)\\u0002/g, function (_, n) {
|
|
13305
|
+
var p = pills[Number(n)];
|
|
13306
|
+
return '<a class="chip chip-link lattice-ref" data-table="' + escapeHtml(p.table) +
|
|
13307
|
+
'" data-id="' + escapeHtml(p.id) + '" title="Open this ' + escapeHtml(p.table) + '">\u{1F517} ' +
|
|
13308
|
+
escapeHtml(p.label) + '</a>';
|
|
13309
|
+
});
|
|
13310
|
+
}
|
|
13311
|
+
// One delegated click handler on the rail feed: a lattice-ref pill opens its
|
|
13312
|
+
// object through the same mode-aware navigator the activity feed uses.
|
|
13313
|
+
var _latticeRefWired = false;
|
|
13314
|
+
function ensureLatticeRefHandler() {
|
|
13315
|
+
if (_latticeRefWired) return;
|
|
13316
|
+
var feedEl = document.getElementById('rail-feed');
|
|
13317
|
+
if (!feedEl) return;
|
|
13318
|
+
feedEl.addEventListener('click', function (e) {
|
|
13319
|
+
var a = e.target && e.target.closest ? e.target.closest('.lattice-ref') : null;
|
|
13320
|
+
if (!a) return;
|
|
13321
|
+
e.preventDefault();
|
|
13322
|
+
openSearchHit(a.getAttribute('data-table'), a.getAttribute('data-id'));
|
|
13323
|
+
});
|
|
13324
|
+
_latticeRefWired = true;
|
|
13325
|
+
}
|
|
13178
13326
|
function setBubbleText(ctx, text) {
|
|
13179
13327
|
if (!ctx || !ctx.bubble) return; // bubble may have been finalized/removed
|
|
13180
13328
|
ctx.bubble.removeAttribute('data-typing');
|
|
13181
13329
|
// Assistant turns are Markdown; render (input is HTML-escaped inside
|
|
13182
|
-
// mdToHtml first, so this is injection-safe).
|
|
13183
|
-
ctx.bubble.innerHTML =
|
|
13330
|
+
// mdToHtml first, so this is injection-safe) + linkify object references.
|
|
13331
|
+
ctx.bubble.innerHTML = renderAssistantHtml(text);
|
|
13332
|
+
ensureLatticeRefHandler();
|
|
13184
13333
|
}
|
|
13185
13334
|
/**
|
|
13186
|
-
* A turn ended
|
|
13187
|
-
*
|
|
13188
|
-
*
|
|
13189
|
-
* "typing\u2026" bubble after the stream completes.
|
|
13335
|
+
* A turn ended still showing the typing indicator (no text streamed) \u2014 drop
|
|
13336
|
+
* the empty bubble. The turn's data-change activity cards live in the rail
|
|
13337
|
+
* feed independently (not inside the message), so they remain.
|
|
13190
13338
|
*/
|
|
13191
13339
|
function finalizeBubble(ctx) {
|
|
13192
13340
|
if (!ctx || !ctx.bubble || !ctx.bubble.getAttribute('data-typing')) return;
|
|
13193
|
-
if (ctx.
|
|
13194
|
-
else if (ctx.msg) ctx.msg.remove();
|
|
13195
|
-
}
|
|
13196
|
-
var TOOL_VERBS = {
|
|
13197
|
-
create_row: ['Creating row', 'Row created', 'Could not create row'],
|
|
13198
|
-
update_row: ['Updating row', 'Row updated', 'Could not update row'],
|
|
13199
|
-
delete_row: ['Deleting row', 'Row deleted', 'Could not delete row'],
|
|
13200
|
-
list_rows: ['Listing rows', 'Listed rows', 'Could not list rows'],
|
|
13201
|
-
get_row: ['Fetching row', 'Fetched row', 'Could not fetch row'],
|
|
13202
|
-
list_entities: ['Listing tables', 'Listed tables', 'Could not list tables']
|
|
13203
|
-
};
|
|
13204
|
-
// Grouped (count > 1) [gerund, past, noun] so a run of identical tool calls
|
|
13205
|
-
// collapses into ONE counted pill \u2014 "Listed 5 rows" \u2014 instead of N identical
|
|
13206
|
-
// "Listed rows" pills. Mirrors the activity feed's groupedSummary() coalescing.
|
|
13207
|
-
var TOOL_GROUP = {
|
|
13208
|
-
create_row: ['Creating', 'Created', 'rows'],
|
|
13209
|
-
update_row: ['Updating', 'Updated', 'rows'],
|
|
13210
|
-
delete_row: ['Deleting', 'Deleted', 'rows'],
|
|
13211
|
-
list_rows: ['Listing', 'Listed', 'rows'],
|
|
13212
|
-
get_row: ['Fetching', 'Fetched', 'rows'],
|
|
13213
|
-
list_entities: ['Listing', 'Listed', 'tables']
|
|
13214
|
-
};
|
|
13215
|
-
function toolLabel(name, state) {
|
|
13216
|
-
var v = TOOL_VERBS[name] || [name, name, name];
|
|
13217
|
-
return state === 'pending' ? v[0] + '\u2026' : (state === 'error' ? v[2] : v[1]);
|
|
13218
|
-
}
|
|
13219
|
-
// Label for a run of "count" identical calls. count <= 1 falls back to the
|
|
13220
|
-
// single-call label so a lone pill reads exactly as before.
|
|
13221
|
-
function toolGroupLabel(name, count, state) {
|
|
13222
|
-
if (count <= 1) return toolLabel(name, state);
|
|
13223
|
-
var g = TOOL_GROUP[name];
|
|
13224
|
-
if (!g) return toolLabel(name, state) + ' \xD7' + count; // unknown tool: stay honest
|
|
13225
|
-
var verb = state === 'pending' ? g[0] : g[1];
|
|
13226
|
-
return verb + ' ' + count + ' ' + g[2] + (state === 'pending' ? '\u2026' : '');
|
|
13227
|
-
}
|
|
13228
|
-
// Paint a (possibly grouped) pill from its live counts: spinner while any call
|
|
13229
|
-
// is still running, then \u2713 (or \u26A0 if any errored) once every call resolves.
|
|
13230
|
-
function paintToolPill(g) {
|
|
13231
|
-
var pending = g.pending > 0;
|
|
13232
|
-
var err = !pending && g.error > 0;
|
|
13233
|
-
if (pending) {
|
|
13234
|
-
g.el.className = 'tool-pill';
|
|
13235
|
-
g.el.innerHTML = '<span class="spin"></span>' + escapeHtml(toolGroupLabel(g.name, g.total, 'pending'));
|
|
13236
|
-
} else {
|
|
13237
|
-
g.el.className = 'tool-pill ' + (err ? 'error' : 'done');
|
|
13238
|
-
g.el.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(g.name, g.total, err ? 'error' : 'done');
|
|
13239
|
-
}
|
|
13240
|
-
}
|
|
13241
|
-
function addToolPill(ctx, id, name) {
|
|
13242
|
-
// Coalesce a run of the same tool within this turn's pill row into one
|
|
13243
|
-
// counted pill (the model emits several list_rows in a single turn).
|
|
13244
|
-
var g = ctx.lastTool;
|
|
13245
|
-
if (g && g.name === name) {
|
|
13246
|
-
g.total += 1; g.pending += 1;
|
|
13247
|
-
} else {
|
|
13248
|
-
var pill = document.createElement('span'); pill.className = 'tool-pill';
|
|
13249
|
-
ctx.tools.appendChild(pill);
|
|
13250
|
-
g = { name: name, el: pill, total: 1, pending: 1, error: 0 };
|
|
13251
|
-
ctx.lastTool = g;
|
|
13252
|
-
}
|
|
13253
|
-
ctx.pills[id] = g; // resolveToolPill maps the tool-use id back to its group
|
|
13254
|
-
paintToolPill(g);
|
|
13255
|
-
}
|
|
13256
|
-
function resolveToolPill(ctx, id, isError) {
|
|
13257
|
-
var g = ctx.pills[id]; if (!g) return;
|
|
13258
|
-
if (g.pending > 0) g.pending -= 1;
|
|
13259
|
-
if (isError) g.error += 1;
|
|
13260
|
-
paintToolPill(g);
|
|
13341
|
+
if (ctx.msg) ctx.msg.remove();
|
|
13261
13342
|
}
|
|
13262
|
-
/**
|
|
13263
|
-
*
|
|
13264
|
-
*
|
|
13265
|
-
*/
|
|
13266
|
-
function
|
|
13267
|
-
var i = 0;
|
|
13268
|
-
while (i < tools.length) {
|
|
13269
|
-
var name = tools[i].name, j = i, errors = 0;
|
|
13270
|
-
while (j < tools.length && tools[j].name === name) { if (tools[j].isError) errors += 1; j++; }
|
|
13271
|
-
var count = j - i, err = errors > 0;
|
|
13272
|
-
var pill = document.createElement('span');
|
|
13273
|
-
pill.className = 'tool-pill ' + (err ? 'error' : 'done');
|
|
13274
|
-
pill.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(name, count, err ? 'error' : 'done');
|
|
13275
|
-
ctx.tools.appendChild(pill);
|
|
13276
|
-
i = j;
|
|
13277
|
-
}
|
|
13278
|
-
}
|
|
13279
|
-
/** Replay one persisted assistant turn: its tool pills + text bubble. */
|
|
13280
|
-
function appendAssistantTurn(turn) {
|
|
13343
|
+
/** Replay one persisted assistant turn: its text bubble + the data-change
|
|
13344
|
+
* activity cards it produced (collapsed, per-turn). Reads aren't persisted
|
|
13345
|
+
* as events, so a read-only turn with no text renders nothing. createdAt
|
|
13346
|
+
* stamps the cards' relative time (events carry no ts of their own). */
|
|
13347
|
+
function appendAssistantTurn(turn, createdAt, startedAt) {
|
|
13281
13348
|
var ctx = newAssistantBubble();
|
|
13282
|
-
renderResolvedPills(ctx, turn.tools || []);
|
|
13283
13349
|
if (turn.text) setBubbleText(ctx, turn.text);
|
|
13284
|
-
else finalizeBubble(ctx); //
|
|
13350
|
+
else finalizeBubble(ctx); // no text \u2192 drop the empty typing bubble
|
|
13351
|
+
var events = (turn.events || []).map(function (e) {
|
|
13352
|
+
return e.ts ? e : { op: e.op, table: e.table, rowId: e.rowId, summary: e.summary, source: e.source || 'ai', ts: createdAt };
|
|
13353
|
+
});
|
|
13354
|
+
// Task start for the duration timer: the persisted turn-start, else the
|
|
13355
|
+
// message time. Per-event ts (above) gives the run's finish.
|
|
13356
|
+
var startedMs = new Date(startedAt || createdAt || 0).getTime();
|
|
13357
|
+
renderTurnEventCards(railFeedEl(), events, startedMs);
|
|
13285
13358
|
}
|
|
13286
13359
|
function parseSse(buffer, onEvent) {
|
|
13287
13360
|
var sep;
|
|
@@ -13297,6 +13370,11 @@ var appJs = `
|
|
|
13297
13370
|
function sendChat(text) {
|
|
13298
13371
|
if (chatBusy || !text) return;
|
|
13299
13372
|
chatBusy = true;
|
|
13373
|
+
// Open a fresh turn scope: this turn's activity cards group together (no
|
|
13374
|
+
// window expiry) and their timers measure from now.
|
|
13375
|
+
feedTurnId += 1;
|
|
13376
|
+
feedTurnStartMs = Date.now();
|
|
13377
|
+
feedTurnActive = true;
|
|
13300
13378
|
appendUserBubble(text);
|
|
13301
13379
|
var historyToSend = chatHistory.slice();
|
|
13302
13380
|
chatHistory.push({ role: 'user', text: text });
|
|
@@ -13323,8 +13401,10 @@ var appJs = `
|
|
|
13323
13401
|
buf = parseSse(buf, function (ev) {
|
|
13324
13402
|
if (ev.type === 'assistant_message_start') { finalizeBubble(actx); actx = newAssistantBubble(); assembled = ''; }
|
|
13325
13403
|
else if (ev.type === 'text_delta' && actx) { assembled += ev.delta; setBubbleText(actx, assembled); railFeedEl().scrollTop = railFeedEl().scrollHeight; }
|
|
13326
|
-
|
|
13327
|
-
|
|
13404
|
+
// tool_use / tool_result are no longer painted as inline pills \u2014 the
|
|
13405
|
+
// assistant's data changes stream in as activity cards over the feed
|
|
13406
|
+
// SSE (renderFeedItem), which sit above the typing bubble. Reads emit
|
|
13407
|
+
// no card by design (only data changes show).
|
|
13328
13408
|
else if (ev.type === 'warn') { finalizeBubble(actx); var wb = newAssistantBubble(); setBubbleText(wb, '\u26A0 ' + ev.message); actx = null; }
|
|
13329
13409
|
else if (ev.type === 'error') { if (!actx) actx = newAssistantBubble(); setBubbleText(actx, (assembled ? assembled + '\\n' : '') + '\u26A0 ' + ev.message); }
|
|
13330
13410
|
});
|
|
@@ -13341,6 +13421,9 @@ var appJs = `
|
|
|
13341
13421
|
var c = newAssistantBubble(); setBubbleText(c, '\u26A0 ' + e.message);
|
|
13342
13422
|
}).finally(function () {
|
|
13343
13423
|
chatBusy = false;
|
|
13424
|
+
// Close the turn scope: later activity starts fresh cards (the next turn,
|
|
13425
|
+
// or manual edits via the rolling window).
|
|
13426
|
+
feedTurnActive = false;
|
|
13344
13427
|
var sb = document.getElementById('chat-send'); if (sb) sb.disabled = false;
|
|
13345
13428
|
var inp = document.getElementById('chat-input'); if (inp) inp.focus();
|
|
13346
13429
|
});
|
|
@@ -13476,7 +13559,10 @@ var appJs = `
|
|
|
13476
13559
|
'<div class="feed-icon"><span class="feed-spinner"></span></div>' +
|
|
13477
13560
|
'<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
|
|
13478
13561
|
'<div class="feed-time">0s</div>';
|
|
13479
|
-
|
|
13562
|
+
// Same bottom-pin rule as renderFeedItem: don't bury a streaming chat
|
|
13563
|
+
// turn's typing bubble beneath this card.
|
|
13564
|
+
var anchor = feedTypingAnchor(feedEl);
|
|
13565
|
+
if (anchor) feedEl.insertBefore(item, anchor); else feedEl.appendChild(item);
|
|
13480
13566
|
feedEl.scrollTop = feedEl.scrollHeight;
|
|
13481
13567
|
// Live elapsed-time counter while the upload + server-side extraction run.
|
|
13482
13568
|
// Previously the time element was left empty (rendered as a stuck "0s")
|
|
@@ -18449,19 +18535,8 @@ async function getCreatorEmail(db) {
|
|
|
18449
18535
|
return null;
|
|
18450
18536
|
}
|
|
18451
18537
|
}
|
|
18452
|
-
function computeState(type,
|
|
18538
|
+
function computeState(type, creatorEmail) {
|
|
18453
18539
|
if (type === "sqlite") return "local";
|
|
18454
|
-
if (!teamEnabled) return "team-cloud-needs-invite";
|
|
18455
|
-
if (!label) {
|
|
18456
|
-
return "team-cloud-needs-invite";
|
|
18457
|
-
}
|
|
18458
|
-
let token = null;
|
|
18459
|
-
try {
|
|
18460
|
-
token = readToken(label);
|
|
18461
|
-
} catch {
|
|
18462
|
-
token = null;
|
|
18463
|
-
}
|
|
18464
|
-
if (!token) return "team-cloud-needs-invite";
|
|
18465
18540
|
const identity = readIdentity();
|
|
18466
18541
|
if (creatorEmail !== null && identity.email.length > 0 && creatorEmail.toLowerCase() === identity.email.toLowerCase()) {
|
|
18467
18542
|
return "team-cloud-creator";
|
|
@@ -18469,8 +18544,9 @@ function computeState(type, teamEnabled, label, creatorEmail) {
|
|
|
18469
18544
|
return "team-cloud-member";
|
|
18470
18545
|
}
|
|
18471
18546
|
function applyTeamMembershipState(info, membership) {
|
|
18472
|
-
if (
|
|
18473
|
-
|
|
18547
|
+
if (info.type !== "postgres") return info.state;
|
|
18548
|
+
if (membership) return membership.isCreator ? "team-cloud-creator" : "team-cloud-member";
|
|
18549
|
+
return info.state;
|
|
18474
18550
|
}
|
|
18475
18551
|
async function describeCurrent(configPath, db) {
|
|
18476
18552
|
const rawYaml = readFileSync14(configPath, "utf8");
|
|
@@ -18492,7 +18568,7 @@ async function describeCurrent(configPath, db) {
|
|
|
18492
18568
|
if (labelMatch) {
|
|
18493
18569
|
const label = labelMatch[1] ?? "";
|
|
18494
18570
|
const url = getDbCredential(label);
|
|
18495
|
-
const state = computeState("postgres",
|
|
18571
|
+
const state = computeState("postgres", creatorEmail);
|
|
18496
18572
|
if (url) {
|
|
18497
18573
|
const parsed = parsePostgresUrl(url);
|
|
18498
18574
|
if (parsed) {
|
|
@@ -18519,7 +18595,7 @@ async function describeCurrent(configPath, db) {
|
|
|
18519
18595
|
}
|
|
18520
18596
|
if (/^postgres(ql)?:\/\//i.test(dbLine)) {
|
|
18521
18597
|
const parsed = parsePostgresUrl(dbLine);
|
|
18522
|
-
const state = computeState("postgres",
|
|
18598
|
+
const state = computeState("postgres", creatorEmail);
|
|
18523
18599
|
return parsed ? {
|
|
18524
18600
|
type: "postgres",
|
|
18525
18601
|
state,
|
|
@@ -20001,6 +20077,7 @@ var BASE_SYSTEM_PROMPT = [
|
|
|
20001
20077
|
"- To relate two tables (link their rows), call create_relationship(table_a, table_b) to get a junction + its two foreign-key columns, then `link` each pair using those columns. If the junction already exists, just `link`.",
|
|
20002
20078
|
"- Use the exact table names from the schema (or one you just created) \u2014 never guess a name for a table that should already exist.",
|
|
20003
20079
|
"- Prefer reading (list_rows, get_row) before writing.",
|
|
20080
|
+
'- When you point the user at a specific row/object \u2014 especially if they ask you to "link", "open", or "show" it \u2014 make it clickable with an INLINE link in this exact form: [short label](lattice://<table>/<id>), using the real table name and the row id from your tool results (e.g. [the offer contract](lattice://contracts/9b7c60f0-fbc2-4f87-a550-c59e3c5d761f)). It renders as a pill that opens that object in the GUI. Only link ids you actually retrieved \u2014 never invent one \u2014 and prefer the user-facing record (the contract/person/etc. row) over an internal `files` id.',
|
|
20004
20081
|
"- Attached files are rows in the `files` table; a file's full text content (CSV, document, etc.) is in its `extracted_text` column. To work from an attached file, read the relevant `files` row(s) and parse `extracted_text` \u2014 never guess a file's contents.",
|
|
20005
20082
|
'- A tool result that contains "error" means the call FAILED. Do NOT claim success or proceed as if it returned data \u2014 read the error, correct your arguments, and retry.',
|
|
20006
20083
|
"- For bulk work, emit several tool calls in one turn instead of one at a time. Every change is recorded in version history and can be undone.",
|
|
@@ -20496,15 +20573,14 @@ var REHYDRATE_MAX_BYTES = 24e3;
|
|
|
20496
20573
|
function rehydrateEnabled() {
|
|
20497
20574
|
return process.env.LATTICE_CHAT_REHYDRATE !== "false";
|
|
20498
20575
|
}
|
|
20499
|
-
async function persistMessage(db, threadId, role, text, turns) {
|
|
20576
|
+
async function persistMessage(db, threadId, role, text, turns, startedAt) {
|
|
20577
|
+
const payload = turns && turns.length > 0 ? { text, turns } : { text };
|
|
20578
|
+
if (startedAt) payload.startedAt = startedAt;
|
|
20500
20579
|
await db.insert("chat_messages", {
|
|
20501
20580
|
id: crypto.randomUUID(),
|
|
20502
20581
|
thread_id: threadId,
|
|
20503
20582
|
role,
|
|
20504
|
-
|
|
20505
|
-
// `turns` carries the rich structure so a reloaded conversation shows the
|
|
20506
|
-
// same text bubbles + tool pills as the live stream, not one text wall.
|
|
20507
|
-
content_json: JSON.stringify(turns && turns.length > 0 ? { text, turns } : { text }),
|
|
20583
|
+
content_json: JSON.stringify(payload),
|
|
20508
20584
|
source: role === "user" ? "gui" : "ai"
|
|
20509
20585
|
});
|
|
20510
20586
|
}
|
|
@@ -20526,11 +20602,17 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20526
20602
|
const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
|
|
20527
20603
|
let text = "";
|
|
20528
20604
|
let turns2;
|
|
20605
|
+
let startedAt;
|
|
20529
20606
|
try {
|
|
20530
20607
|
const parsed = JSON.parse(asStr(r.content_json, "{}"));
|
|
20531
20608
|
text = parsed.text ?? "";
|
|
20609
|
+
if (typeof parsed.startedAt === "string") startedAt = parsed.startedAt;
|
|
20532
20610
|
if (Array.isArray(parsed.turns)) {
|
|
20533
|
-
turns2 = parsed.turns.map((t) => ({
|
|
20611
|
+
turns2 = parsed.turns.map((t) => ({
|
|
20612
|
+
text: t.text,
|
|
20613
|
+
tools: t.tools,
|
|
20614
|
+
...t.events ? { events: t.events } : {}
|
|
20615
|
+
}));
|
|
20534
20616
|
}
|
|
20535
20617
|
} catch {
|
|
20536
20618
|
}
|
|
@@ -20538,6 +20620,7 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20538
20620
|
role: asStr(r.role),
|
|
20539
20621
|
text,
|
|
20540
20622
|
...turns2 ? { turns: turns2 } : {},
|
|
20623
|
+
...startedAt ? { startedAt } : {},
|
|
20541
20624
|
created_at: asStr(r.created_at)
|
|
20542
20625
|
};
|
|
20543
20626
|
}).sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
@@ -20594,8 +20677,21 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20594
20677
|
...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
|
|
20595
20678
|
...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
|
|
20596
20679
|
};
|
|
20680
|
+
const turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
20597
20681
|
let assistantText = "";
|
|
20598
20682
|
const turns = [];
|
|
20683
|
+
const unsubscribeFeed = ctx.feed.subscribe((fe) => {
|
|
20684
|
+
if (fe.source !== "ai") return;
|
|
20685
|
+
const cur = turns[turns.length - 1];
|
|
20686
|
+
if (cur)
|
|
20687
|
+
cur.events.push({
|
|
20688
|
+
op: fe.op,
|
|
20689
|
+
table: fe.table,
|
|
20690
|
+
rowId: fe.rowId,
|
|
20691
|
+
summary: fe.summary ?? "",
|
|
20692
|
+
ts: fe.ts
|
|
20693
|
+
});
|
|
20694
|
+
});
|
|
20599
20695
|
try {
|
|
20600
20696
|
const client = createAnthropicClient(auth);
|
|
20601
20697
|
const temperature = aggressivenessToTemperature(getAggressiveness());
|
|
@@ -20611,7 +20707,7 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20611
20707
|
}
|
|
20612
20708
|
})) {
|
|
20613
20709
|
if (ev.type === "assistant_message_start") {
|
|
20614
|
-
turns.push({ text: "", tools: [], toolCalls: [] });
|
|
20710
|
+
turns.push({ text: "", tools: [], events: [], toolCalls: [] });
|
|
20615
20711
|
} else if (ev.type === "text_delta") {
|
|
20616
20712
|
assistantText += ev.delta;
|
|
20617
20713
|
const cur = turns[turns.length - 1];
|
|
@@ -20634,16 +20730,19 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20634
20730
|
res.write(formatSseFrame({ type: "done" }));
|
|
20635
20731
|
} catch {
|
|
20636
20732
|
}
|
|
20733
|
+
} finally {
|
|
20734
|
+
unsubscribeFeed();
|
|
20637
20735
|
}
|
|
20638
20736
|
res.end();
|
|
20639
20737
|
if (threadId) {
|
|
20640
20738
|
const cleanTurns = turns.map((t) => ({
|
|
20641
20739
|
text: t.text,
|
|
20642
20740
|
tools: t.tools.map((x) => ({ name: x.name, isError: x.isError })),
|
|
20741
|
+
...t.events.length > 0 ? { events: t.events } : {},
|
|
20643
20742
|
...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
|
|
20644
|
-
})).filter((t) => t.text.length > 0 || t.tools.length > 0);
|
|
20743
|
+
})).filter((t) => t.text.length > 0 || t.tools.length > 0 || (t.events?.length ?? 0) > 0);
|
|
20645
20744
|
try {
|
|
20646
|
-
await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns);
|
|
20745
|
+
await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
|
|
20647
20746
|
} catch (e) {
|
|
20648
20747
|
console.warn("[chat] persist assistant message failed:", e.message);
|
|
20649
20748
|
}
|
|
@@ -22462,34 +22561,6 @@ data: ${JSON.stringify(data)}
|
|
|
22462
22561
|
} catch {
|
|
22463
22562
|
}
|
|
22464
22563
|
};
|
|
22465
|
-
try {
|
|
22466
|
-
const auditBackfill = await active.db.query("_lattice_gui_audit", {
|
|
22467
|
-
orderBy: "ts",
|
|
22468
|
-
orderDir: "desc",
|
|
22469
|
-
limit: 20
|
|
22470
|
-
});
|
|
22471
|
-
for (const a of auditBackfill.reverse()) {
|
|
22472
|
-
const json = a.operation === "delete" ? a.before_json : a.after_json;
|
|
22473
|
-
let labelRow;
|
|
22474
|
-
if (json) {
|
|
22475
|
-
try {
|
|
22476
|
-
labelRow = JSON.parse(json);
|
|
22477
|
-
} catch {
|
|
22478
|
-
labelRow = void 0;
|
|
22479
|
-
}
|
|
22480
|
-
}
|
|
22481
|
-
writeFeed({
|
|
22482
|
-
seq: 0,
|
|
22483
|
-
table: a.table_name,
|
|
22484
|
-
op: a.operation,
|
|
22485
|
-
rowId: a.row_id,
|
|
22486
|
-
source: "gui",
|
|
22487
|
-
ts: a.ts,
|
|
22488
|
-
summary: feedSummary(a.operation, a.table_name, labelRow)
|
|
22489
|
-
});
|
|
22490
|
-
}
|
|
22491
|
-
} catch {
|
|
22492
|
-
}
|
|
22493
22564
|
const keepalive = setInterval(() => {
|
|
22494
22565
|
try {
|
|
22495
22566
|
res.write(`: keepalive
|
package/package.json
CHANGED