latticesql 2.0.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 +9 -3
- package/dist/cli.js +1217 -818
- package/dist/index.cjs +41 -3
- package/dist/index.d.cts +23 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +41 -3
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -230,7 +230,9 @@ function writeIdentity(identity) {
|
|
|
230
230
|
var PREFERENCES_FILENAME = "preferences.json";
|
|
231
231
|
var DEFAULT_PREFERENCES = {
|
|
232
232
|
show_system_tables: false,
|
|
233
|
-
analytics: true
|
|
233
|
+
analytics: true,
|
|
234
|
+
voice_provider: "auto",
|
|
235
|
+
aggressiveness: 0.5
|
|
234
236
|
};
|
|
235
237
|
function readPreferences() {
|
|
236
238
|
const dir = ensureConfigDir();
|
|
@@ -238,9 +240,12 @@ function readPreferences() {
|
|
|
238
240
|
if (!existsSync2(path2)) return { ...DEFAULT_PREFERENCES };
|
|
239
241
|
try {
|
|
240
242
|
const parsed = JSON.parse(readFileSync(path2, "utf8"));
|
|
243
|
+
const agg = typeof parsed.aggressiveness === "number" ? parsed.aggressiveness : NaN;
|
|
241
244
|
return {
|
|
242
245
|
show_system_tables: typeof parsed.show_system_tables === "boolean" ? parsed.show_system_tables : DEFAULT_PREFERENCES.show_system_tables,
|
|
243
|
-
analytics: typeof parsed.analytics === "boolean" ? parsed.analytics : DEFAULT_PREFERENCES.analytics
|
|
246
|
+
analytics: typeof parsed.analytics === "boolean" ? parsed.analytics : DEFAULT_PREFERENCES.analytics,
|
|
247
|
+
voice_provider: parsed.voice_provider === "openai" || parsed.voice_provider === "elevenlabs" || parsed.voice_provider === "auto" ? parsed.voice_provider : DEFAULT_PREFERENCES.voice_provider,
|
|
248
|
+
aggressiveness: Number.isFinite(agg) ? Math.min(1, Math.max(0, agg)) : DEFAULT_PREFERENCES.aggressiveness
|
|
244
249
|
};
|
|
245
250
|
} catch {
|
|
246
251
|
return { ...DEFAULT_PREFERENCES };
|
|
@@ -250,7 +255,12 @@ function writePreferences(prefs) {
|
|
|
250
255
|
const dir = ensureConfigDir();
|
|
251
256
|
const path2 = join2(dir, PREFERENCES_FILENAME);
|
|
252
257
|
const body = JSON.stringify(
|
|
253
|
-
{
|
|
258
|
+
{
|
|
259
|
+
show_system_tables: prefs.show_system_tables,
|
|
260
|
+
analytics: prefs.analytics,
|
|
261
|
+
voice_provider: prefs.voice_provider,
|
|
262
|
+
aggressiveness: prefs.aggressiveness
|
|
263
|
+
},
|
|
254
264
|
null,
|
|
255
265
|
2
|
|
256
266
|
);
|
|
@@ -407,12 +417,6 @@ function assertSafeLabel(label) {
|
|
|
407
417
|
throw new Error(`Invalid label "${label}": must match [A-Za-z0-9._-]+ and not start with .`);
|
|
408
418
|
}
|
|
409
419
|
}
|
|
410
|
-
function readToken(label) {
|
|
411
|
-
assertSafeLabel(label);
|
|
412
|
-
const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
|
|
413
|
-
if (!existsSync2(path2)) return null;
|
|
414
|
-
return readFileSync(path2, "utf8").trim();
|
|
415
|
-
}
|
|
416
420
|
function writeToken(label, token) {
|
|
417
421
|
assertSafeLabel(label);
|
|
418
422
|
const path2 = join2(ensureKeysDir(), label + TOKEN_EXT);
|
|
@@ -1598,6 +1602,19 @@ var SchemaManager = class {
|
|
|
1598
1602
|
redefineEntityContext(table, def) {
|
|
1599
1603
|
this._entityContexts.set(table, def);
|
|
1600
1604
|
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Remove a table from the registry — the inverse of {@link define}. Clears the
|
|
1607
|
+
* table def, its primary-key, any multi-render, and its entity context. A no-op
|
|
1608
|
+
* if the table was never defined. Used by the GUI's soft table-delete to drop a
|
|
1609
|
+
* runtime-registered table without a full reopen; the physical SQL table is left
|
|
1610
|
+
* intact by the caller so the delete stays revertible.
|
|
1611
|
+
*/
|
|
1612
|
+
undefine(table) {
|
|
1613
|
+
this._tables.delete(table);
|
|
1614
|
+
this._tablePK.delete(table);
|
|
1615
|
+
this._multis.delete(table);
|
|
1616
|
+
this._entityContexts.delete(table);
|
|
1617
|
+
}
|
|
1601
1618
|
getTables() {
|
|
1602
1619
|
return this._tables;
|
|
1603
1620
|
}
|
|
@@ -4531,6 +4548,21 @@ var Lattice = class _Lattice {
|
|
|
4531
4548
|
}
|
|
4532
4549
|
return this;
|
|
4533
4550
|
}
|
|
4551
|
+
/**
|
|
4552
|
+
* Remove a runtime-registered table from the live schema registry — the
|
|
4553
|
+
* inverse of {@link defineLate}. The table stops being listed/queryable
|
|
4554
|
+
* WITHOUT a full reopen (which would dispose this instance and invalidate any
|
|
4555
|
+
* captured `db`/feed references mid-operation — see the GUI's soft table
|
|
4556
|
+
* delete). Does NOT drop the physical SQL table or its rows; the caller keeps
|
|
4557
|
+
* them so the delete remains revertible. A no-op if the table isn't
|
|
4558
|
+
* registered.
|
|
4559
|
+
*/
|
|
4560
|
+
unregisterTable(table) {
|
|
4561
|
+
this._schema.undefine(table);
|
|
4562
|
+
this._columnCache.delete(table);
|
|
4563
|
+
this._changelogTables.delete(table);
|
|
4564
|
+
return this;
|
|
4565
|
+
}
|
|
4534
4566
|
_registerTable(table, def) {
|
|
4535
4567
|
const columns = def.rewardTracking ? { ...def.columns, _reward_total: "REAL DEFAULT 0", _reward_count: "INTEGER DEFAULT 0" } : def.columns;
|
|
4536
4568
|
const renderTemplateName = _resolveTemplateName(def.render);
|
|
@@ -6107,6 +6139,205 @@ async function tryHandler(res, fn) {
|
|
|
6107
6139
|
// src/gui/data.ts
|
|
6108
6140
|
import { existsSync as existsSync14, readFileSync as readFileSync10, statSync as statSync4 } from "fs";
|
|
6109
6141
|
import { basename as basename3, join as join13, relative, resolve as resolve5, sep as sep2 } from "path";
|
|
6142
|
+
|
|
6143
|
+
// src/framework/native-entities.ts
|
|
6144
|
+
var NATIVE_ENTITY_DEFS = {
|
|
6145
|
+
secrets: {
|
|
6146
|
+
columns: {
|
|
6147
|
+
id: "TEXT PRIMARY KEY",
|
|
6148
|
+
// NOT NULL needs a DEFAULT so ALTER TABLE ADD COLUMN succeeds when this
|
|
6149
|
+
// native shape is merged onto a pre-existing table (the adopt + team
|
|
6150
|
+
// shared-schema sync paths use ADD COLUMN; SQLite + Postgres both reject
|
|
6151
|
+
// a NOT NULL add without a default). Every insert sets `name` explicitly,
|
|
6152
|
+
// so the default is never observed in practice.
|
|
6153
|
+
name: "TEXT NOT NULL DEFAULT ''",
|
|
6154
|
+
kind: "TEXT",
|
|
6155
|
+
value: "TEXT",
|
|
6156
|
+
description: "TEXT",
|
|
6157
|
+
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6158
|
+
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6159
|
+
deleted_at: "TEXT"
|
|
6160
|
+
},
|
|
6161
|
+
encrypted: { columns: ["value"] },
|
|
6162
|
+
render: () => "",
|
|
6163
|
+
outputFile: ".lattice-native/secrets.md"
|
|
6164
|
+
},
|
|
6165
|
+
files: {
|
|
6166
|
+
columns: {
|
|
6167
|
+
id: "TEXT PRIMARY KEY",
|
|
6168
|
+
// Legacy columns (DEPRECATED v2.0 — retained for back-compat, NOT dropped).
|
|
6169
|
+
// path — superseded by the reference model below (`ref_kind`/`ref_uri`).
|
|
6170
|
+
// New local-file ingestion records a `local_ref` via
|
|
6171
|
+
// `referenceLocalFile()` rather than writing `path`; readers fall
|
|
6172
|
+
// back to `ref_uri`. Do not write `path` in new code.
|
|
6173
|
+
// kind — orphaned: superseded by `mime` (content type) and `ref_kind`
|
|
6174
|
+
// (the blob/local_ref/cloud_ref discriminator). Not read or
|
|
6175
|
+
// written by production code; kept only so existing rows retain
|
|
6176
|
+
// their value.
|
|
6177
|
+
path: "TEXT",
|
|
6178
|
+
kind: "TEXT",
|
|
6179
|
+
// Content-addressed storage. `sha256` is the canonical content
|
|
6180
|
+
// identifier; `blob_path` is the relative path under
|
|
6181
|
+
// `<lattice-root>/data/blobs/` written by attachBlob().
|
|
6182
|
+
original_name: "TEXT",
|
|
6183
|
+
mime: "TEXT",
|
|
6184
|
+
size_bytes: "INTEGER",
|
|
6185
|
+
sha256: "TEXT",
|
|
6186
|
+
blob_path: "TEXT",
|
|
6187
|
+
// Reference mode (v2.0): a row can INDEX data that lives elsewhere
|
|
6188
|
+
// instead of owning a copy. All nullable + additive (back-compat).
|
|
6189
|
+
// ref_kind discriminator: 'blob' | 'local_ref' | 'cloud_ref'
|
|
6190
|
+
// (NULL ⇒ legacy/owned blob)
|
|
6191
|
+
// ref_uri durable pointer: absolute local path or remote URL
|
|
6192
|
+
// ref_provider resolver selector: 'fs' | 'web' | 'gdrive'
|
|
6193
|
+
// source_json provider-specific metadata (etag, availability, …)
|
|
6194
|
+
ref_kind: "TEXT",
|
|
6195
|
+
ref_uri: "TEXT",
|
|
6196
|
+
ref_provider: "TEXT",
|
|
6197
|
+
source_json: "TEXT",
|
|
6198
|
+
extraction_status: "TEXT",
|
|
6199
|
+
extracted_text: "TEXT",
|
|
6200
|
+
description: "TEXT",
|
|
6201
|
+
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6202
|
+
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6203
|
+
deleted_at: "TEXT"
|
|
6204
|
+
},
|
|
6205
|
+
render: () => "",
|
|
6206
|
+
outputFile: ".lattice-native/files.md"
|
|
6207
|
+
},
|
|
6208
|
+
notes: {
|
|
6209
|
+
// A generic knowledge object: a free-form note with a title and body.
|
|
6210
|
+
// Ordinary, user-editable rows; `source_file_id` optionally points back at
|
|
6211
|
+
// an originating `files` row. Retained as native (1.16.3) because the
|
|
6212
|
+
// reference/source-organizer store uses it as the fallback organizer target.
|
|
6213
|
+
columns: {
|
|
6214
|
+
id: "TEXT PRIMARY KEY",
|
|
6215
|
+
title: "TEXT",
|
|
6216
|
+
body: "TEXT",
|
|
6217
|
+
source_file_id: "TEXT",
|
|
6218
|
+
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6219
|
+
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6220
|
+
deleted_at: "TEXT"
|
|
6221
|
+
},
|
|
6222
|
+
render: () => "",
|
|
6223
|
+
outputFile: ".lattice-native/notes.md"
|
|
6224
|
+
},
|
|
6225
|
+
chat_threads: {
|
|
6226
|
+
// An assistant conversation. Native so chat history survives across
|
|
6227
|
+
// sessions and is queryable/renderable like any other Lattice entity.
|
|
6228
|
+
columns: {
|
|
6229
|
+
id: "TEXT PRIMARY KEY",
|
|
6230
|
+
title: "TEXT",
|
|
6231
|
+
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6232
|
+
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6233
|
+
deleted_at: "TEXT"
|
|
6234
|
+
},
|
|
6235
|
+
render: () => "",
|
|
6236
|
+
outputFile: ".lattice-native/chat-threads.md"
|
|
6237
|
+
},
|
|
6238
|
+
chat_messages: {
|
|
6239
|
+
// One turn (or feed entry) within a chat_thread.
|
|
6240
|
+
columns: {
|
|
6241
|
+
id: "TEXT PRIMARY KEY",
|
|
6242
|
+
// Soft reference to chat_threads.id. Kept as a plain column (no FK)
|
|
6243
|
+
// to match the generic, dialect-agnostic native-entity style.
|
|
6244
|
+
thread_id: "TEXT",
|
|
6245
|
+
// user | assistant | tool | feed | system
|
|
6246
|
+
role: "TEXT NOT NULL DEFAULT 'user'",
|
|
6247
|
+
// JSON payload: text, tool_use / tool_result blocks, attachments, or
|
|
6248
|
+
// (for role='feed') the feed-event details.
|
|
6249
|
+
content_json: "TEXT",
|
|
6250
|
+
// ai | gui | cli | ingest — meaningful for role='feed'.
|
|
6251
|
+
source: "TEXT",
|
|
6252
|
+
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
6253
|
+
deleted_at: "TEXT"
|
|
6254
|
+
},
|
|
6255
|
+
render: () => "",
|
|
6256
|
+
outputFile: ".lattice-native/chat-messages.md"
|
|
6257
|
+
}
|
|
6258
|
+
};
|
|
6259
|
+
var NATIVE_ENTITY_NAMES = new Set(Object.keys(NATIVE_ENTITY_DEFS));
|
|
6260
|
+
function isNativeEntity(name) {
|
|
6261
|
+
return NATIVE_ENTITY_NAMES.has(name);
|
|
6262
|
+
}
|
|
6263
|
+
var NATIVE_INTERNAL_NAMES = /* @__PURE__ */ new Set([
|
|
6264
|
+
"chat_threads",
|
|
6265
|
+
"chat_messages"
|
|
6266
|
+
]);
|
|
6267
|
+
function isInternalNativeEntity(name) {
|
|
6268
|
+
return NATIVE_INTERNAL_NAMES.has(name);
|
|
6269
|
+
}
|
|
6270
|
+
function registerNativeEntities(db) {
|
|
6271
|
+
const existing = new Set(db.getRegisteredTableNames());
|
|
6272
|
+
for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
|
|
6273
|
+
if (existing.has(name)) continue;
|
|
6274
|
+
db.define(name, def);
|
|
6275
|
+
}
|
|
6276
|
+
}
|
|
6277
|
+
var NATIVE_REGISTRY_TABLE = "__lattice_native_entities";
|
|
6278
|
+
var NATIVE_REGISTRY_DEF = {
|
|
6279
|
+
columns: {
|
|
6280
|
+
entity: "TEXT PRIMARY KEY",
|
|
6281
|
+
table_name: "TEXT NOT NULL",
|
|
6282
|
+
adopted_at: "TEXT NOT NULL",
|
|
6283
|
+
origin: "TEXT NOT NULL"
|
|
6284
|
+
},
|
|
6285
|
+
primaryKey: "entity",
|
|
6286
|
+
render: () => "",
|
|
6287
|
+
outputFile: ".lattice-native/native-entities.md"
|
|
6288
|
+
};
|
|
6289
|
+
async function adoptNativeEntities(db, options = {}) {
|
|
6290
|
+
const onConflict = options.onConflict ?? "adopt";
|
|
6291
|
+
await db.defineLate(NATIVE_REGISTRY_TABLE, NATIVE_REGISTRY_DEF);
|
|
6292
|
+
const results = [];
|
|
6293
|
+
for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
|
|
6294
|
+
const physicalCols = await db.introspectColumns(name);
|
|
6295
|
+
const exists = physicalCols.length > 0;
|
|
6296
|
+
const registered = new Set(db.getRegisteredTableNames());
|
|
6297
|
+
if (!exists) {
|
|
6298
|
+
if (!registered.has(name)) await db.defineLate(name, def);
|
|
6299
|
+
results.push({ entity: name, tableName: name, origin: "created" });
|
|
6300
|
+
await recordNativeBinding(db, name, "created");
|
|
6301
|
+
continue;
|
|
6302
|
+
}
|
|
6303
|
+
if (onConflict === "error") {
|
|
6304
|
+
throw new Error(
|
|
6305
|
+
`adoptNativeEntities: physical table "${name}" already exists; refusing to adopt with onConflict:'error'`
|
|
6306
|
+
);
|
|
6307
|
+
}
|
|
6308
|
+
const nativeCols = new Set(Object.keys(def.columns));
|
|
6309
|
+
const hadForeignShape = physicalCols.some((c) => !nativeCols.has(c));
|
|
6310
|
+
const rowCount = await db.count(name);
|
|
6311
|
+
let origin = hadForeignShape || rowCount > 0 ? "adopted" : "created";
|
|
6312
|
+
if (onConflict === "skip") {
|
|
6313
|
+
origin = "skipped";
|
|
6314
|
+
} else if (!registered.has(name)) {
|
|
6315
|
+
await db.defineLate(name, def);
|
|
6316
|
+
}
|
|
6317
|
+
results.push({ entity: name, tableName: name, origin });
|
|
6318
|
+
await recordNativeBinding(db, name, origin);
|
|
6319
|
+
}
|
|
6320
|
+
return results;
|
|
6321
|
+
}
|
|
6322
|
+
async function listNativeBindings(db) {
|
|
6323
|
+
if (!db.getRegisteredTableNames().includes(NATIVE_REGISTRY_TABLE)) return [];
|
|
6324
|
+
const rows = await db.query(NATIVE_REGISTRY_TABLE);
|
|
6325
|
+
return rows.map((r) => ({
|
|
6326
|
+
entity: String(r.entity),
|
|
6327
|
+
tableName: String(r.table_name),
|
|
6328
|
+
origin: r.origin
|
|
6329
|
+
}));
|
|
6330
|
+
}
|
|
6331
|
+
async function recordNativeBinding(db, entity, origin) {
|
|
6332
|
+
await db.upsert(NATIVE_REGISTRY_TABLE, {
|
|
6333
|
+
entity,
|
|
6334
|
+
table_name: entity,
|
|
6335
|
+
adopted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
6336
|
+
origin
|
|
6337
|
+
});
|
|
6338
|
+
}
|
|
6339
|
+
|
|
6340
|
+
// src/gui/data.ts
|
|
6110
6341
|
function tableToSummary(name, definition) {
|
|
6111
6342
|
return {
|
|
6112
6343
|
name,
|
|
@@ -6264,6 +6495,7 @@ function buildGuiGraph(configPath, outputDir, options = {}) {
|
|
|
6264
6495
|
const filter = options.visibleFilter;
|
|
6265
6496
|
data.tables = data.tables.filter((t) => filter(t.name));
|
|
6266
6497
|
}
|
|
6498
|
+
data.tables = data.tables.filter((t) => !isInternalNativeEntity(t.name));
|
|
6267
6499
|
const nodes = /* @__PURE__ */ new Map();
|
|
6268
6500
|
const edges = /* @__PURE__ */ new Map();
|
|
6269
6501
|
const fileOwners = /* @__PURE__ */ new Map();
|
|
@@ -6742,6 +6974,9 @@ var css = `
|
|
|
6742
6974
|
padding: 0 12px; margin: 12px 0 6px;
|
|
6743
6975
|
}
|
|
6744
6976
|
.section-label:first-child { margin-top: 0; }
|
|
6977
|
+
/* Extra breathing room above the "SYSTEM" heading so it isn't cramped
|
|
6978
|
+
against the object list above it. */
|
|
6979
|
+
#system-section .section-label { margin-top: 20px; }
|
|
6745
6980
|
nav ul { list-style: none; padding: 0; margin: 0; }
|
|
6746
6981
|
nav li a {
|
|
6747
6982
|
display: flex; align-items: center; gap: 10px;
|
|
@@ -6845,6 +7080,8 @@ var css = `
|
|
|
6845
7080
|
}
|
|
6846
7081
|
a.chip-link { cursor: pointer; }
|
|
6847
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; }
|
|
6848
7085
|
.empty-row td {
|
|
6849
7086
|
color: var(--text-muted); font-style: italic; text-align: center;
|
|
6850
7087
|
padding: 24px;
|
|
@@ -7571,20 +7808,9 @@ var css = `
|
|
|
7571
7808
|
padding: 8px; margin: 0 0 8px; overflow-x: auto;
|
|
7572
7809
|
}
|
|
7573
7810
|
.chat-bubble.assistant pre code { background: none; border: none; padding: 0; white-space: pre; }
|
|
7574
|
-
|
|
7575
|
-
|
|
7576
|
-
|
|
7577
|
-
border-radius: 999px; padding: 2px 9px; font-size: 11px; font-weight: 500;
|
|
7578
|
-
background: var(--accent-soft); color: var(--accent);
|
|
7579
|
-
box-shadow: var(--glow-accent-soft);
|
|
7580
|
-
}
|
|
7581
|
-
.tool-pill.done { background: var(--surface-2); color: var(--text-muted); box-shadow: none; }
|
|
7582
|
-
.tool-pill.error { background: rgba(251,146,60,0.14); color: var(--warn); box-shadow: none; }
|
|
7583
|
-
.tool-pill .spin { display: inline-block; width: 9px; height: 9px;
|
|
7584
|
-
border: 1.5px solid currentColor; border-top-color: transparent; border-radius: 50%;
|
|
7585
|
-
animation: pillspin 0.7s linear infinite; }
|
|
7586
|
-
@keyframes pillspin { to { transform: rotate(360deg); } }
|
|
7587
|
-
/* 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
|
|
7588
7814
|
the model is generating (before the first text delta of a turn). */
|
|
7589
7815
|
.chat-typing { display: inline-flex; align-items: center; gap: 4px; padding: 1px 0; }
|
|
7590
7816
|
.chat-typing i {
|
|
@@ -7612,6 +7838,9 @@ var css = `
|
|
|
7612
7838
|
overflow-wrap: break-word; word-break: break-word;
|
|
7613
7839
|
}
|
|
7614
7840
|
.rail-composer textarea:focus { outline: none; border-color: var(--accent); box-shadow: var(--glow-focus); }
|
|
7841
|
+
/* While a voice note is being recorded/transcribed the textarea is read-only
|
|
7842
|
+
(shows a "Listening\u2026" / "Transcribing\u2026" placeholder, not editable). */
|
|
7843
|
+
.rail-composer textarea.recording { opacity: 0.6; cursor: not-allowed; }
|
|
7615
7844
|
.rail-composer .composer-row { display: flex; gap: 8px; align-items: flex-end; }
|
|
7616
7845
|
.rail-composer .composer-send {
|
|
7617
7846
|
flex: 0 0 auto; height: 38px; padding: 0 14px; border: none; border-radius: 8px;
|
|
@@ -7719,6 +7948,13 @@ var appJs = `
|
|
|
7719
7948
|
return !!(t && t[colName] && t[colName].secret);
|
|
7720
7949
|
}
|
|
7721
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
|
+
}
|
|
7722
7958
|
|
|
7723
7959
|
function displayFor(name) {
|
|
7724
7960
|
var override = state.iconOverrides[name];
|
|
@@ -8295,11 +8531,21 @@ var appJs = `
|
|
|
8295
8531
|
} catch (_) { return ''; }
|
|
8296
8532
|
}
|
|
8297
8533
|
|
|
8534
|
+
// Elapsed duration since a start timestamp (ms), for in-progress work like a
|
|
8535
|
+
// running upload \u2014 no "ago" suffix. Mirrors relTime's unit thresholds.
|
|
8536
|
+
function formatElapsed(ms) {
|
|
8537
|
+
var s = Math.max(0, Math.floor(ms / 1000));
|
|
8538
|
+
if (s < 60) return s + 's';
|
|
8539
|
+
if (s < 3600) return Math.floor(s / 60) + 'm ' + (s % 60) + 's';
|
|
8540
|
+
return Math.floor(s / 3600) + 'h ' + Math.floor((s % 3600) / 60) + 'm';
|
|
8541
|
+
}
|
|
8542
|
+
|
|
8298
8543
|
// \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8299
|
-
//
|
|
8544
|
+
// Search \u2014 the top box hands the query to the AI ASSISTANT (which answers
|
|
8545
|
+
// conversationally using its search/read tools), not a plain full-text
|
|
8546
|
+
// match. hideSearchResults/openSearchHit are retained because the activity
|
|
8547
|
+
// feed still uses openSearchHit to jump to a row.
|
|
8300
8548
|
// \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\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
8301
|
-
var searchTimer = null;
|
|
8302
|
-
var searchSeq = 0;
|
|
8303
8549
|
function hideSearchResults() {
|
|
8304
8550
|
var box = document.getElementById('search-results');
|
|
8305
8551
|
if (box) { box.hidden = true; box.innerHTML = ''; }
|
|
@@ -8313,66 +8559,30 @@ var appJs = `
|
|
|
8313
8559
|
var prefix = advancedMode() ? '#/objects/' : '#/fs/';
|
|
8314
8560
|
location.hash = prefix + encodeURIComponent(table) + '/' + encodeURIComponent(id);
|
|
8315
8561
|
}
|
|
8316
|
-
|
|
8317
|
-
|
|
8318
|
-
|
|
8319
|
-
|
|
8320
|
-
|
|
8321
|
-
|
|
8322
|
-
|
|
8323
|
-
|
|
8324
|
-
|
|
8325
|
-
var
|
|
8326
|
-
|
|
8327
|
-
|
|
8328
|
-
html += '<div class="search-group">' +
|
|
8329
|
-
'<div class="search-group-head"><span class="search-group-icon">' + disp.icon +
|
|
8330
|
-
'</span>' + escapeHtml(disp.label) +
|
|
8331
|
-
(g.more ? ' <span class="search-more">' + g.count + '+</span>' : '') + '</div>';
|
|
8332
|
-
g.hits.forEach(function (h) {
|
|
8333
|
-
html += '<button type="button" class="search-hit" data-table="' + escapeHtml(g.table) +
|
|
8334
|
-
'" data-id="' + escapeHtml(h.id) + '">' +
|
|
8335
|
-
'<span class="search-snippet">' + escapeHtml(h.snippet || h.id) + '</span></button>';
|
|
8336
|
-
});
|
|
8337
|
-
html += '</div>';
|
|
8338
|
-
});
|
|
8339
|
-
box.innerHTML = html;
|
|
8340
|
-
box.hidden = false;
|
|
8341
|
-
box.querySelectorAll('.search-hit').forEach(function (btn) {
|
|
8342
|
-
btn.addEventListener('click', function () {
|
|
8343
|
-
openSearchHit(btn.getAttribute('data-table'), btn.getAttribute('data-id'));
|
|
8344
|
-
});
|
|
8345
|
-
});
|
|
8346
|
-
}
|
|
8347
|
-
function runSearch(q) {
|
|
8348
|
-
var seq = ++searchSeq;
|
|
8349
|
-
fetchJson('/api/search?q=' + encodeURIComponent(q) + '&limit=6').then(function (result) {
|
|
8350
|
-
if (seq !== searchSeq) return; // a newer query superseded this one
|
|
8351
|
-
renderSearchResults(result);
|
|
8352
|
-
}).catch(function () { /* transient \u2014 ignore */ });
|
|
8562
|
+
// Route the typed query into the assistant rail as a chat turn. Opens the
|
|
8563
|
+
// rail (a no-op on desktop; opens the mobile drawer) and submits via the
|
|
8564
|
+
// same path as the composer, so the assistant searches + answers.
|
|
8565
|
+
function askAssistant(q) {
|
|
8566
|
+
hideSearchResults();
|
|
8567
|
+
var input = document.getElementById('search-input');
|
|
8568
|
+
if (input) input.value = '';
|
|
8569
|
+
var rail = document.getElementById('assistant-rail');
|
|
8570
|
+
if (rail) rail.classList.add('expanded');
|
|
8571
|
+
var chatInput = document.getElementById('chat-input');
|
|
8572
|
+
if (chatInput) chatInput.focus();
|
|
8573
|
+
sendChat(q);
|
|
8353
8574
|
}
|
|
8354
8575
|
function initSearch() {
|
|
8355
8576
|
var input = document.getElementById('search-input');
|
|
8356
|
-
|
|
8357
|
-
if (!input || !box) return;
|
|
8358
|
-
input.addEventListener('input', function () {
|
|
8359
|
-
var q = input.value.trim();
|
|
8360
|
-
if (searchTimer) clearTimeout(searchTimer);
|
|
8361
|
-
if (q.length < 2) { hideSearchResults(); return; }
|
|
8362
|
-
searchTimer = setTimeout(function () { runSearch(q); }, 180);
|
|
8363
|
-
});
|
|
8577
|
+
if (!input) return;
|
|
8364
8578
|
input.addEventListener('keydown', function (e) {
|
|
8365
|
-
if (e.key === 'Escape') {
|
|
8579
|
+
if (e.key === 'Escape') { input.value = ''; input.blur(); }
|
|
8366
8580
|
else if (e.key === 'Enter') {
|
|
8367
|
-
|
|
8368
|
-
|
|
8581
|
+
e.preventDefault();
|
|
8582
|
+
var q = input.value.trim();
|
|
8583
|
+
if (q) askAssistant(q);
|
|
8369
8584
|
}
|
|
8370
8585
|
});
|
|
8371
|
-
// Dismiss when a click lands outside the search box.
|
|
8372
|
-
document.addEventListener('click', function (e) {
|
|
8373
|
-
var host = document.getElementById('topsearch');
|
|
8374
|
-
if (host && !host.contains(e.target)) hideSearchResults();
|
|
8375
|
-
});
|
|
8376
8586
|
}
|
|
8377
8587
|
|
|
8378
8588
|
/** Reload column meta after a secret-flag change. */
|
|
@@ -8576,9 +8786,16 @@ var appJs = `
|
|
|
8576
8786
|
headers: { 'content-type': 'application/json' },
|
|
8577
8787
|
body: JSON.stringify({ id: id }),
|
|
8578
8788
|
}).then(function () {
|
|
8579
|
-
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.
|
|
8580
8796
|
return reloadEverything();
|
|
8581
8797
|
}).then(function () {
|
|
8798
|
+
menu.hidden = true;
|
|
8582
8799
|
// Conversations + activity both live in the workspace DB. Drop
|
|
8583
8800
|
// the old workspace's thread + activity cards, reconnect the feed
|
|
8584
8801
|
// to THIS workspace, and reload its thread list (+ latest convo).
|
|
@@ -8587,7 +8804,7 @@ var appJs = `
|
|
|
8587
8804
|
startFeed();
|
|
8588
8805
|
refreshThreadList(true);
|
|
8589
8806
|
showToast('Switched workspace', {});
|
|
8590
|
-
}).catch(function (err) { showToast('Switch failed: ' + err.message, {}); });
|
|
8807
|
+
}).catch(function (err) { menu.hidden = true; showToast('Switch failed: ' + err.message, {}); });
|
|
8591
8808
|
});
|
|
8592
8809
|
});
|
|
8593
8810
|
});
|
|
@@ -8953,6 +9170,14 @@ var appJs = `
|
|
|
8953
9170
|
clearUnseen(tableName);
|
|
8954
9171
|
var t = tableByName(tableName);
|
|
8955
9172
|
if (!t) {
|
|
9173
|
+
// Conversation-storage tables (chat_messages/chat_threads) and other
|
|
9174
|
+
// Lattice internals aren't in the Objects list, but are browsable
|
|
9175
|
+
// read-only under "System". If something routed here for one of them,
|
|
9176
|
+
// fall back to the system-table view instead of "Unknown entity".
|
|
9177
|
+
if ((state.systemTables || []).some(function (s) { return s.name === tableName; })) {
|
|
9178
|
+
renderSystemTable(content, tableName);
|
|
9179
|
+
return;
|
|
9180
|
+
}
|
|
8956
9181
|
content.innerHTML = '<div class="placeholder">Unknown entity: ' + escapeHtml(tableName) + '</div>';
|
|
8957
9182
|
return;
|
|
8958
9183
|
}
|
|
@@ -8984,7 +9209,7 @@ var appJs = `
|
|
|
8984
9209
|
} else {
|
|
8985
9210
|
bodyRows = rows.map(function (r) {
|
|
8986
9211
|
var tds = intrinsic.map(function (c) {
|
|
8987
|
-
if (isSecretColumn(tableName, c) && r[c] != null && r[c] !== '') {
|
|
9212
|
+
if ((isSecretColumn(tableName, c) || looksEncrypted(r[c])) && r[c] != null && r[c] !== '') {
|
|
8988
9213
|
return '<td class="muted">' + SECRET_MASK + '</td>';
|
|
8989
9214
|
}
|
|
8990
9215
|
return '<td><div class="cell-clip">' + escapeHtml(truncate(r[c], 120)) + '</div></td>';
|
|
@@ -9269,7 +9494,7 @@ var appJs = `
|
|
|
9269
9494
|
function paint(editing) {
|
|
9270
9495
|
var rows = [];
|
|
9271
9496
|
intrinsic.forEach(function (c) {
|
|
9272
|
-
var secret = isSecretColumn(tableName, c);
|
|
9497
|
+
var secret = isSecretColumn(tableName, c) || looksEncrypted(row[c]);
|
|
9273
9498
|
var dd;
|
|
9274
9499
|
if (editing) {
|
|
9275
9500
|
dd = fieldFor(c, row[c], t);
|
|
@@ -9637,7 +9862,7 @@ var appJs = `
|
|
|
9637
9862
|
function fsValInner(table, row, col) {
|
|
9638
9863
|
var raw = row[col];
|
|
9639
9864
|
if (raw == null || raw === '') return '<span class="fs-empty-val">\u2014</span>';
|
|
9640
|
-
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>';
|
|
9641
9866
|
var s = String(raw);
|
|
9642
9867
|
if (FS_LONGFORM.indexOf(col) >= 0 || s.indexOf('\\n') >= 0) {
|
|
9643
9868
|
return '<div class="md-body">' + mdToHtml(s.slice(0, 40000)) + '</div>';
|
|
@@ -11639,20 +11864,29 @@ var appJs = `
|
|
|
11639
11864
|
'</div>' +
|
|
11640
11865
|
'</div>';
|
|
11641
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
|
+
}
|
|
11642
11878
|
host.innerHTML =
|
|
11643
11879
|
'<div class="dbconfig-panel" style="margin-bottom:18px;padding:14px;border:1px solid var(--border);border-radius:8px;background:var(--surface)">' +
|
|
11644
11880
|
'<h3 style="margin:0 0 10px">Assistant</h3>' +
|
|
11645
11881
|
'<p class="lead" style="margin:0 0 12px;font-size:12px;color:var(--text-muted)">' +
|
|
11646
|
-
'Keys are stored encrypted in the <code>secrets</code> table
|
|
11647
|
-
'saved. Environment variables (<code>ANTHROPIC_API_KEY</code>, <code>OPENAI_API_KEY</code>, ' +
|
|
11648
|
-
'<code>ELEVENLABS_API_KEY</code>) also work.' +
|
|
11882
|
+
'Keys are stored encrypted in the <code>secrets</code> table.' +
|
|
11649
11883
|
'</p>' +
|
|
11650
11884
|
rowHtml('asst-anthropic', 'Claude API token (chat)', !!cfg.hasAnthropicKey, 'sk-ant-\u2026') +
|
|
11651
|
-
|
|
11652
|
-
(
|
|
11653
|
-
|
|
11654
|
-
|
|
11655
|
-
|
|
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
|
+
: '') +
|
|
11656
11890
|
'<div style="margin:6px 0 12px">' +
|
|
11657
11891
|
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">' +
|
|
11658
11892
|
'<strong style="font-size:13px">Inference aggressiveness</strong>' +
|
|
@@ -11669,17 +11903,16 @@ var appJs = `
|
|
|
11669
11903
|
'auto-creates link tables) when you drop in files. Higher extrapolates more.' +
|
|
11670
11904
|
'</p>' +
|
|
11671
11905
|
'</div>' +
|
|
11672
|
-
'<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
|
|
11673
|
-
|
|
11674
|
-
rowHtml('asst-elevenlabs', 'ElevenLabs key', !!cfg.hasElevenlabsKey, 'xi-\u2026') +
|
|
11675
|
-
'<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">' +
|
|
11676
11908
|
'<span style="font-size:12px;color:var(--text-muted)">Use for voice:</span>' +
|
|
11677
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">' +
|
|
11678
|
-
'<option value="auto">
|
|
11679
|
-
'<option value="openai">OpenAI
|
|
11910
|
+
'<option value="auto">Select provider\u2026</option>' +
|
|
11911
|
+
'<option value="openai">OpenAI</option>' +
|
|
11680
11912
|
'<option value="elevenlabs">ElevenLabs</option>' +
|
|
11681
11913
|
'</select>' +
|
|
11682
11914
|
'</div>' +
|
|
11915
|
+
'<div id="asst-voice-key">' + voiceRowHtml(cfg.sttPreference || 'auto') + '</div>' +
|
|
11683
11916
|
'<div id="assistant-msg" style="margin-top:4px;font-size:12px;color:var(--text-muted)"></div>' +
|
|
11684
11917
|
'</div>';
|
|
11685
11918
|
var msg = host.querySelector('#assistant-msg');
|
|
@@ -11709,12 +11942,18 @@ var appJs = `
|
|
|
11709
11942
|
});
|
|
11710
11943
|
}
|
|
11711
11944
|
wire('asst-anthropic', 'anthropic');
|
|
11712
|
-
wire('asst-openai', 'openai');
|
|
11713
|
-
wire('asst-elevenlabs', 'elevenlabs');
|
|
11714
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
|
+
}
|
|
11715
11951
|
if (sttSel) {
|
|
11716
11952
|
sttSel.value = cfg.sttPreference || 'auto';
|
|
11953
|
+
wireVoiceKey(sttSel.value);
|
|
11717
11954
|
sttSel.addEventListener('change', function () {
|
|
11955
|
+
if (voiceKeyHost) voiceKeyHost.innerHTML = voiceRowHtml(sttSel.value);
|
|
11956
|
+
wireVoiceKey(sttSel.value);
|
|
11718
11957
|
msg.textContent = 'Saving\u2026';
|
|
11719
11958
|
fetch('/api/assistant/stt-provider', {
|
|
11720
11959
|
method: 'PUT',
|
|
@@ -12105,9 +12344,10 @@ var appJs = `
|
|
|
12105
12344
|
|
|
12106
12345
|
// State-machine Database panel (v1.13+). Renders a different body
|
|
12107
12346
|
// per info.state: local -> Migrate / Connect-existing wizards;
|
|
12108
|
-
//
|
|
12109
|
-
//
|
|
12110
|
-
//
|
|
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.
|
|
12111
12351
|
function renderDatabasePanel(host) {
|
|
12112
12352
|
fetchJson('/api/dbconfig').then(function (info) {
|
|
12113
12353
|
var badge = renderStateBadge(info);
|
|
@@ -12143,10 +12383,6 @@ var appJs = `
|
|
|
12143
12383
|
label = 'CLOUD \xB7 MEMBER';
|
|
12144
12384
|
color = 'var(--accent)';
|
|
12145
12385
|
break;
|
|
12146
|
-
case 'team-cloud-needs-invite':
|
|
12147
|
-
label = 'CLOUD \xB7 NEEDS INVITE';
|
|
12148
|
-
color = 'var(--warn)';
|
|
12149
|
-
break;
|
|
12150
12386
|
default:
|
|
12151
12387
|
label = String(info.state || 'UNKNOWN').toUpperCase();
|
|
12152
12388
|
}
|
|
@@ -12182,21 +12418,6 @@ var appJs = `
|
|
|
12182
12418
|
'<div id="db-members-host" style="margin-top:12px"><div style="font-size:12px;color:var(--text-muted)">Loading members\u2026</div></div>'
|
|
12183
12419
|
);
|
|
12184
12420
|
}
|
|
12185
|
-
if (info.state === 'team-cloud-needs-invite') {
|
|
12186
|
-
return (
|
|
12187
|
-
renderConnectionSummary(info) +
|
|
12188
|
-
'<p style="margin-top:10px;color:var(--warn);font-size:13px">' +
|
|
12189
|
-
'Not a member of this cloud workspace yet \u2014 paste your invite token to join.' +
|
|
12190
|
-
'</p>' +
|
|
12191
|
-
'<div style="display:grid;grid-template-columns:1fr;gap:8px;margin-top:6px">' +
|
|
12192
|
-
'<div><label class="field-label">Invite token</label>' +
|
|
12193
|
-
'<textarea id="db-rejoin-token" placeholder="latinv_..." style="width:100%;height:54px;font-family:JetBrains Mono,monospace"></textarea></div>' +
|
|
12194
|
-
'</div>' +
|
|
12195
|
-
'<div class="team-actions" style="margin-top:10px">' +
|
|
12196
|
-
'<button class="btn primary" data-act="rejoin-with-token">Join workspace \u2192</button>' +
|
|
12197
|
-
'</div>'
|
|
12198
|
-
);
|
|
12199
|
-
}
|
|
12200
12421
|
return '<p style="color:var(--text-muted)">Unknown database state.</p>';
|
|
12201
12422
|
}
|
|
12202
12423
|
|
|
@@ -12268,32 +12489,6 @@ var appJs = `
|
|
|
12268
12489
|
}).catch(function () { membersHost.innerHTML = '<div style="font-size:12px;color:var(--text-muted)">Members unavailable.</div>'; });
|
|
12269
12490
|
}
|
|
12270
12491
|
|
|
12271
|
-
var rejoinBtn = host.querySelector('[data-act="rejoin-with-token"]');
|
|
12272
|
-
if (rejoinBtn) rejoinBtn.addEventListener('click', function () {
|
|
12273
|
-
var token = (document.getElementById('db-rejoin-token').value || '').trim();
|
|
12274
|
-
if (!token) { setMsg('Invite token required.', false); return; }
|
|
12275
|
-
// Without form re-entry the credentials are already saved; we
|
|
12276
|
-
// call the connect-existing endpoint with just the invite
|
|
12277
|
-
// token. The handler reads credentials from db-credentials.enc
|
|
12278
|
-
// via the active configPath's label.
|
|
12279
|
-
setMsg('Joining workspace\u2026');
|
|
12280
|
-
fetch('/api/dbconfig/connect-existing', {
|
|
12281
|
-
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
12282
|
-
body: JSON.stringify({
|
|
12283
|
-
type: 'postgres',
|
|
12284
|
-
label: info.label,
|
|
12285
|
-
host: info.host, port: info.port, dbname: info.dbname,
|
|
12286
|
-
user: info.user, password: '', // password lives in db-credentials.enc; backend will pull
|
|
12287
|
-
invite_token: token,
|
|
12288
|
-
}),
|
|
12289
|
-
})
|
|
12290
|
-
.then(function (r) { return r.json(); })
|
|
12291
|
-
.then(function (d) {
|
|
12292
|
-
if (d.error) { setMsg('Failed: ' + d.error, false); return; }
|
|
12293
|
-
setMsg('Joined.', true); rerender();
|
|
12294
|
-
})
|
|
12295
|
-
.catch(function (e) { setMsg('Failed: ' + e.message, false); });
|
|
12296
|
-
});
|
|
12297
12492
|
}
|
|
12298
12493
|
|
|
12299
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
|
|
@@ -12669,51 +12864,103 @@ var appJs = `
|
|
|
12669
12864
|
insert: '\u2795', update: '\u270F\uFE0F', delete: '\u{1F5D1}',
|
|
12670
12865
|
link: '\u{1F517}', unlink: '\u26D3', undo: '\u21B6', redo: '\u21B7', schema: '\u{1F6E0}',
|
|
12671
12866
|
};
|
|
12672
|
-
//
|
|
12673
|
-
//
|
|
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.
|
|
12674
12876
|
var GROUPABLE_OPS = { insert: 1, update: 1, delete: 1, link: 1, unlink: 1 };
|
|
12675
|
-
var
|
|
12676
|
-
|
|
12677
|
-
|
|
12678
|
-
|
|
12679
|
-
|
|
12680
|
-
|
|
12681
|
-
|
|
12682
|
-
|
|
12683
|
-
|
|
12684
|
-
|
|
12685
|
-
|
|
12686
|
-
|
|
12687
|
-
|
|
12688
|
-
|
|
12689
|
-
|
|
12690
|
-
|
|
12691
|
-
|
|
12692
|
-
|
|
12693
|
-
|
|
12694
|
-
|
|
12695
|
-
|
|
12696
|
-
|
|
12697
|
-
|
|
12698
|
-
if (
|
|
12699
|
-
|
|
12700
|
-
|
|
12701
|
-
|
|
12702
|
-
|
|
12703
|
-
|
|
12704
|
-
|
|
12705
|
-
|
|
12706
|
-
|
|
12707
|
-
|
|
12708
|
-
|
|
12709
|
-
|
|
12710
|
-
|
|
12711
|
-
|
|
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
|
|
12910
|
+
// activity starts a fresh bubble so unrelated edits aren't merged in.
|
|
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;
|
|
12916
|
+
}
|
|
12917
|
+
return GROUPABLE_OPS[ev.op] ? String(ev.op) + '|' + src : null;
|
|
12918
|
+
}
|
|
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) {
|
|
12712
12959
|
var item = document.createElement('div');
|
|
12713
12960
|
item.className = 'feed-item';
|
|
12714
12961
|
var icon = document.createElement('div');
|
|
12715
12962
|
icon.className = 'feed-icon';
|
|
12716
|
-
icon.textContent =
|
|
12963
|
+
icon.textContent = feedIcon(ev.op);
|
|
12717
12964
|
var body = document.createElement('div');
|
|
12718
12965
|
body.className = 'feed-body';
|
|
12719
12966
|
var summary = document.createElement('div');
|
|
@@ -12729,11 +12976,13 @@ var appJs = `
|
|
|
12729
12976
|
body.appendChild(meta);
|
|
12730
12977
|
var time = document.createElement('div');
|
|
12731
12978
|
time.className = 'feed-time';
|
|
12732
|
-
|
|
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 = '';
|
|
12733
12982
|
item.appendChild(icon);
|
|
12734
12983
|
item.appendChild(body);
|
|
12735
12984
|
item.appendChild(time);
|
|
12736
|
-
// 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
|
|
12737
12986
|
// shortcut to that object. Link/unlink and schema events have no single
|
|
12738
12987
|
// row (rowId is null), so they stay non-clickable.
|
|
12739
12988
|
if (ev.rowId && ev.table) {
|
|
@@ -12741,19 +12990,118 @@ var appJs = `
|
|
|
12741
12990
|
item.setAttribute('role', 'button');
|
|
12742
12991
|
item.setAttribute('tabindex', '0');
|
|
12743
12992
|
item.title = 'Open this ' + String(ev.table);
|
|
12744
|
-
// _rowClickOff is set when the
|
|
12993
|
+
// _rowClickOff is set when the card becomes a group \u2014 clicks no-op then.
|
|
12745
12994
|
var openRow = function () { if (item._rowClickOff) return; openSearchHit(String(ev.table), String(ev.rowId)); };
|
|
12746
12995
|
item.addEventListener('click', openRow);
|
|
12747
12996
|
item.addEventListener('keydown', function (e) {
|
|
12748
12997
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openRow(); }
|
|
12749
12998
|
});
|
|
12750
12999
|
}
|
|
12751
|
-
|
|
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);
|
|
12752
13071
|
feedEl.scrollTop = feedEl.scrollHeight;
|
|
12753
|
-
//
|
|
12754
|
-
|
|
12755
|
-
|
|
12756
|
-
|
|
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;
|
|
13075
|
+
if (groupKey) {
|
|
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)); }
|
|
13104
|
+
}
|
|
12757
13105
|
}
|
|
12758
13106
|
function startFeed() {
|
|
12759
13107
|
if (feedSource) {
|
|
@@ -12766,14 +13114,16 @@ var appJs = `
|
|
|
12766
13114
|
var data;
|
|
12767
13115
|
try { data = JSON.parse(ev.data); } catch (_) { return; /* ignore malformed */ }
|
|
12768
13116
|
try { renderFeedItem(data); } catch (_) { /* render best-effort */ }
|
|
12769
|
-
//
|
|
12770
|
-
//
|
|
12771
|
-
//
|
|
12772
|
-
//
|
|
12773
|
-
//
|
|
12774
|
-
//
|
|
12775
|
-
//
|
|
12776
|
-
|
|
13117
|
+
// Refresh on ANY data mutation, not just schema/new-table events. The
|
|
13118
|
+
// local feed bus delivers every insert/update/delete/link even when
|
|
13119
|
+
// there's no realtime broker (SQLite/local), so this is what makes the
|
|
13120
|
+
// home dashboard counts AND the open entity view live-update without a
|
|
13121
|
+
// manual reload (previously only schema ops or brand-new tables did).
|
|
13122
|
+
// scheduleRealtimeRefresh is debounced (200ms) so a burst from one
|
|
13123
|
+
// ingest still coalesces into a single refetch \u2014 and on Postgres/cloud
|
|
13124
|
+
// it shares that debounce with the realtime 'change' handler (no double
|
|
13125
|
+
// fetch). See Rule 28: /api/entities uses batched counts, not N queries.
|
|
13126
|
+
if (data && (data.table || data.op === 'schema')) {
|
|
12777
13127
|
scheduleRealtimeRefresh();
|
|
12778
13128
|
}
|
|
12779
13129
|
});
|
|
@@ -12835,12 +13185,13 @@ var appJs = `
|
|
|
12835
13185
|
chatHistory = [];
|
|
12836
13186
|
var feedEl = railFeedEl();
|
|
12837
13187
|
if (!feedEl) return;
|
|
12838
|
-
//
|
|
12839
|
-
//
|
|
12840
|
-
//
|
|
12841
|
-
//
|
|
12842
|
-
var
|
|
12843
|
-
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 = {};
|
|
12844
13195
|
// Restore the empty hint only when the rail is now completely empty.
|
|
12845
13196
|
if (!feedEl.firstElementChild) {
|
|
12846
13197
|
feedEl.innerHTML = '<div class="rail-empty" id="rail-empty">No activity yet. Changes you make will appear here.</div>';
|
|
@@ -12853,7 +13204,7 @@ var appJs = `
|
|
|
12853
13204
|
if (!feedEl) return;
|
|
12854
13205
|
var items = feedEl.querySelectorAll('.feed-item');
|
|
12855
13206
|
for (var i = 0; i < items.length; i++) items[i].remove();
|
|
12856
|
-
|
|
13207
|
+
feedGroups = {};
|
|
12857
13208
|
}
|
|
12858
13209
|
function newChat() {
|
|
12859
13210
|
currentThreadId = null;
|
|
@@ -12893,11 +13244,12 @@ var appJs = `
|
|
|
12893
13244
|
msgs.forEach(function (m) {
|
|
12894
13245
|
if (m.role === 'user') { appendUserBubble(m.text); chatHistory.push({ role: 'user', text: m.text }); }
|
|
12895
13246
|
else if (m.role === 'assistant') {
|
|
12896
|
-
// Rich replay: the saved per-turn structure (text +
|
|
12897
|
-
// matching the live stream. Falls back to
|
|
12898
|
-
// messages saved before turns were persisted.
|
|
12899
|
-
if (Array.isArray(m.turns) && m.turns.length > 0) {
|
|
12900
|
-
|
|
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); }
|
|
12901
13253
|
chatHistory.push({ role: 'assistant', text: m.text });
|
|
12902
13254
|
}
|
|
12903
13255
|
});
|
|
@@ -12921,126 +13273,88 @@ var appJs = `
|
|
|
12921
13273
|
railEmptyGone();
|
|
12922
13274
|
var feedEl = railFeedEl();
|
|
12923
13275
|
var msg = document.createElement('div'); msg.className = 'chat-msg assistant';
|
|
12924
|
-
var wrap = document.createElement('div');
|
|
12925
|
-
var tools = document.createElement('div'); tools.className = 'chat-tools';
|
|
12926
13276
|
var b = document.createElement('div'); b.className = 'chat-bubble assistant';
|
|
12927
13277
|
// Show an animated typing indicator until the first text delta arrives.
|
|
12928
13278
|
b.innerHTML = '<span class="chat-typing"><i></i><i></i><i></i></span>';
|
|
12929
13279
|
b.setAttribute('data-typing', '1');
|
|
12930
|
-
|
|
12931
|
-
msg
|
|
12932
|
-
// lastTool anchors the current run of identical tool calls so consecutive
|
|
12933
|
-
// same-name calls coalesce into one counted pill (see addToolPill).
|
|
12934
|
-
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 };
|
|
12935
13282
|
}
|
|
12936
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
|
+
}
|
|
12937
13326
|
function setBubbleText(ctx, text) {
|
|
12938
13327
|
if (!ctx || !ctx.bubble) return; // bubble may have been finalized/removed
|
|
12939
13328
|
ctx.bubble.removeAttribute('data-typing');
|
|
12940
13329
|
// Assistant turns are Markdown; render (input is HTML-escaped inside
|
|
12941
|
-
// mdToHtml first, so this is injection-safe).
|
|
12942
|
-
ctx.bubble.innerHTML =
|
|
13330
|
+
// mdToHtml first, so this is injection-safe) + linkify object references.
|
|
13331
|
+
ctx.bubble.innerHTML = renderAssistantHtml(text);
|
|
13332
|
+
ensureLatticeRefHandler();
|
|
12943
13333
|
}
|
|
12944
13334
|
/**
|
|
12945
|
-
* A turn ended
|
|
12946
|
-
*
|
|
12947
|
-
*
|
|
12948
|
-
* "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.
|
|
12949
13338
|
*/
|
|
12950
13339
|
function finalizeBubble(ctx) {
|
|
12951
13340
|
if (!ctx || !ctx.bubble || !ctx.bubble.getAttribute('data-typing')) return;
|
|
12952
|
-
if (ctx.
|
|
12953
|
-
else if (ctx.msg) ctx.msg.remove();
|
|
12954
|
-
}
|
|
12955
|
-
var TOOL_VERBS = {
|
|
12956
|
-
create_row: ['Creating row', 'Row created', 'Could not create row'],
|
|
12957
|
-
update_row: ['Updating row', 'Row updated', 'Could not update row'],
|
|
12958
|
-
delete_row: ['Deleting row', 'Row deleted', 'Could not delete row'],
|
|
12959
|
-
list_rows: ['Listing rows', 'Listed rows', 'Could not list rows'],
|
|
12960
|
-
get_row: ['Fetching row', 'Fetched row', 'Could not fetch row'],
|
|
12961
|
-
list_entities: ['Listing tables', 'Listed tables', 'Could not list tables']
|
|
12962
|
-
};
|
|
12963
|
-
// Grouped (count > 1) [gerund, past, noun] so a run of identical tool calls
|
|
12964
|
-
// collapses into ONE counted pill \u2014 "Listed 5 rows" \u2014 instead of N identical
|
|
12965
|
-
// "Listed rows" pills. Mirrors the activity feed's groupedSummary() coalescing.
|
|
12966
|
-
var TOOL_GROUP = {
|
|
12967
|
-
create_row: ['Creating', 'Created', 'rows'],
|
|
12968
|
-
update_row: ['Updating', 'Updated', 'rows'],
|
|
12969
|
-
delete_row: ['Deleting', 'Deleted', 'rows'],
|
|
12970
|
-
list_rows: ['Listing', 'Listed', 'rows'],
|
|
12971
|
-
get_row: ['Fetching', 'Fetched', 'rows'],
|
|
12972
|
-
list_entities: ['Listing', 'Listed', 'tables']
|
|
12973
|
-
};
|
|
12974
|
-
function toolLabel(name, state) {
|
|
12975
|
-
var v = TOOL_VERBS[name] || [name, name, name];
|
|
12976
|
-
return state === 'pending' ? v[0] + '\u2026' : (state === 'error' ? v[2] : v[1]);
|
|
12977
|
-
}
|
|
12978
|
-
// Label for a run of "count" identical calls. count <= 1 falls back to the
|
|
12979
|
-
// single-call label so a lone pill reads exactly as before.
|
|
12980
|
-
function toolGroupLabel(name, count, state) {
|
|
12981
|
-
if (count <= 1) return toolLabel(name, state);
|
|
12982
|
-
var g = TOOL_GROUP[name];
|
|
12983
|
-
if (!g) return toolLabel(name, state) + ' \xD7' + count; // unknown tool: stay honest
|
|
12984
|
-
var verb = state === 'pending' ? g[0] : g[1];
|
|
12985
|
-
return verb + ' ' + count + ' ' + g[2] + (state === 'pending' ? '\u2026' : '');
|
|
12986
|
-
}
|
|
12987
|
-
// Paint a (possibly grouped) pill from its live counts: spinner while any call
|
|
12988
|
-
// is still running, then \u2713 (or \u26A0 if any errored) once every call resolves.
|
|
12989
|
-
function paintToolPill(g) {
|
|
12990
|
-
var pending = g.pending > 0;
|
|
12991
|
-
var err = !pending && g.error > 0;
|
|
12992
|
-
if (pending) {
|
|
12993
|
-
g.el.className = 'tool-pill';
|
|
12994
|
-
g.el.innerHTML = '<span class="spin"></span>' + escapeHtml(toolGroupLabel(g.name, g.total, 'pending'));
|
|
12995
|
-
} else {
|
|
12996
|
-
g.el.className = 'tool-pill ' + (err ? 'error' : 'done');
|
|
12997
|
-
g.el.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(g.name, g.total, err ? 'error' : 'done');
|
|
12998
|
-
}
|
|
12999
|
-
}
|
|
13000
|
-
function addToolPill(ctx, id, name) {
|
|
13001
|
-
// Coalesce a run of the same tool within this turn's pill row into one
|
|
13002
|
-
// counted pill (the model emits several list_rows in a single turn).
|
|
13003
|
-
var g = ctx.lastTool;
|
|
13004
|
-
if (g && g.name === name) {
|
|
13005
|
-
g.total += 1; g.pending += 1;
|
|
13006
|
-
} else {
|
|
13007
|
-
var pill = document.createElement('span'); pill.className = 'tool-pill';
|
|
13008
|
-
ctx.tools.appendChild(pill);
|
|
13009
|
-
g = { name: name, el: pill, total: 1, pending: 1, error: 0 };
|
|
13010
|
-
ctx.lastTool = g;
|
|
13011
|
-
}
|
|
13012
|
-
ctx.pills[id] = g; // resolveToolPill maps the tool-use id back to its group
|
|
13013
|
-
paintToolPill(g);
|
|
13341
|
+
if (ctx.msg) ctx.msg.remove();
|
|
13014
13342
|
}
|
|
13015
|
-
|
|
13016
|
-
|
|
13017
|
-
|
|
13018
|
-
|
|
13019
|
-
|
|
13020
|
-
}
|
|
13021
|
-
/**
|
|
13022
|
-
* Append already-resolved pills for a replayed turn, collapsing consecutive
|
|
13023
|
-
* identical tools into one counted pill (matching the live grouping above).
|
|
13024
|
-
*/
|
|
13025
|
-
function renderResolvedPills(ctx, tools) {
|
|
13026
|
-
var i = 0;
|
|
13027
|
-
while (i < tools.length) {
|
|
13028
|
-
var name = tools[i].name, j = i, errors = 0;
|
|
13029
|
-
while (j < tools.length && tools[j].name === name) { if (tools[j].isError) errors += 1; j++; }
|
|
13030
|
-
var count = j - i, err = errors > 0;
|
|
13031
|
-
var pill = document.createElement('span');
|
|
13032
|
-
pill.className = 'tool-pill ' + (err ? 'error' : 'done');
|
|
13033
|
-
pill.textContent = (err ? '\u26A0 ' : '\u2713 ') + toolGroupLabel(name, count, err ? 'error' : 'done');
|
|
13034
|
-
ctx.tools.appendChild(pill);
|
|
13035
|
-
i = j;
|
|
13036
|
-
}
|
|
13037
|
-
}
|
|
13038
|
-
/** Replay one persisted assistant turn: its tool pills + text bubble. */
|
|
13039
|
-
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) {
|
|
13040
13348
|
var ctx = newAssistantBubble();
|
|
13041
|
-
renderResolvedPills(ctx, turn.tools || []);
|
|
13042
13349
|
if (turn.text) setBubbleText(ctx, turn.text);
|
|
13043
|
-
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);
|
|
13044
13358
|
}
|
|
13045
13359
|
function parseSse(buffer, onEvent) {
|
|
13046
13360
|
var sep;
|
|
@@ -13056,6 +13370,11 @@ var appJs = `
|
|
|
13056
13370
|
function sendChat(text) {
|
|
13057
13371
|
if (chatBusy || !text) return;
|
|
13058
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;
|
|
13059
13378
|
appendUserBubble(text);
|
|
13060
13379
|
var historyToSend = chatHistory.slice();
|
|
13061
13380
|
chatHistory.push({ role: 'user', text: text });
|
|
@@ -13082,8 +13401,10 @@ var appJs = `
|
|
|
13082
13401
|
buf = parseSse(buf, function (ev) {
|
|
13083
13402
|
if (ev.type === 'assistant_message_start') { finalizeBubble(actx); actx = newAssistantBubble(); assembled = ''; }
|
|
13084
13403
|
else if (ev.type === 'text_delta' && actx) { assembled += ev.delta; setBubbleText(actx, assembled); railFeedEl().scrollTop = railFeedEl().scrollHeight; }
|
|
13085
|
-
|
|
13086
|
-
|
|
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).
|
|
13087
13408
|
else if (ev.type === 'warn') { finalizeBubble(actx); var wb = newAssistantBubble(); setBubbleText(wb, '\u26A0 ' + ev.message); actx = null; }
|
|
13088
13409
|
else if (ev.type === 'error') { if (!actx) actx = newAssistantBubble(); setBubbleText(actx, (assembled ? assembled + '\\n' : '') + '\u26A0 ' + ev.message); }
|
|
13089
13410
|
});
|
|
@@ -13100,6 +13421,9 @@ var appJs = `
|
|
|
13100
13421
|
var c = newAssistantBubble(); setBubbleText(c, '\u26A0 ' + e.message);
|
|
13101
13422
|
}).finally(function () {
|
|
13102
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;
|
|
13103
13427
|
var sb = document.getElementById('chat-send'); if (sb) sb.disabled = false;
|
|
13104
13428
|
var inp = document.getElementById('chat-input'); if (inp) inp.focus();
|
|
13105
13429
|
});
|
|
@@ -13109,6 +13433,32 @@ var appJs = `
|
|
|
13109
13433
|
var audioChunks = [];
|
|
13110
13434
|
function setMicState(btn, state) {
|
|
13111
13435
|
recState = state;
|
|
13436
|
+
// Mirror the recording lifecycle onto the composer. While recording or
|
|
13437
|
+
// transcribing, the textarea is read-only (it shows a status placeholder,
|
|
13438
|
+
// not editable text) and the Send button is disabled \u2014 you can't send a
|
|
13439
|
+
// half-captured voice note. Returning to idle restores both, then the
|
|
13440
|
+
// transcript is dropped in (see rec.onstop).
|
|
13441
|
+
var inp = document.getElementById('chat-input');
|
|
13442
|
+
var snd = document.getElementById('chat-send');
|
|
13443
|
+
var busy = state === 'recording' || state === 'transcribing';
|
|
13444
|
+
if (inp) {
|
|
13445
|
+
if (busy) {
|
|
13446
|
+
if (inp._restorePlaceholder == null) {
|
|
13447
|
+
inp._restorePlaceholder = inp.getAttribute('placeholder') || '';
|
|
13448
|
+
}
|
|
13449
|
+
inp.setAttribute('readonly', 'readonly');
|
|
13450
|
+
inp.classList.add('recording');
|
|
13451
|
+
inp.setAttribute('placeholder', state === 'recording' ? 'Listening\u2026' : 'Transcribing\u2026');
|
|
13452
|
+
} else {
|
|
13453
|
+
inp.removeAttribute('readonly');
|
|
13454
|
+
inp.classList.remove('recording');
|
|
13455
|
+
if (inp._restorePlaceholder != null) {
|
|
13456
|
+
inp.setAttribute('placeholder', inp._restorePlaceholder);
|
|
13457
|
+
inp._restorePlaceholder = null;
|
|
13458
|
+
}
|
|
13459
|
+
}
|
|
13460
|
+
}
|
|
13461
|
+
if (snd) snd.disabled = busy;
|
|
13112
13462
|
if (!btn) return;
|
|
13113
13463
|
btn.classList.remove('recording', 'transcribing');
|
|
13114
13464
|
if (state === 'recording') { btn.classList.add('recording'); btn.textContent = '\u23F9'; btn.title = 'Stop recording'; btn.disabled = false; }
|
|
@@ -13208,10 +13558,26 @@ var appJs = `
|
|
|
13208
13558
|
item.innerHTML =
|
|
13209
13559
|
'<div class="feed-icon"><span class="feed-spinner"></span></div>' +
|
|
13210
13560
|
'<div class="feed-body"><div class="feed-summary">Analyzing ' + escapeHtml(label) + '\u2026</div></div>' +
|
|
13211
|
-
'<div class="feed-time"
|
|
13212
|
-
|
|
13561
|
+
'<div class="feed-time">0s</div>';
|
|
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);
|
|
13213
13566
|
feedEl.scrollTop = feedEl.scrollHeight;
|
|
13214
|
-
|
|
13567
|
+
// Live elapsed-time counter while the upload + server-side extraction run.
|
|
13568
|
+
// Previously the time element was left empty (rendered as a stuck "0s")
|
|
13569
|
+
// because nothing tracked or updated it. Tick once a second; the cleanup
|
|
13570
|
+
// returned below clears the interval (and self-clears if the node is gone).
|
|
13571
|
+
var started = Date.now();
|
|
13572
|
+
var timeEl = item.querySelector('.feed-time');
|
|
13573
|
+
var tick = setInterval(function () {
|
|
13574
|
+
if (!item.parentNode || !timeEl) { clearInterval(tick); return; }
|
|
13575
|
+
timeEl.textContent = formatElapsed(Date.now() - started);
|
|
13576
|
+
}, 1000);
|
|
13577
|
+
return function () {
|
|
13578
|
+
clearInterval(tick);
|
|
13579
|
+
if (item.parentNode) item.parentNode.removeChild(item);
|
|
13580
|
+
};
|
|
13215
13581
|
}
|
|
13216
13582
|
function uploadFile(file) {
|
|
13217
13583
|
var done = pendingIngestItem(file.name || 'file');
|
|
@@ -13386,7 +13752,7 @@ var guiAppHtml = `<!doctype html>
|
|
|
13386
13752
|
</div>
|
|
13387
13753
|
<div class="topsearch" id="topsearch">
|
|
13388
13754
|
<span class="topsearch-icon" aria-hidden="true">\u{1F50D}</span>
|
|
13389
|
-
<input type="search" id="search-input" placeholder="
|
|
13755
|
+
<input type="search" id="search-input" placeholder="Ask the assistant\u2026" autocomplete="off" spellcheck="false" aria-label="Ask the assistant" />
|
|
13390
13756
|
<div class="search-results" id="search-results" hidden></div>
|
|
13391
13757
|
</div>
|
|
13392
13758
|
<div class="history-controls">
|
|
@@ -15190,203 +15556,6 @@ function saveConfigDoc(configPath, doc) {
|
|
|
15190
15556
|
writeFileSync6(configPath, doc.toString(), "utf8");
|
|
15191
15557
|
}
|
|
15192
15558
|
|
|
15193
|
-
// src/framework/native-entities.ts
|
|
15194
|
-
var NATIVE_ENTITY_DEFS = {
|
|
15195
|
-
secrets: {
|
|
15196
|
-
columns: {
|
|
15197
|
-
id: "TEXT PRIMARY KEY",
|
|
15198
|
-
// NOT NULL needs a DEFAULT so ALTER TABLE ADD COLUMN succeeds when this
|
|
15199
|
-
// native shape is merged onto a pre-existing table (the adopt + team
|
|
15200
|
-
// shared-schema sync paths use ADD COLUMN; SQLite + Postgres both reject
|
|
15201
|
-
// a NOT NULL add without a default). Every insert sets `name` explicitly,
|
|
15202
|
-
// so the default is never observed in practice.
|
|
15203
|
-
name: "TEXT NOT NULL DEFAULT ''",
|
|
15204
|
-
kind: "TEXT",
|
|
15205
|
-
value: "TEXT",
|
|
15206
|
-
description: "TEXT",
|
|
15207
|
-
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15208
|
-
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15209
|
-
deleted_at: "TEXT"
|
|
15210
|
-
},
|
|
15211
|
-
encrypted: { columns: ["value"] },
|
|
15212
|
-
render: () => "",
|
|
15213
|
-
outputFile: ".lattice-native/secrets.md"
|
|
15214
|
-
},
|
|
15215
|
-
files: {
|
|
15216
|
-
columns: {
|
|
15217
|
-
id: "TEXT PRIMARY KEY",
|
|
15218
|
-
// Legacy columns (DEPRECATED v2.0 — retained for back-compat, NOT dropped).
|
|
15219
|
-
// path — superseded by the reference model below (`ref_kind`/`ref_uri`).
|
|
15220
|
-
// New local-file ingestion records a `local_ref` via
|
|
15221
|
-
// `referenceLocalFile()` rather than writing `path`; readers fall
|
|
15222
|
-
// back to `ref_uri`. Do not write `path` in new code.
|
|
15223
|
-
// kind — orphaned: superseded by `mime` (content type) and `ref_kind`
|
|
15224
|
-
// (the blob/local_ref/cloud_ref discriminator). Not read or
|
|
15225
|
-
// written by production code; kept only so existing rows retain
|
|
15226
|
-
// their value.
|
|
15227
|
-
path: "TEXT",
|
|
15228
|
-
kind: "TEXT",
|
|
15229
|
-
// Content-addressed storage. `sha256` is the canonical content
|
|
15230
|
-
// identifier; `blob_path` is the relative path under
|
|
15231
|
-
// `<lattice-root>/data/blobs/` written by attachBlob().
|
|
15232
|
-
original_name: "TEXT",
|
|
15233
|
-
mime: "TEXT",
|
|
15234
|
-
size_bytes: "INTEGER",
|
|
15235
|
-
sha256: "TEXT",
|
|
15236
|
-
blob_path: "TEXT",
|
|
15237
|
-
// Reference mode (v2.0): a row can INDEX data that lives elsewhere
|
|
15238
|
-
// instead of owning a copy. All nullable + additive (back-compat).
|
|
15239
|
-
// ref_kind discriminator: 'blob' | 'local_ref' | 'cloud_ref'
|
|
15240
|
-
// (NULL ⇒ legacy/owned blob)
|
|
15241
|
-
// ref_uri durable pointer: absolute local path or remote URL
|
|
15242
|
-
// ref_provider resolver selector: 'fs' | 'web' | 'gdrive'
|
|
15243
|
-
// source_json provider-specific metadata (etag, availability, …)
|
|
15244
|
-
ref_kind: "TEXT",
|
|
15245
|
-
ref_uri: "TEXT",
|
|
15246
|
-
ref_provider: "TEXT",
|
|
15247
|
-
source_json: "TEXT",
|
|
15248
|
-
extraction_status: "TEXT",
|
|
15249
|
-
extracted_text: "TEXT",
|
|
15250
|
-
description: "TEXT",
|
|
15251
|
-
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15252
|
-
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15253
|
-
deleted_at: "TEXT"
|
|
15254
|
-
},
|
|
15255
|
-
render: () => "",
|
|
15256
|
-
outputFile: ".lattice-native/files.md"
|
|
15257
|
-
},
|
|
15258
|
-
notes: {
|
|
15259
|
-
// A generic knowledge object: a free-form note with a title and body.
|
|
15260
|
-
// Ordinary, user-editable rows; `source_file_id` optionally points back at
|
|
15261
|
-
// an originating `files` row. Retained as native (1.16.3) because the
|
|
15262
|
-
// reference/source-organizer store uses it as the fallback organizer target.
|
|
15263
|
-
columns: {
|
|
15264
|
-
id: "TEXT PRIMARY KEY",
|
|
15265
|
-
title: "TEXT",
|
|
15266
|
-
body: "TEXT",
|
|
15267
|
-
source_file_id: "TEXT",
|
|
15268
|
-
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15269
|
-
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15270
|
-
deleted_at: "TEXT"
|
|
15271
|
-
},
|
|
15272
|
-
render: () => "",
|
|
15273
|
-
outputFile: ".lattice-native/notes.md"
|
|
15274
|
-
},
|
|
15275
|
-
chat_threads: {
|
|
15276
|
-
// An assistant conversation. Native so chat history survives across
|
|
15277
|
-
// sessions and is queryable/renderable like any other Lattice entity.
|
|
15278
|
-
columns: {
|
|
15279
|
-
id: "TEXT PRIMARY KEY",
|
|
15280
|
-
title: "TEXT",
|
|
15281
|
-
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15282
|
-
updated_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15283
|
-
deleted_at: "TEXT"
|
|
15284
|
-
},
|
|
15285
|
-
render: () => "",
|
|
15286
|
-
outputFile: ".lattice-native/chat-threads.md"
|
|
15287
|
-
},
|
|
15288
|
-
chat_messages: {
|
|
15289
|
-
// One turn (or feed entry) within a chat_thread.
|
|
15290
|
-
columns: {
|
|
15291
|
-
id: "TEXT PRIMARY KEY",
|
|
15292
|
-
// Soft reference to chat_threads.id. Kept as a plain column (no FK)
|
|
15293
|
-
// to match the generic, dialect-agnostic native-entity style.
|
|
15294
|
-
thread_id: "TEXT",
|
|
15295
|
-
// user | assistant | tool | feed | system
|
|
15296
|
-
role: "TEXT NOT NULL DEFAULT 'user'",
|
|
15297
|
-
// JSON payload: text, tool_use / tool_result blocks, attachments, or
|
|
15298
|
-
// (for role='feed') the feed-event details.
|
|
15299
|
-
content_json: "TEXT",
|
|
15300
|
-
// ai | gui | cli | ingest — meaningful for role='feed'.
|
|
15301
|
-
source: "TEXT",
|
|
15302
|
-
created_at: "TEXT NOT NULL DEFAULT (datetime('now'))",
|
|
15303
|
-
deleted_at: "TEXT"
|
|
15304
|
-
},
|
|
15305
|
-
render: () => "",
|
|
15306
|
-
outputFile: ".lattice-native/chat-messages.md"
|
|
15307
|
-
}
|
|
15308
|
-
};
|
|
15309
|
-
var NATIVE_ENTITY_NAMES = new Set(Object.keys(NATIVE_ENTITY_DEFS));
|
|
15310
|
-
function isNativeEntity(name) {
|
|
15311
|
-
return NATIVE_ENTITY_NAMES.has(name);
|
|
15312
|
-
}
|
|
15313
|
-
var NATIVE_INTERNAL_NAMES = /* @__PURE__ */ new Set([
|
|
15314
|
-
"chat_threads",
|
|
15315
|
-
"chat_messages"
|
|
15316
|
-
]);
|
|
15317
|
-
function isInternalNativeEntity(name) {
|
|
15318
|
-
return NATIVE_INTERNAL_NAMES.has(name);
|
|
15319
|
-
}
|
|
15320
|
-
function registerNativeEntities(db) {
|
|
15321
|
-
const existing = new Set(db.getRegisteredTableNames());
|
|
15322
|
-
for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
|
|
15323
|
-
if (existing.has(name)) continue;
|
|
15324
|
-
db.define(name, def);
|
|
15325
|
-
}
|
|
15326
|
-
}
|
|
15327
|
-
var NATIVE_REGISTRY_TABLE = "__lattice_native_entities";
|
|
15328
|
-
var NATIVE_REGISTRY_DEF = {
|
|
15329
|
-
columns: {
|
|
15330
|
-
entity: "TEXT PRIMARY KEY",
|
|
15331
|
-
table_name: "TEXT NOT NULL",
|
|
15332
|
-
adopted_at: "TEXT NOT NULL",
|
|
15333
|
-
origin: "TEXT NOT NULL"
|
|
15334
|
-
},
|
|
15335
|
-
primaryKey: "entity",
|
|
15336
|
-
render: () => "",
|
|
15337
|
-
outputFile: ".lattice-native/native-entities.md"
|
|
15338
|
-
};
|
|
15339
|
-
async function adoptNativeEntities(db, options = {}) {
|
|
15340
|
-
const onConflict = options.onConflict ?? "adopt";
|
|
15341
|
-
await db.defineLate(NATIVE_REGISTRY_TABLE, NATIVE_REGISTRY_DEF);
|
|
15342
|
-
const results = [];
|
|
15343
|
-
for (const [name, def] of Object.entries(NATIVE_ENTITY_DEFS)) {
|
|
15344
|
-
const physicalCols = await db.introspectColumns(name);
|
|
15345
|
-
const exists = physicalCols.length > 0;
|
|
15346
|
-
const registered = new Set(db.getRegisteredTableNames());
|
|
15347
|
-
if (!exists) {
|
|
15348
|
-
if (!registered.has(name)) await db.defineLate(name, def);
|
|
15349
|
-
results.push({ entity: name, tableName: name, origin: "created" });
|
|
15350
|
-
await recordNativeBinding(db, name, "created");
|
|
15351
|
-
continue;
|
|
15352
|
-
}
|
|
15353
|
-
if (onConflict === "error") {
|
|
15354
|
-
throw new Error(
|
|
15355
|
-
`adoptNativeEntities: physical table "${name}" already exists; refusing to adopt with onConflict:'error'`
|
|
15356
|
-
);
|
|
15357
|
-
}
|
|
15358
|
-
const nativeCols = new Set(Object.keys(def.columns));
|
|
15359
|
-
const hadForeignShape = physicalCols.some((c) => !nativeCols.has(c));
|
|
15360
|
-
const rowCount = await db.count(name);
|
|
15361
|
-
let origin = hadForeignShape || rowCount > 0 ? "adopted" : "created";
|
|
15362
|
-
if (onConflict === "skip") {
|
|
15363
|
-
origin = "skipped";
|
|
15364
|
-
} else if (!registered.has(name)) {
|
|
15365
|
-
await db.defineLate(name, def);
|
|
15366
|
-
}
|
|
15367
|
-
results.push({ entity: name, tableName: name, origin });
|
|
15368
|
-
await recordNativeBinding(db, name, origin);
|
|
15369
|
-
}
|
|
15370
|
-
return results;
|
|
15371
|
-
}
|
|
15372
|
-
async function listNativeBindings(db) {
|
|
15373
|
-
if (!db.getRegisteredTableNames().includes(NATIVE_REGISTRY_TABLE)) return [];
|
|
15374
|
-
const rows = await db.query(NATIVE_REGISTRY_TABLE);
|
|
15375
|
-
return rows.map((r) => ({
|
|
15376
|
-
entity: String(r.entity),
|
|
15377
|
-
tableName: String(r.table_name),
|
|
15378
|
-
origin: r.origin
|
|
15379
|
-
}));
|
|
15380
|
-
}
|
|
15381
|
-
async function recordNativeBinding(db, entity, origin) {
|
|
15382
|
-
await db.upsert(NATIVE_REGISTRY_TABLE, {
|
|
15383
|
-
entity,
|
|
15384
|
-
table_name: entity,
|
|
15385
|
-
adopted_at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
15386
|
-
origin
|
|
15387
|
-
});
|
|
15388
|
-
}
|
|
15389
|
-
|
|
15390
15559
|
// src/gui/schema-ops.ts
|
|
15391
15560
|
async function listPhysicalUserTables(active) {
|
|
15392
15561
|
const adapter = active.db._adapter;
|
|
@@ -15569,6 +15738,130 @@ async function createUserEntity(active, name, columns, sessionId, opts) {
|
|
|
15569
15738
|
);
|
|
15570
15739
|
return entity;
|
|
15571
15740
|
}
|
|
15741
|
+
async function softDeleteUserEntity(active, name, sessionId, summary) {
|
|
15742
|
+
const doc = loadConfigDoc(active.configPath);
|
|
15743
|
+
const entityDef = doc.toJS().entities?.[name];
|
|
15744
|
+
doc.deleteIn(["entities", name]);
|
|
15745
|
+
saveConfigDoc(active.configPath, doc);
|
|
15746
|
+
active.db.unregisterTable(name);
|
|
15747
|
+
active.validTables.delete(name);
|
|
15748
|
+
active.junctionTables.delete(name);
|
|
15749
|
+
active.softDeletable.delete(name);
|
|
15750
|
+
active.entityContextByTable.delete(name);
|
|
15751
|
+
syncCanonicalContexts(active);
|
|
15752
|
+
await recordSchemaOp(
|
|
15753
|
+
active,
|
|
15754
|
+
"schema.delete_entity",
|
|
15755
|
+
name,
|
|
15756
|
+
{ entity: name, entityDef },
|
|
15757
|
+
null,
|
|
15758
|
+
summary ?? `Deleted table ${name}`,
|
|
15759
|
+
sessionId
|
|
15760
|
+
);
|
|
15761
|
+
}
|
|
15762
|
+
var AI_DELETE_ROW_CAP = 1e3;
|
|
15763
|
+
async function aiDeleteEntity(active, name, resolution, sessionId) {
|
|
15764
|
+
if (!active.validTables.has(name)) return { ok: false, error: `Unknown table: ${name}` };
|
|
15765
|
+
if (isNativeEntity(name)) {
|
|
15766
|
+
return { ok: false, error: `"${name}" is a built-in table and cannot be deleted.` };
|
|
15767
|
+
}
|
|
15768
|
+
const tc = active.teamContext;
|
|
15769
|
+
if (tc && tc.owners.get(name) !== tc.myUserId) {
|
|
15770
|
+
return { ok: false, error: `Only the table's owner can delete "${name}".` };
|
|
15771
|
+
}
|
|
15772
|
+
const inbound = [];
|
|
15773
|
+
for (const t of getGuiEntities(active.configPath, active.outputDir).tables) {
|
|
15774
|
+
if (t.name === name) continue;
|
|
15775
|
+
for (const rel of Object.values(t.relations)) {
|
|
15776
|
+
if (rel.type === "belongsTo" && rel.table === name) {
|
|
15777
|
+
inbound.push(`${t.name}.${rel.foreignKey}`);
|
|
15778
|
+
}
|
|
15779
|
+
}
|
|
15780
|
+
}
|
|
15781
|
+
if (inbound.length > 0) {
|
|
15782
|
+
return {
|
|
15783
|
+
ok: false,
|
|
15784
|
+
error: `Cannot delete "${name}" \u2014 these links point at it: ${inbound.join(", ")}. Remove those links first.`
|
|
15785
|
+
};
|
|
15786
|
+
}
|
|
15787
|
+
const mctx = {
|
|
15788
|
+
db: active.db,
|
|
15789
|
+
feed: active.feed,
|
|
15790
|
+
softDeletable: active.softDeletable,
|
|
15791
|
+
source: "ai"
|
|
15792
|
+
};
|
|
15793
|
+
const softDeletable = active.softDeletable.has(name);
|
|
15794
|
+
const rowCount = softDeletable ? await active.db.count(name, { filters: [{ col: "deleted_at", op: "isNull" }] }) : await active.db.count(name);
|
|
15795
|
+
if (rowCount === 0) {
|
|
15796
|
+
await softDeleteUserEntity(active, name, sessionId);
|
|
15797
|
+
return { ok: true, deleted: name };
|
|
15798
|
+
}
|
|
15799
|
+
if (resolution === void 0) {
|
|
15800
|
+
return {
|
|
15801
|
+
needsResolution: true,
|
|
15802
|
+
rowCount,
|
|
15803
|
+
message: `"${name}" still has ${String(rowCount)} row${rowCount === 1 ? "" : "s"}. Deleting a table with data needs a decision first \u2014 ask the user whether to delete the rows too (reversible), move them into another table, or cancel. Then call delete_entity again with resolution="delete_data" or resolution={"move_to":"<table>"}.`
|
|
15804
|
+
};
|
|
15805
|
+
}
|
|
15806
|
+
if (resolution === "delete_data") {
|
|
15807
|
+
if (!softDeletable) {
|
|
15808
|
+
return {
|
|
15809
|
+
ok: false,
|
|
15810
|
+
error: `"${name}" rows can't be soft-deleted (no deleted_at column) \u2014 clear them manually first.`
|
|
15811
|
+
};
|
|
15812
|
+
}
|
|
15813
|
+
if (rowCount > AI_DELETE_ROW_CAP) {
|
|
15814
|
+
return {
|
|
15815
|
+
ok: false,
|
|
15816
|
+
error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-delete safely (cap ${String(AI_DELETE_ROW_CAP)}). Trim it first.`
|
|
15817
|
+
};
|
|
15818
|
+
}
|
|
15819
|
+
const rows2 = await active.db.query(name, {
|
|
15820
|
+
filters: [{ col: "deleted_at", op: "isNull" }],
|
|
15821
|
+
limit: AI_DELETE_ROW_CAP
|
|
15822
|
+
});
|
|
15823
|
+
let deletedRows = 0;
|
|
15824
|
+
for (const r of rows2) {
|
|
15825
|
+
await deleteRow(mctx, name, String(r.id), false);
|
|
15826
|
+
deletedRows++;
|
|
15827
|
+
}
|
|
15828
|
+
await softDeleteUserEntity(active, name, sessionId);
|
|
15829
|
+
return { ok: true, deleted: name, deletedRows };
|
|
15830
|
+
}
|
|
15831
|
+
const target = resolution.move_to;
|
|
15832
|
+
if (!active.validTables.has(target)) {
|
|
15833
|
+
return { ok: false, error: `move_to target "${target}" is not a known table.` };
|
|
15834
|
+
}
|
|
15835
|
+
if (target === name) return { ok: false, error: "move_to target must be a different table." };
|
|
15836
|
+
if (active.junctionTables.has(target) || isNativeEntity(target)) {
|
|
15837
|
+
return { ok: false, error: `Cannot move rows into "${target}".` };
|
|
15838
|
+
}
|
|
15839
|
+
if (rowCount > AI_DELETE_ROW_CAP) {
|
|
15840
|
+
return {
|
|
15841
|
+
ok: false,
|
|
15842
|
+
error: `"${name}" has ${String(rowCount)} rows \u2014 too many to auto-move (cap ${String(AI_DELETE_ROW_CAP)}).`
|
|
15843
|
+
};
|
|
15844
|
+
}
|
|
15845
|
+
const targetCols = active.db.getRegisteredColumns(target);
|
|
15846
|
+
if (!targetCols) return { ok: false, error: `Could not read the columns of "${target}".` };
|
|
15847
|
+
const rows = await active.db.query(
|
|
15848
|
+
name,
|
|
15849
|
+
softDeletable ? { filters: [{ col: "deleted_at", op: "isNull" }], limit: AI_DELETE_ROW_CAP } : { limit: AI_DELETE_ROW_CAP }
|
|
15850
|
+
);
|
|
15851
|
+
const SKIP = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
|
|
15852
|
+
let movedRows = 0;
|
|
15853
|
+
for (const r of rows) {
|
|
15854
|
+
const mapped = {};
|
|
15855
|
+
for (const [k, v] of Object.entries(r)) {
|
|
15856
|
+
if (!SKIP.has(k) && k in targetCols) mapped[k] = v;
|
|
15857
|
+
}
|
|
15858
|
+
await createRow(mctx, target, mapped);
|
|
15859
|
+
if (softDeletable) await deleteRow(mctx, name, String(r.id), false);
|
|
15860
|
+
movedRows++;
|
|
15861
|
+
}
|
|
15862
|
+
await softDeleteUserEntity(active, name, sessionId);
|
|
15863
|
+
return { ok: true, deleted: name, movedRows };
|
|
15864
|
+
}
|
|
15572
15865
|
|
|
15573
15866
|
// src/teams/server/routes.ts
|
|
15574
15867
|
var UNAUTHENTICATED_TEAM_PATHS = /* @__PURE__ */ new Set([
|
|
@@ -18078,6 +18371,9 @@ async function dispatchUserConfigRoute(req, res, ctx) {
|
|
|
18078
18371
|
const body = await readJson(req);
|
|
18079
18372
|
const current = readPreferences();
|
|
18080
18373
|
const next = {
|
|
18374
|
+
// Preserve keys this endpoint doesn't manage (voice_provider,
|
|
18375
|
+
// aggressiveness — set via the assistant config routes).
|
|
18376
|
+
...current,
|
|
18081
18377
|
show_system_tables: typeof body.show_system_tables === "boolean" ? body.show_system_tables : current.show_system_tables,
|
|
18082
18378
|
analytics: typeof body.analytics === "boolean" ? body.analytics : current.analytics
|
|
18083
18379
|
};
|
|
@@ -18239,19 +18535,8 @@ async function getCreatorEmail(db) {
|
|
|
18239
18535
|
return null;
|
|
18240
18536
|
}
|
|
18241
18537
|
}
|
|
18242
|
-
function computeState(type,
|
|
18538
|
+
function computeState(type, creatorEmail) {
|
|
18243
18539
|
if (type === "sqlite") return "local";
|
|
18244
|
-
if (!teamEnabled) return "team-cloud-needs-invite";
|
|
18245
|
-
if (!label) {
|
|
18246
|
-
return "team-cloud-needs-invite";
|
|
18247
|
-
}
|
|
18248
|
-
let token = null;
|
|
18249
|
-
try {
|
|
18250
|
-
token = readToken(label);
|
|
18251
|
-
} catch {
|
|
18252
|
-
token = null;
|
|
18253
|
-
}
|
|
18254
|
-
if (!token) return "team-cloud-needs-invite";
|
|
18255
18540
|
const identity = readIdentity();
|
|
18256
18541
|
if (creatorEmail !== null && identity.email.length > 0 && creatorEmail.toLowerCase() === identity.email.toLowerCase()) {
|
|
18257
18542
|
return "team-cloud-creator";
|
|
@@ -18259,8 +18544,9 @@ function computeState(type, teamEnabled, label, creatorEmail) {
|
|
|
18259
18544
|
return "team-cloud-member";
|
|
18260
18545
|
}
|
|
18261
18546
|
function applyTeamMembershipState(info, membership) {
|
|
18262
|
-
if (
|
|
18263
|
-
|
|
18547
|
+
if (info.type !== "postgres") return info.state;
|
|
18548
|
+
if (membership) return membership.isCreator ? "team-cloud-creator" : "team-cloud-member";
|
|
18549
|
+
return info.state;
|
|
18264
18550
|
}
|
|
18265
18551
|
async function describeCurrent(configPath, db) {
|
|
18266
18552
|
const rawYaml = readFileSync14(configPath, "utf8");
|
|
@@ -18282,7 +18568,7 @@ async function describeCurrent(configPath, db) {
|
|
|
18282
18568
|
if (labelMatch) {
|
|
18283
18569
|
const label = labelMatch[1] ?? "";
|
|
18284
18570
|
const url = getDbCredential(label);
|
|
18285
|
-
const state = computeState("postgres",
|
|
18571
|
+
const state = computeState("postgres", creatorEmail);
|
|
18286
18572
|
if (url) {
|
|
18287
18573
|
const parsed = parsePostgresUrl(url);
|
|
18288
18574
|
if (parsed) {
|
|
@@ -18309,7 +18595,7 @@ async function describeCurrent(configPath, db) {
|
|
|
18309
18595
|
}
|
|
18310
18596
|
if (/^postgres(ql)?:\/\//i.test(dbLine)) {
|
|
18311
18597
|
const parsed = parsePostgresUrl(dbLine);
|
|
18312
|
-
const state = computeState("postgres",
|
|
18598
|
+
const state = computeState("postgres", creatorEmail);
|
|
18313
18599
|
return parsed ? {
|
|
18314
18600
|
type: "postgres",
|
|
18315
18601
|
state,
|
|
@@ -18932,22 +19218,6 @@ function parseCookies(req) {
|
|
|
18932
19218
|
}
|
|
18933
19219
|
return out;
|
|
18934
19220
|
}
|
|
18935
|
-
async function storeSecret(db, kind, name, value) {
|
|
18936
|
-
const [first, ...extras] = await liveSecretsOfKind(db, kind);
|
|
18937
|
-
if (first) {
|
|
18938
|
-
await db.update("secrets", first.id, { value, name });
|
|
18939
|
-
for (const extra of extras)
|
|
18940
|
-
await db.update("secrets", extra.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
18941
|
-
} else {
|
|
18942
|
-
await db.insert("secrets", {
|
|
18943
|
-
id: crypto.randomUUID(),
|
|
18944
|
-
name,
|
|
18945
|
-
kind,
|
|
18946
|
-
value,
|
|
18947
|
-
description: `${name} (assistant).`
|
|
18948
|
-
});
|
|
18949
|
-
}
|
|
18950
|
-
}
|
|
18951
19221
|
async function liveSecretsOfKind(db, kind) {
|
|
18952
19222
|
const rows = await db.query("secrets", {
|
|
18953
19223
|
filters: [{ col: "kind", op: "eq", val: kind }]
|
|
@@ -18975,19 +19245,29 @@ async function readMachineCredential(db, kind) {
|
|
|
18975
19245
|
var STT_PROVIDER_KIND = "stt_provider";
|
|
18976
19246
|
var AGGRESSIVENESS_KIND = "assistant_aggressiveness";
|
|
18977
19247
|
var DEFAULT_AGGRESSIVENESS = 0.5;
|
|
18978
|
-
|
|
18979
|
-
const
|
|
18980
|
-
const n = raw === null ? NaN : Number(raw);
|
|
19248
|
+
function getAggressiveness() {
|
|
19249
|
+
const n = readPreferences().aggressiveness;
|
|
18981
19250
|
if (!Number.isFinite(n)) return DEFAULT_AGGRESSIVENESS;
|
|
18982
19251
|
return Math.min(1, Math.max(0, n));
|
|
18983
19252
|
}
|
|
19253
|
+
async function retireLegacyPreferenceSecrets(db) {
|
|
19254
|
+
for (const kind of [STT_PROVIDER_KIND, AGGRESSIVENESS_KIND]) {
|
|
19255
|
+
try {
|
|
19256
|
+
for (const row of await liveSecretsOfKind(db, kind)) {
|
|
19257
|
+
await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
19258
|
+
}
|
|
19259
|
+
} catch (e) {
|
|
19260
|
+
console.warn(`[assistant] could not retire legacy ${kind} secret:`, e.message);
|
|
19261
|
+
}
|
|
19262
|
+
}
|
|
19263
|
+
}
|
|
18984
19264
|
function aggressivenessToTemperature(aggressiveness) {
|
|
18985
19265
|
return Math.min(1, Math.max(0, aggressiveness));
|
|
18986
19266
|
}
|
|
18987
19267
|
async function getVoiceCredential(db) {
|
|
18988
19268
|
const openai = await readMachineCredential(db, CREDENTIALS.openai.kind) ?? process.env.OPENAI_API_KEY ?? null;
|
|
18989
19269
|
const eleven = await readMachineCredential(db, CREDENTIALS.elevenlabs.kind) ?? process.env.ELEVENLABS_API_KEY ?? null;
|
|
18990
|
-
const pref =
|
|
19270
|
+
const pref = readPreferences().voice_provider;
|
|
18991
19271
|
if (pref === "elevenlabs" && eleven) return { provider: "elevenlabs", apiKey: eleven };
|
|
18992
19272
|
if (pref === "openai" && openai) return { provider: "openai", apiKey: openai };
|
|
18993
19273
|
if (openai) return { provider: "openai", apiKey: openai };
|
|
@@ -19039,8 +19319,8 @@ async function dispatchAssistantRoute(req, res, ctx) {
|
|
|
19039
19319
|
hasClaudeAuth: await hasClaudeAuth(db),
|
|
19040
19320
|
hasVoiceKey: voice !== null,
|
|
19041
19321
|
sttProvider: voice?.provider ?? null,
|
|
19042
|
-
sttPreference:
|
|
19043
|
-
aggressiveness:
|
|
19322
|
+
sttPreference: readPreferences().voice_provider,
|
|
19323
|
+
aggressiveness: getAggressiveness(),
|
|
19044
19324
|
oauthEnabled: oauthConfigured()
|
|
19045
19325
|
});
|
|
19046
19326
|
return true;
|
|
@@ -19058,7 +19338,7 @@ async function dispatchAssistantRoute(req, res, ctx) {
|
|
|
19058
19338
|
sendJson3(res, { error: "value must be a number in [0, 1]" }, 400);
|
|
19059
19339
|
return true;
|
|
19060
19340
|
}
|
|
19061
|
-
|
|
19341
|
+
writePreferences({ ...readPreferences(), aggressiveness: value });
|
|
19062
19342
|
sendJson3(res, { ok: true, value });
|
|
19063
19343
|
return true;
|
|
19064
19344
|
}
|
|
@@ -19071,17 +19351,11 @@ async function dispatchAssistantRoute(req, res, ctx) {
|
|
|
19071
19351
|
return true;
|
|
19072
19352
|
}
|
|
19073
19353
|
const provider = typeof body.provider === "string" ? body.provider : "auto";
|
|
19074
|
-
if (
|
|
19354
|
+
if (provider !== "auto" && provider !== "openai" && provider !== "elevenlabs") {
|
|
19075
19355
|
sendJson3(res, { error: `unknown provider: ${provider}` }, 400);
|
|
19076
19356
|
return true;
|
|
19077
19357
|
}
|
|
19078
|
-
|
|
19079
|
-
for (const row of await liveSecretsOfKind(db, STT_PROVIDER_KIND)) {
|
|
19080
|
-
await db.update("secrets", row.id, { deleted_at: (/* @__PURE__ */ new Date()).toISOString() });
|
|
19081
|
-
}
|
|
19082
|
-
} else {
|
|
19083
|
-
await storeSecret(db, STT_PROVIDER_KIND, "Voice provider preference", provider);
|
|
19084
|
-
}
|
|
19358
|
+
writePreferences({ ...readPreferences(), voice_provider: provider });
|
|
19085
19359
|
sendJson3(res, { ok: true });
|
|
19086
19360
|
return true;
|
|
19087
19361
|
}
|
|
@@ -19264,6 +19538,24 @@ var REGISTRY = [
|
|
|
19264
19538
|
category: "read",
|
|
19265
19539
|
args: obj({ table: str("Table name."), id: str("Primary key of the row.") }, ["table", "id"])
|
|
19266
19540
|
},
|
|
19541
|
+
{
|
|
19542
|
+
name: "search",
|
|
19543
|
+
description: "Full-text search across the user tables for a query string. Returns matching rows grouped by table (id + snippet). Use this to find records by their content when you do not know the table or id up front.",
|
|
19544
|
+
mutates: false,
|
|
19545
|
+
category: "read",
|
|
19546
|
+
args: obj(
|
|
19547
|
+
{
|
|
19548
|
+
query: str("Text to search for."),
|
|
19549
|
+
tables: {
|
|
19550
|
+
type: "array",
|
|
19551
|
+
description: "Restrict to these tables (optional; defaults to all searchable tables).",
|
|
19552
|
+
items: { type: "string" }
|
|
19553
|
+
},
|
|
19554
|
+
limit: { type: "integer", description: "Max hits per table (optional, default 8)." }
|
|
19555
|
+
},
|
|
19556
|
+
["query"]
|
|
19557
|
+
)
|
|
19558
|
+
},
|
|
19267
19559
|
{
|
|
19268
19560
|
name: "get_history",
|
|
19269
19561
|
description: "Fetch recent audit-log entries, optionally filtered to one table.",
|
|
@@ -19385,6 +19677,26 @@ var REGISTRY = [
|
|
|
19385
19677
|
["table_a", "table_b"]
|
|
19386
19678
|
)
|
|
19387
19679
|
},
|
|
19680
|
+
{
|
|
19681
|
+
name: "delete_entity",
|
|
19682
|
+
description: "Soft-delete a user table (reversible \u2014 the rows are kept and it can be restored from history). Guarded: an EMPTY table is removed immediately; a NON-EMPTY table is NOT deleted until you say what to do with its data \u2014 the tool returns the row count and you must ask the user, then call again with resolution='delete_data' (soft-delete the rows too) or move_to=<table> (move the rows into another table first). Never deletes built-in tables.",
|
|
19683
|
+
mutates: true,
|
|
19684
|
+
category: "schema",
|
|
19685
|
+
args: obj(
|
|
19686
|
+
{
|
|
19687
|
+
name: str("Table to delete."),
|
|
19688
|
+
resolution: {
|
|
19689
|
+
type: "string",
|
|
19690
|
+
enum: ["delete_data"],
|
|
19691
|
+
description: 'For a NON-empty table: "delete_data" soft-deletes its rows too (reversible). Omit to be told the row count and asked first.'
|
|
19692
|
+
},
|
|
19693
|
+
move_to: str(
|
|
19694
|
+
"For a NON-empty table: move its rows into this existing table, then delete the emptied table."
|
|
19695
|
+
)
|
|
19696
|
+
},
|
|
19697
|
+
["name"]
|
|
19698
|
+
)
|
|
19699
|
+
},
|
|
19388
19700
|
{
|
|
19389
19701
|
name: "rename_entity",
|
|
19390
19702
|
description: "Rename an existing entity (table).",
|
|
@@ -19501,6 +19813,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
|
|
|
19501
19813
|
"list_entities",
|
|
19502
19814
|
"list_rows",
|
|
19503
19815
|
"get_row",
|
|
19816
|
+
"search",
|
|
19504
19817
|
"get_history",
|
|
19505
19818
|
"create_row",
|
|
19506
19819
|
"update_row",
|
|
@@ -19509,6 +19822,7 @@ var DISPATCHABLE = /* @__PURE__ */ new Set([
|
|
|
19509
19822
|
"unlink",
|
|
19510
19823
|
"create_entity",
|
|
19511
19824
|
"create_relationship",
|
|
19825
|
+
"delete_entity",
|
|
19512
19826
|
"undo",
|
|
19513
19827
|
"redo",
|
|
19514
19828
|
"revert"
|
|
@@ -19590,6 +19904,20 @@ async function executeFunction(ctx, name, args) {
|
|
|
19590
19904
|
if (row === null) return { ok: false, error: "Row not found" };
|
|
19591
19905
|
return { ok: true, result: redactRow(row, await secretColumnsFor(ctx.db, table)) };
|
|
19592
19906
|
}
|
|
19907
|
+
case "search": {
|
|
19908
|
+
const query = requireString3(args.query, "query");
|
|
19909
|
+
let tables = [...ctx.validTables];
|
|
19910
|
+
if (Array.isArray(args.tables)) {
|
|
19911
|
+
const want = new Set(args.tables.filter((t) => typeof t === "string"));
|
|
19912
|
+
tables = tables.filter((t) => want.has(t));
|
|
19913
|
+
}
|
|
19914
|
+
const limit = typeof args.limit === "number" ? args.limit : 8;
|
|
19915
|
+
const result = await fullTextSearch(ctx.db.adapter, tables, {
|
|
19916
|
+
query,
|
|
19917
|
+
limitPerTable: limit
|
|
19918
|
+
});
|
|
19919
|
+
return { ok: true, result };
|
|
19920
|
+
}
|
|
19593
19921
|
case "create_row": {
|
|
19594
19922
|
const table = requireTable(args.table, ctx.validTables);
|
|
19595
19923
|
if (!args.values || typeof args.values !== "object") {
|
|
@@ -19663,6 +19991,23 @@ async function executeFunction(ctx, name, args) {
|
|
|
19663
19991
|
}
|
|
19664
19992
|
};
|
|
19665
19993
|
}
|
|
19994
|
+
case "delete_entity": {
|
|
19995
|
+
if (!ctx.deleteEntity) {
|
|
19996
|
+
return { ok: false, error: "Deleting tables is not available in this context" };
|
|
19997
|
+
}
|
|
19998
|
+
const target = requireString3(args.name, "name");
|
|
19999
|
+
let resolution;
|
|
20000
|
+
if (args.resolution === "delete_data") resolution = "delete_data";
|
|
20001
|
+
else if (typeof args.move_to === "string" && args.move_to) {
|
|
20002
|
+
resolution = { move_to: args.move_to };
|
|
20003
|
+
}
|
|
20004
|
+
const outcome = await ctx.deleteEntity(target, resolution);
|
|
20005
|
+
if ("needsResolution" in outcome) return { ok: true, result: outcome };
|
|
20006
|
+
if (!outcome.ok) return { ok: false, error: outcome.error };
|
|
20007
|
+
ctx.validTables.delete(target);
|
|
20008
|
+
ctx.junctionTables.delete(target);
|
|
20009
|
+
return { ok: true, result: outcome };
|
|
20010
|
+
}
|
|
19666
20011
|
case "get_history": {
|
|
19667
20012
|
const limit = typeof args.limit === "number" ? args.limit : 50;
|
|
19668
20013
|
const rows = await ctx.db.query("_lattice_gui_audit", { limit });
|
|
@@ -19732,6 +20077,7 @@ var BASE_SYSTEM_PROMPT = [
|
|
|
19732
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`.",
|
|
19733
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.",
|
|
19734
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.',
|
|
19735
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.",
|
|
19736
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.',
|
|
19737
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.",
|
|
@@ -19878,9 +20224,193 @@ function createAnthropicClient(auth) {
|
|
|
19878
20224
|
toolUses.push({ id: tu.id, name: tu.name, input: tu.input });
|
|
19879
20225
|
}
|
|
19880
20226
|
}
|
|
19881
|
-
return { stopReason: final.stop_reason ?? "end_turn", text, toolUses };
|
|
19882
|
-
}
|
|
19883
|
-
};
|
|
20227
|
+
return { stopReason: final.stop_reason ?? "end_turn", text, toolUses };
|
|
20228
|
+
}
|
|
20229
|
+
};
|
|
20230
|
+
}
|
|
20231
|
+
|
|
20232
|
+
// src/ai/llm-client.ts
|
|
20233
|
+
import { createRequire as createRequire4 } from "module";
|
|
20234
|
+
var DEFAULT_MODEL2 = "claude-haiku-4-5";
|
|
20235
|
+
|
|
20236
|
+
// src/ai/summarize.ts
|
|
20237
|
+
function parseMatches(raw, catalog) {
|
|
20238
|
+
const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
|
|
20239
|
+
const body = fence ? fence[1] : raw;
|
|
20240
|
+
let parsed;
|
|
20241
|
+
try {
|
|
20242
|
+
parsed = JSON.parse((body ?? "").trim());
|
|
20243
|
+
} catch {
|
|
20244
|
+
return [];
|
|
20245
|
+
}
|
|
20246
|
+
if (!Array.isArray(parsed)) return [];
|
|
20247
|
+
const valid = new Map(catalog.map((e) => [e.table, new Set(e.records.map((r) => r.id))]));
|
|
20248
|
+
const out = [];
|
|
20249
|
+
for (const item of parsed) {
|
|
20250
|
+
if (!item || typeof item !== "object") continue;
|
|
20251
|
+
const table = item.table;
|
|
20252
|
+
const id = item.id;
|
|
20253
|
+
if (typeof table === "string" && typeof id === "string" && valid.get(table)?.has(id)) {
|
|
20254
|
+
out.push({ table, id });
|
|
20255
|
+
}
|
|
20256
|
+
}
|
|
20257
|
+
return out;
|
|
20258
|
+
}
|
|
20259
|
+
var ID_RE = /^[a-z][a-z0-9_]*$/;
|
|
20260
|
+
var RESERVED_COLS = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
|
|
20261
|
+
function parseObjects(raw) {
|
|
20262
|
+
const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
|
|
20263
|
+
let parsed;
|
|
20264
|
+
try {
|
|
20265
|
+
parsed = JSON.parse((fence ? fence[1] : raw)?.trim() ?? "");
|
|
20266
|
+
} catch {
|
|
20267
|
+
return [];
|
|
20268
|
+
}
|
|
20269
|
+
if (!Array.isArray(parsed)) return [];
|
|
20270
|
+
const out = [];
|
|
20271
|
+
for (const item of parsed) {
|
|
20272
|
+
if (!item || typeof item !== "object") continue;
|
|
20273
|
+
const o = item;
|
|
20274
|
+
const entity = typeof o.entity === "string" ? o.entity.trim().toLowerCase() : "";
|
|
20275
|
+
const label = typeof o.label === "string" ? o.label.trim() : "";
|
|
20276
|
+
if (!ID_RE.test(entity) || !label) continue;
|
|
20277
|
+
let valuesRaw = {};
|
|
20278
|
+
if (Array.isArray(o.values) && Array.isArray(o.columns)) {
|
|
20279
|
+
o.columns.forEach((c, i) => {
|
|
20280
|
+
valuesRaw[String(c)] = o.values[i];
|
|
20281
|
+
});
|
|
20282
|
+
} else if (o.values && typeof o.values === "object") {
|
|
20283
|
+
valuesRaw = o.values;
|
|
20284
|
+
}
|
|
20285
|
+
const values = {};
|
|
20286
|
+
for (const [k, v] of Object.entries(valuesRaw)) {
|
|
20287
|
+
const col = k.trim().toLowerCase();
|
|
20288
|
+
if (ID_RE.test(col) && !RESERVED_COLS.has(col) && (typeof v === "string" || typeof v === "number")) {
|
|
20289
|
+
values[col] = String(v).slice(0, 2e3);
|
|
20290
|
+
}
|
|
20291
|
+
}
|
|
20292
|
+
if (Object.keys(values).length === 0) continue;
|
|
20293
|
+
const cols = Array.isArray(o.columns) ? o.columns.map((c) => String(c).trim().toLowerCase()).filter((c) => ID_RE.test(c) && !RESERVED_COLS.has(c)) : [];
|
|
20294
|
+
const columns = Array.from(/* @__PURE__ */ new Set([...cols, ...Object.keys(values)])).slice(0, 8);
|
|
20295
|
+
out.push({ entity, isNew: o.isNew === true, columns, values, label });
|
|
20296
|
+
if (out.length >= 3) break;
|
|
20297
|
+
}
|
|
20298
|
+
return out;
|
|
20299
|
+
}
|
|
20300
|
+
|
|
20301
|
+
// src/gui/ai/summarize.ts
|
|
20302
|
+
var SUMMARY_SYSTEM = 'You write a one or two sentence factual description of a document for a knowledge base, focused on what it is and what it contains. No preamble, no "This document". Plain text only.';
|
|
20303
|
+
async function summarizeText(client, text, name, temperature) {
|
|
20304
|
+
const turn = await client.runTurn({
|
|
20305
|
+
model: DEFAULT_MODEL,
|
|
20306
|
+
system: SUMMARY_SYSTEM,
|
|
20307
|
+
messages: [
|
|
20308
|
+
{
|
|
20309
|
+
role: "user",
|
|
20310
|
+
content: `File name: ${name}
|
|
20311
|
+
|
|
20312
|
+
Content:
|
|
20313
|
+
${text.slice(0, 12e3)}
|
|
20314
|
+
|
|
20315
|
+
Describe it in 1-2 sentences.`
|
|
20316
|
+
}
|
|
20317
|
+
],
|
|
20318
|
+
tools: [],
|
|
20319
|
+
...temperature !== void 0 ? { temperature } : {},
|
|
20320
|
+
onText: () => void 0
|
|
20321
|
+
});
|
|
20322
|
+
return turn.text.trim();
|
|
20323
|
+
}
|
|
20324
|
+
var TITLE_SYSTEM = 'You write a short, specific title (3-5 words, Title Case) for a chat conversation based on its opening exchange \u2014 capture the concrete topic, e.g. "Adding New Notes About Cheese" or "Q3 Invoice Cleanup". No quotes, no trailing punctuation, no preamble. Plain text only.';
|
|
20325
|
+
async function generateThreadTitle(client, userMessage, assistantReply) {
|
|
20326
|
+
const turn = await client.runTurn({
|
|
20327
|
+
model: DEFAULT_MODEL,
|
|
20328
|
+
system: TITLE_SYSTEM,
|
|
20329
|
+
messages: [
|
|
20330
|
+
{
|
|
20331
|
+
role: "user",
|
|
20332
|
+
content: `First user message:
|
|
20333
|
+
${userMessage.slice(0, 2e3)}
|
|
20334
|
+
|
|
20335
|
+
Assistant reply:
|
|
20336
|
+
${assistantReply.slice(0, 2e3)}
|
|
20337
|
+
|
|
20338
|
+
Title (3-5 words):`
|
|
20339
|
+
}
|
|
20340
|
+
],
|
|
20341
|
+
tools: [],
|
|
20342
|
+
onText: () => void 0
|
|
20343
|
+
});
|
|
20344
|
+
return turn.text.trim().replace(/^["'`]+|["'`]+$/g, "").replace(/[.\s]+$/, "").slice(0, 60);
|
|
20345
|
+
}
|
|
20346
|
+
var CLASSIFY_SYSTEM = 'You decide which existing records a newly added document relates to. You are given a catalog of record types (with descriptions) and their records. Return ONLY a JSON array of {"table","id"} objects for records the document clearly relates to \u2014 an empty array if none. Output the JSON in a ```json fenced block and nothing else.';
|
|
20347
|
+
function buildCatalogBlock(catalog) {
|
|
20348
|
+
return catalog.map((e) => {
|
|
20349
|
+
const head = `## ${e.table}${e.description ? ` \u2014 ${e.description}` : ""}`;
|
|
20350
|
+
const rows = e.records.map((r) => `- id=${r.id} | ${r.label}`).join("\n");
|
|
20351
|
+
return `${head}
|
|
20352
|
+
${rows || "- (no records)"}`;
|
|
20353
|
+
}).join("\n\n");
|
|
20354
|
+
}
|
|
20355
|
+
async function classifyLinks(client, text, name, catalog, temperature) {
|
|
20356
|
+
if (catalog.length === 0 || text.trim().length === 0) return [];
|
|
20357
|
+
let captured = "";
|
|
20358
|
+
const liberal = temperature !== void 0 && temperature >= 0.66 ? " Include records that are strongly implied even without an exact name match." : temperature !== void 0 && temperature <= 0.33 ? " Only include records that are explicitly and unambiguously named." : "";
|
|
20359
|
+
const turn = await client.runTurn({
|
|
20360
|
+
model: DEFAULT_MODEL,
|
|
20361
|
+
system: CLASSIFY_SYSTEM,
|
|
20362
|
+
messages: [
|
|
20363
|
+
{
|
|
20364
|
+
role: "user",
|
|
20365
|
+
content: `# Catalog
|
|
20366
|
+
${buildCatalogBlock(catalog)}
|
|
20367
|
+
|
|
20368
|
+
# Document: ${name}
|
|
20369
|
+
|
|
20370
|
+
${text.slice(0, 12e3)}
|
|
20371
|
+
|
|
20372
|
+
# Task
|
|
20373
|
+
Return the JSON array of matching {table,id}.${liberal}`
|
|
20374
|
+
}
|
|
20375
|
+
],
|
|
20376
|
+
tools: [],
|
|
20377
|
+
...temperature !== void 0 ? { temperature } : {},
|
|
20378
|
+
onText: (d) => {
|
|
20379
|
+
captured += d;
|
|
20380
|
+
}
|
|
20381
|
+
});
|
|
20382
|
+
return parseMatches(turn.text || captured, catalog);
|
|
20383
|
+
}
|
|
20384
|
+
var EXTRACT_SYSTEM = 'You build a knowledge base by extracting the key structured objects a document is ABOUT \u2014 e.g. an invoice, a person, a project, a contract, a meeting. You are given the existing entity types (tables) and their columns. For each salient object: reuse an existing entity when one clearly fits; otherwise propose a NEW entity with a short snake_case PLURAL name and 2-6 simple snake_case columns. Extract only objects the document is genuinely about \u2014 prefer 1-3, never more than 3, and never invent data not in the document. Return ONLY a JSON array of objects {"entity","isNew","columns","values","label"}, where "values" is an OBJECT mapping each column name to its value \u2014 e.g. {"invoice_number":"INV-114","total":"6400"} \u2014 in a ```json fenced block.';
|
|
20385
|
+
function buildSchemaBlock(existing) {
|
|
20386
|
+
if (existing.length === 0) return "(no entities yet \u2014 propose new ones)";
|
|
20387
|
+
return existing.map((e) => `## ${e.table}
|
|
20388
|
+
columns: ${e.columns.join(", ")}`).join("\n\n");
|
|
20389
|
+
}
|
|
20390
|
+
async function extractObjects(client, text, name, existing, temperature) {
|
|
20391
|
+
if (text.trim().length === 0) return [];
|
|
20392
|
+
const turn = await client.runTurn({
|
|
20393
|
+
model: DEFAULT_MODEL,
|
|
20394
|
+
system: EXTRACT_SYSTEM,
|
|
20395
|
+
messages: [
|
|
20396
|
+
{
|
|
20397
|
+
role: "user",
|
|
20398
|
+
content: `# Existing entities
|
|
20399
|
+
${buildSchemaBlock(existing)}
|
|
20400
|
+
|
|
20401
|
+
# Document: ${name}
|
|
20402
|
+
|
|
20403
|
+
${text.slice(0, 12e3)}
|
|
20404
|
+
|
|
20405
|
+
# Task
|
|
20406
|
+
Return the JSON array of objects to create.`
|
|
20407
|
+
}
|
|
20408
|
+
],
|
|
20409
|
+
tools: [],
|
|
20410
|
+
...temperature !== void 0 ? { temperature } : {},
|
|
20411
|
+
onText: () => void 0
|
|
20412
|
+
});
|
|
20413
|
+
return parseObjects(turn.text);
|
|
19884
20414
|
}
|
|
19885
20415
|
|
|
19886
20416
|
// src/gui/ai/sse.ts
|
|
@@ -20043,15 +20573,14 @@ var REHYDRATE_MAX_BYTES = 24e3;
|
|
|
20043
20573
|
function rehydrateEnabled() {
|
|
20044
20574
|
return process.env.LATTICE_CHAT_REHYDRATE !== "false";
|
|
20045
20575
|
}
|
|
20046
|
-
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;
|
|
20047
20579
|
await db.insert("chat_messages", {
|
|
20048
20580
|
id: crypto.randomUUID(),
|
|
20049
20581
|
thread_id: threadId,
|
|
20050
20582
|
role,
|
|
20051
|
-
|
|
20052
|
-
// `turns` carries the rich structure so a reloaded conversation shows the
|
|
20053
|
-
// same text bubbles + tool pills as the live stream, not one text wall.
|
|
20054
|
-
content_json: JSON.stringify(turns && turns.length > 0 ? { text, turns } : { text }),
|
|
20583
|
+
content_json: JSON.stringify(payload),
|
|
20055
20584
|
source: role === "user" ? "gui" : "ai"
|
|
20056
20585
|
});
|
|
20057
20586
|
}
|
|
@@ -20073,11 +20602,17 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20073
20602
|
const messages = rows.filter((r) => r.thread_id === threadId2 && !r.deleted_at).map((r) => {
|
|
20074
20603
|
let text = "";
|
|
20075
20604
|
let turns2;
|
|
20605
|
+
let startedAt;
|
|
20076
20606
|
try {
|
|
20077
20607
|
const parsed = JSON.parse(asStr(r.content_json, "{}"));
|
|
20078
20608
|
text = parsed.text ?? "";
|
|
20609
|
+
if (typeof parsed.startedAt === "string") startedAt = parsed.startedAt;
|
|
20079
20610
|
if (Array.isArray(parsed.turns)) {
|
|
20080
|
-
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
|
+
}));
|
|
20081
20616
|
}
|
|
20082
20617
|
} catch {
|
|
20083
20618
|
}
|
|
@@ -20085,6 +20620,7 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20085
20620
|
role: asStr(r.role),
|
|
20086
20621
|
text,
|
|
20087
20622
|
...turns2 ? { turns: turns2 } : {},
|
|
20623
|
+
...startedAt ? { startedAt } : {},
|
|
20088
20624
|
created_at: asStr(r.created_at)
|
|
20089
20625
|
};
|
|
20090
20626
|
}).sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
@@ -20138,13 +20674,27 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20138
20674
|
junctionTables: new Set([...ctx.junctionTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t))),
|
|
20139
20675
|
softDeletable: ctx.softDeletable,
|
|
20140
20676
|
...ctx.createEntity ? { createEntity: ctx.createEntity } : {},
|
|
20141
|
-
...ctx.createJunction ? { createJunction: ctx.createJunction } : {}
|
|
20677
|
+
...ctx.createJunction ? { createJunction: ctx.createJunction } : {},
|
|
20678
|
+
...ctx.deleteEntity ? { deleteEntity: ctx.deleteEntity } : {}
|
|
20142
20679
|
};
|
|
20680
|
+
const turnStartedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
20143
20681
|
let assistantText = "";
|
|
20144
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
|
+
});
|
|
20145
20695
|
try {
|
|
20146
20696
|
const client = createAnthropicClient(auth);
|
|
20147
|
-
const temperature = aggressivenessToTemperature(
|
|
20697
|
+
const temperature = aggressivenessToTemperature(getAggressiveness());
|
|
20148
20698
|
for await (const ev of runChat({
|
|
20149
20699
|
client,
|
|
20150
20700
|
dispatch,
|
|
@@ -20157,7 +20707,7 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20157
20707
|
}
|
|
20158
20708
|
})) {
|
|
20159
20709
|
if (ev.type === "assistant_message_start") {
|
|
20160
|
-
turns.push({ text: "", tools: [], toolCalls: [] });
|
|
20710
|
+
turns.push({ text: "", tools: [], events: [], toolCalls: [] });
|
|
20161
20711
|
} else if (ev.type === "text_delta") {
|
|
20162
20712
|
assistantText += ev.delta;
|
|
20163
20713
|
const cur = turns[turns.length - 1];
|
|
@@ -20180,19 +20730,39 @@ async function dispatchChatRoute(req, res, ctx) {
|
|
|
20180
20730
|
res.write(formatSseFrame({ type: "done" }));
|
|
20181
20731
|
} catch {
|
|
20182
20732
|
}
|
|
20733
|
+
} finally {
|
|
20734
|
+
unsubscribeFeed();
|
|
20183
20735
|
}
|
|
20184
20736
|
res.end();
|
|
20185
20737
|
if (threadId) {
|
|
20186
20738
|
const cleanTurns = turns.map((t) => ({
|
|
20187
20739
|
text: t.text,
|
|
20188
20740
|
tools: t.tools.map((x) => ({ name: x.name, isError: x.isError })),
|
|
20741
|
+
...t.events.length > 0 ? { events: t.events } : {},
|
|
20189
20742
|
...t.toolCalls.length > 0 ? { toolCalls: t.toolCalls } : {}
|
|
20190
|
-
})).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);
|
|
20191
20744
|
try {
|
|
20192
|
-
await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns);
|
|
20745
|
+
await persistMessage(ctx.db, threadId, "assistant", assistantText, cleanTurns, turnStartedAt);
|
|
20193
20746
|
} catch (e) {
|
|
20194
20747
|
console.warn("[chat] persist assistant message failed:", e.message);
|
|
20195
20748
|
}
|
|
20749
|
+
const createdNew = threadId !== requestedThread;
|
|
20750
|
+
if (createdNew && assistantText.trim()) {
|
|
20751
|
+
try {
|
|
20752
|
+
const placeholder = message.slice(0, 60) || "Chat";
|
|
20753
|
+
const cur = await ctx.db.get("chat_threads", threadId);
|
|
20754
|
+
if (cur && (cur.title ?? "") === placeholder) {
|
|
20755
|
+
const title = await generateThreadTitle(
|
|
20756
|
+
createAnthropicClient(auth),
|
|
20757
|
+
message,
|
|
20758
|
+
assistantText
|
|
20759
|
+
);
|
|
20760
|
+
if (title) await ctx.db.update("chat_threads", threadId, { title });
|
|
20761
|
+
}
|
|
20762
|
+
} catch (e) {
|
|
20763
|
+
console.warn("[chat] thread title generation failed:", e.message);
|
|
20764
|
+
}
|
|
20765
|
+
}
|
|
20196
20766
|
}
|
|
20197
20767
|
return true;
|
|
20198
20768
|
}
|
|
@@ -20344,12 +20914,6 @@ function describe(text, mime, name) {
|
|
|
20344
20914
|
// src/ai/vision.ts
|
|
20345
20915
|
import { createRequire as createRequire5 } from "module";
|
|
20346
20916
|
import { readFile as readFile2 } from "fs/promises";
|
|
20347
|
-
|
|
20348
|
-
// src/ai/llm-client.ts
|
|
20349
|
-
import { createRequire as createRequire4 } from "module";
|
|
20350
|
-
var DEFAULT_MODEL2 = "claude-haiku-4-5";
|
|
20351
|
-
|
|
20352
|
-
// src/ai/vision.ts
|
|
20353
20917
|
var DEFAULT_PROMPT = "Describe this image for a knowledge base in 2-4 factual sentences: what it shows, any visible text, and notable details. No preamble.";
|
|
20354
20918
|
var MAX_DIM = 1568;
|
|
20355
20919
|
async function describeImage(auth, path2, opts = {}) {
|
|
@@ -20709,164 +21273,6 @@ async function attachBlob(srcPath, latticeRoot) {
|
|
|
20709
21273
|
};
|
|
20710
21274
|
}
|
|
20711
21275
|
|
|
20712
|
-
// src/ai/summarize.ts
|
|
20713
|
-
function parseMatches(raw, catalog) {
|
|
20714
|
-
const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
|
|
20715
|
-
const body = fence ? fence[1] : raw;
|
|
20716
|
-
let parsed;
|
|
20717
|
-
try {
|
|
20718
|
-
parsed = JSON.parse((body ?? "").trim());
|
|
20719
|
-
} catch {
|
|
20720
|
-
return [];
|
|
20721
|
-
}
|
|
20722
|
-
if (!Array.isArray(parsed)) return [];
|
|
20723
|
-
const valid = new Map(catalog.map((e) => [e.table, new Set(e.records.map((r) => r.id))]));
|
|
20724
|
-
const out = [];
|
|
20725
|
-
for (const item of parsed) {
|
|
20726
|
-
if (!item || typeof item !== "object") continue;
|
|
20727
|
-
const table = item.table;
|
|
20728
|
-
const id = item.id;
|
|
20729
|
-
if (typeof table === "string" && typeof id === "string" && valid.get(table)?.has(id)) {
|
|
20730
|
-
out.push({ table, id });
|
|
20731
|
-
}
|
|
20732
|
-
}
|
|
20733
|
-
return out;
|
|
20734
|
-
}
|
|
20735
|
-
var ID_RE = /^[a-z][a-z0-9_]*$/;
|
|
20736
|
-
var RESERVED_COLS = /* @__PURE__ */ new Set(["id", "deleted_at", "created_at", "updated_at"]);
|
|
20737
|
-
function parseObjects(raw) {
|
|
20738
|
-
const fence = /```json\s*([\s\S]*?)```/i.exec(raw);
|
|
20739
|
-
let parsed;
|
|
20740
|
-
try {
|
|
20741
|
-
parsed = JSON.parse((fence ? fence[1] : raw)?.trim() ?? "");
|
|
20742
|
-
} catch {
|
|
20743
|
-
return [];
|
|
20744
|
-
}
|
|
20745
|
-
if (!Array.isArray(parsed)) return [];
|
|
20746
|
-
const out = [];
|
|
20747
|
-
for (const item of parsed) {
|
|
20748
|
-
if (!item || typeof item !== "object") continue;
|
|
20749
|
-
const o = item;
|
|
20750
|
-
const entity = typeof o.entity === "string" ? o.entity.trim().toLowerCase() : "";
|
|
20751
|
-
const label = typeof o.label === "string" ? o.label.trim() : "";
|
|
20752
|
-
if (!ID_RE.test(entity) || !label) continue;
|
|
20753
|
-
let valuesRaw = {};
|
|
20754
|
-
if (Array.isArray(o.values) && Array.isArray(o.columns)) {
|
|
20755
|
-
o.columns.forEach((c, i) => {
|
|
20756
|
-
valuesRaw[String(c)] = o.values[i];
|
|
20757
|
-
});
|
|
20758
|
-
} else if (o.values && typeof o.values === "object") {
|
|
20759
|
-
valuesRaw = o.values;
|
|
20760
|
-
}
|
|
20761
|
-
const values = {};
|
|
20762
|
-
for (const [k, v] of Object.entries(valuesRaw)) {
|
|
20763
|
-
const col = k.trim().toLowerCase();
|
|
20764
|
-
if (ID_RE.test(col) && !RESERVED_COLS.has(col) && (typeof v === "string" || typeof v === "number")) {
|
|
20765
|
-
values[col] = String(v).slice(0, 2e3);
|
|
20766
|
-
}
|
|
20767
|
-
}
|
|
20768
|
-
if (Object.keys(values).length === 0) continue;
|
|
20769
|
-
const cols = Array.isArray(o.columns) ? o.columns.map((c) => String(c).trim().toLowerCase()).filter((c) => ID_RE.test(c) && !RESERVED_COLS.has(c)) : [];
|
|
20770
|
-
const columns = Array.from(/* @__PURE__ */ new Set([...cols, ...Object.keys(values)])).slice(0, 8);
|
|
20771
|
-
out.push({ entity, isNew: o.isNew === true, columns, values, label });
|
|
20772
|
-
if (out.length >= 3) break;
|
|
20773
|
-
}
|
|
20774
|
-
return out;
|
|
20775
|
-
}
|
|
20776
|
-
|
|
20777
|
-
// src/gui/ai/summarize.ts
|
|
20778
|
-
var SUMMARY_SYSTEM = 'You write a one or two sentence factual description of a document for a knowledge base, focused on what it is and what it contains. No preamble, no "This document". Plain text only.';
|
|
20779
|
-
async function summarizeText(client, text, name, temperature) {
|
|
20780
|
-
const turn = await client.runTurn({
|
|
20781
|
-
model: DEFAULT_MODEL,
|
|
20782
|
-
system: SUMMARY_SYSTEM,
|
|
20783
|
-
messages: [
|
|
20784
|
-
{
|
|
20785
|
-
role: "user",
|
|
20786
|
-
content: `File name: ${name}
|
|
20787
|
-
|
|
20788
|
-
Content:
|
|
20789
|
-
${text.slice(0, 12e3)}
|
|
20790
|
-
|
|
20791
|
-
Describe it in 1-2 sentences.`
|
|
20792
|
-
}
|
|
20793
|
-
],
|
|
20794
|
-
tools: [],
|
|
20795
|
-
...temperature !== void 0 ? { temperature } : {},
|
|
20796
|
-
onText: () => void 0
|
|
20797
|
-
});
|
|
20798
|
-
return turn.text.trim();
|
|
20799
|
-
}
|
|
20800
|
-
var CLASSIFY_SYSTEM = 'You decide which existing records a newly added document relates to. You are given a catalog of record types (with descriptions) and their records. Return ONLY a JSON array of {"table","id"} objects for records the document clearly relates to \u2014 an empty array if none. Output the JSON in a ```json fenced block and nothing else.';
|
|
20801
|
-
function buildCatalogBlock(catalog) {
|
|
20802
|
-
return catalog.map((e) => {
|
|
20803
|
-
const head = `## ${e.table}${e.description ? ` \u2014 ${e.description}` : ""}`;
|
|
20804
|
-
const rows = e.records.map((r) => `- id=${r.id} | ${r.label}`).join("\n");
|
|
20805
|
-
return `${head}
|
|
20806
|
-
${rows || "- (no records)"}`;
|
|
20807
|
-
}).join("\n\n");
|
|
20808
|
-
}
|
|
20809
|
-
async function classifyLinks(client, text, name, catalog, temperature) {
|
|
20810
|
-
if (catalog.length === 0 || text.trim().length === 0) return [];
|
|
20811
|
-
let captured = "";
|
|
20812
|
-
const liberal = temperature !== void 0 && temperature >= 0.66 ? " Include records that are strongly implied even without an exact name match." : temperature !== void 0 && temperature <= 0.33 ? " Only include records that are explicitly and unambiguously named." : "";
|
|
20813
|
-
const turn = await client.runTurn({
|
|
20814
|
-
model: DEFAULT_MODEL,
|
|
20815
|
-
system: CLASSIFY_SYSTEM,
|
|
20816
|
-
messages: [
|
|
20817
|
-
{
|
|
20818
|
-
role: "user",
|
|
20819
|
-
content: `# Catalog
|
|
20820
|
-
${buildCatalogBlock(catalog)}
|
|
20821
|
-
|
|
20822
|
-
# Document: ${name}
|
|
20823
|
-
|
|
20824
|
-
${text.slice(0, 12e3)}
|
|
20825
|
-
|
|
20826
|
-
# Task
|
|
20827
|
-
Return the JSON array of matching {table,id}.${liberal}`
|
|
20828
|
-
}
|
|
20829
|
-
],
|
|
20830
|
-
tools: [],
|
|
20831
|
-
...temperature !== void 0 ? { temperature } : {},
|
|
20832
|
-
onText: (d) => {
|
|
20833
|
-
captured += d;
|
|
20834
|
-
}
|
|
20835
|
-
});
|
|
20836
|
-
return parseMatches(turn.text || captured, catalog);
|
|
20837
|
-
}
|
|
20838
|
-
var EXTRACT_SYSTEM = 'You build a knowledge base by extracting the key structured objects a document is ABOUT \u2014 e.g. an invoice, a person, a project, a contract, a meeting. You are given the existing entity types (tables) and their columns. For each salient object: reuse an existing entity when one clearly fits; otherwise propose a NEW entity with a short snake_case PLURAL name and 2-6 simple snake_case columns. Extract only objects the document is genuinely about \u2014 prefer 1-3, never more than 3, and never invent data not in the document. Return ONLY a JSON array of objects {"entity","isNew","columns","values","label"}, where "values" is an OBJECT mapping each column name to its value \u2014 e.g. {"invoice_number":"INV-114","total":"6400"} \u2014 in a ```json fenced block.';
|
|
20839
|
-
function buildSchemaBlock(existing) {
|
|
20840
|
-
if (existing.length === 0) return "(no entities yet \u2014 propose new ones)";
|
|
20841
|
-
return existing.map((e) => `## ${e.table}
|
|
20842
|
-
columns: ${e.columns.join(", ")}`).join("\n\n");
|
|
20843
|
-
}
|
|
20844
|
-
async function extractObjects(client, text, name, existing, temperature) {
|
|
20845
|
-
if (text.trim().length === 0) return [];
|
|
20846
|
-
const turn = await client.runTurn({
|
|
20847
|
-
model: DEFAULT_MODEL,
|
|
20848
|
-
system: EXTRACT_SYSTEM,
|
|
20849
|
-
messages: [
|
|
20850
|
-
{
|
|
20851
|
-
role: "user",
|
|
20852
|
-
content: `# Existing entities
|
|
20853
|
-
${buildSchemaBlock(existing)}
|
|
20854
|
-
|
|
20855
|
-
# Document: ${name}
|
|
20856
|
-
|
|
20857
|
-
${text.slice(0, 12e3)}
|
|
20858
|
-
|
|
20859
|
-
# Task
|
|
20860
|
-
Return the JSON array of objects to create.`
|
|
20861
|
-
}
|
|
20862
|
-
],
|
|
20863
|
-
tools: [],
|
|
20864
|
-
...temperature !== void 0 ? { temperature } : {},
|
|
20865
|
-
onText: () => void 0
|
|
20866
|
-
});
|
|
20867
|
-
return parseObjects(turn.text);
|
|
20868
|
-
}
|
|
20869
|
-
|
|
20870
21276
|
// src/gui/ingest-routes.ts
|
|
20871
21277
|
var MIME_BY_EXT = {
|
|
20872
21278
|
".pdf": "application/pdf",
|
|
@@ -21107,6 +21513,37 @@ async function enrichWithLlm(mctx, db, fileId, text, name, junctions, descriptio
|
|
|
21107
21513
|
return [];
|
|
21108
21514
|
}
|
|
21109
21515
|
}
|
|
21516
|
+
async function enrichOrFail(mctx, db, fileId, text, name, ctx, res) {
|
|
21517
|
+
try {
|
|
21518
|
+
return await enrichWithLlm(
|
|
21519
|
+
mctx,
|
|
21520
|
+
db,
|
|
21521
|
+
fileId,
|
|
21522
|
+
text,
|
|
21523
|
+
name,
|
|
21524
|
+
ctx.fileJunctions,
|
|
21525
|
+
ctx.entityDescriptions,
|
|
21526
|
+
ctx.createJunction,
|
|
21527
|
+
ctx.aggressiveness,
|
|
21528
|
+
ctx.createEntity
|
|
21529
|
+
);
|
|
21530
|
+
} catch (e) {
|
|
21531
|
+
const err = e;
|
|
21532
|
+
console.error(
|
|
21533
|
+
`[ingest] enrichment failed for file ${fileId}: ${err.message}
|
|
21534
|
+
${err.stack ?? ""}`
|
|
21535
|
+
);
|
|
21536
|
+
await updateRow(mctx, "files", fileId, { extraction_status: "enrichment_failed" }).catch(
|
|
21537
|
+
(e2) => {
|
|
21538
|
+
console.error(
|
|
21539
|
+
`[ingest] could not mark enrichment_failed on ${fileId}: ${e2.message}`
|
|
21540
|
+
);
|
|
21541
|
+
}
|
|
21542
|
+
);
|
|
21543
|
+
sendJson5(res, { id: fileId, extraction_status: "enrichment_failed", error: err.message }, 201);
|
|
21544
|
+
return null;
|
|
21545
|
+
}
|
|
21546
|
+
}
|
|
21110
21547
|
async function extractImage(db, path2, mime) {
|
|
21111
21548
|
if (!mime.startsWith("image/")) return null;
|
|
21112
21549
|
const auth = await resolveClaudeAuth(db);
|
|
@@ -21214,18 +21651,12 @@ async function dispatchIngestRoute(req, res, ctx) {
|
|
|
21214
21651
|
extraction_status: result.skip ? "skipped" : "extracted",
|
|
21215
21652
|
...blob ? { ref_kind: "blob", blob_path: blob.blob_path, sha256: blob.sha256 } : {}
|
|
21216
21653
|
});
|
|
21217
|
-
|
|
21218
|
-
|
|
21219
|
-
ctx.db,
|
|
21220
|
-
|
|
21221
|
-
|
|
21222
|
-
|
|
21223
|
-
ctx.fileJunctions,
|
|
21224
|
-
ctx.entityDescriptions,
|
|
21225
|
-
ctx.createJunction,
|
|
21226
|
-
ctx.aggressiveness,
|
|
21227
|
-
ctx.createEntity
|
|
21228
|
-
);
|
|
21654
|
+
let suggestedLinks = [];
|
|
21655
|
+
if (!result.skip) {
|
|
21656
|
+
const links = await enrichOrFail(mctx, ctx.db, id2, result.text, name2, ctx, res);
|
|
21657
|
+
if (links === null) return true;
|
|
21658
|
+
suggestedLinks = links;
|
|
21659
|
+
}
|
|
21229
21660
|
sendJson5(
|
|
21230
21661
|
res,
|
|
21231
21662
|
{ id: id2, extraction_status: result.skip ? "skipped" : "extracted", suggestedLinks },
|
|
@@ -21272,18 +21703,8 @@ async function dispatchIngestRoute(req, res, ctx) {
|
|
|
21272
21703
|
extraction_status: "extracted",
|
|
21273
21704
|
...sourceUrl ? { ref_kind: "cloud_ref", ref_uri: sourceUrl, ref_provider: "web" } : {}
|
|
21274
21705
|
});
|
|
21275
|
-
const suggestedLinks = await
|
|
21276
|
-
|
|
21277
|
-
ctx.db,
|
|
21278
|
-
id2,
|
|
21279
|
-
content,
|
|
21280
|
-
title,
|
|
21281
|
-
ctx.fileJunctions,
|
|
21282
|
-
ctx.entityDescriptions,
|
|
21283
|
-
ctx.createJunction,
|
|
21284
|
-
ctx.aggressiveness,
|
|
21285
|
-
ctx.createEntity
|
|
21286
|
-
);
|
|
21706
|
+
const suggestedLinks = await enrichOrFail(mctx, ctx.db, id2, content, title, ctx, res);
|
|
21707
|
+
if (suggestedLinks === null) return true;
|
|
21287
21708
|
sendJson5(res, { id: id2, extraction_status: "extracted", suggestedLinks }, 201);
|
|
21288
21709
|
return true;
|
|
21289
21710
|
}
|
|
@@ -21340,11 +21761,16 @@ async function dispatchIngestRoute(req, res, ctx) {
|
|
|
21340
21761
|
201
|
|
21341
21762
|
);
|
|
21342
21763
|
} catch (e) {
|
|
21764
|
+
const err = e;
|
|
21765
|
+
console.error(
|
|
21766
|
+
`[ingest] extraction/enrichment failed for file ${id}: ${err.message}
|
|
21767
|
+
${err.stack ?? ""}`
|
|
21768
|
+
);
|
|
21343
21769
|
await updateRow(mctx, "files", id, {
|
|
21344
21770
|
extraction_status: "failed",
|
|
21345
|
-
description: `Extraction failed: ${
|
|
21771
|
+
description: `Extraction failed: ${err.message}`
|
|
21346
21772
|
});
|
|
21347
|
-
sendJson5(res, { id, extraction_status: "failed", error:
|
|
21773
|
+
sendJson5(res, { id, extraction_status: "failed", error: err.message }, 201);
|
|
21348
21774
|
}
|
|
21349
21775
|
return true;
|
|
21350
21776
|
}
|
|
@@ -21649,6 +22075,7 @@ async function openConfig(configPath, outputDir, autoRender = false) {
|
|
|
21649
22075
|
await db.init();
|
|
21650
22076
|
await syncUserIdentityRow(db);
|
|
21651
22077
|
await adoptNativeEntities(db);
|
|
22078
|
+
await retireLegacyPreferenceSecrets(db);
|
|
21652
22079
|
const validTables = new Set(parsed.tables.map((t) => t.name));
|
|
21653
22080
|
for (const name of db.getRegisteredTableNames()) {
|
|
21654
22081
|
if (name.startsWith("__lattice_") || name.startsWith("_lattice_")) continue;
|
|
@@ -22134,34 +22561,6 @@ data: ${JSON.stringify(data)}
|
|
|
22134
22561
|
} catch {
|
|
22135
22562
|
}
|
|
22136
22563
|
};
|
|
22137
|
-
try {
|
|
22138
|
-
const auditBackfill = await active.db.query("_lattice_gui_audit", {
|
|
22139
|
-
orderBy: "ts",
|
|
22140
|
-
orderDir: "desc",
|
|
22141
|
-
limit: 20
|
|
22142
|
-
});
|
|
22143
|
-
for (const a of auditBackfill.reverse()) {
|
|
22144
|
-
const json = a.operation === "delete" ? a.before_json : a.after_json;
|
|
22145
|
-
let labelRow;
|
|
22146
|
-
if (json) {
|
|
22147
|
-
try {
|
|
22148
|
-
labelRow = JSON.parse(json);
|
|
22149
|
-
} catch {
|
|
22150
|
-
labelRow = void 0;
|
|
22151
|
-
}
|
|
22152
|
-
}
|
|
22153
|
-
writeFeed({
|
|
22154
|
-
seq: 0,
|
|
22155
|
-
table: a.table_name,
|
|
22156
|
-
op: a.operation,
|
|
22157
|
-
rowId: a.row_id,
|
|
22158
|
-
source: "gui",
|
|
22159
|
-
ts: a.ts,
|
|
22160
|
-
summary: feedSummary(a.operation, a.table_name, labelRow)
|
|
22161
|
-
});
|
|
22162
|
-
}
|
|
22163
|
-
} catch {
|
|
22164
|
-
}
|
|
22165
22564
|
const keepalive = setInterval(() => {
|
|
22166
22565
|
try {
|
|
22167
22566
|
res.write(`: keepalive
|
|
@@ -22236,7 +22635,7 @@ data: ${JSON.stringify(data)}
|
|
|
22236
22635
|
}
|
|
22237
22636
|
const limit = Math.min(50, Math.max(1, Number(url2.searchParams.get("limit") ?? "8")));
|
|
22238
22637
|
const requested = url2.searchParams.get("tables");
|
|
22239
|
-
let tables = [...active.validTables];
|
|
22638
|
+
let tables = [...active.validTables].filter((t) => !ASSISTANT_HIDDEN_TABLES.has(t));
|
|
22240
22639
|
if (requested) {
|
|
22241
22640
|
const want = new Set(
|
|
22242
22641
|
requested.split(",").map((t) => t.trim()).filter(Boolean)
|
|
@@ -22420,20 +22819,7 @@ data: ${JSON.stringify(data)}
|
|
|
22420
22819
|
);
|
|
22421
22820
|
return;
|
|
22422
22821
|
}
|
|
22423
|
-
|
|
22424
|
-
const deletedEntityDef = doc.toJS().entities?.[name];
|
|
22425
|
-
doc.deleteIn(["entities", name]);
|
|
22426
|
-
saveConfigDoc(active.configPath, doc);
|
|
22427
|
-
active = await reopenSameConfig(active, autoRender);
|
|
22428
|
-
await recordSchemaOp(
|
|
22429
|
-
active,
|
|
22430
|
-
"schema.delete_entity",
|
|
22431
|
-
name,
|
|
22432
|
-
{ entity: name, entityDef: deletedEntityDef },
|
|
22433
|
-
null,
|
|
22434
|
-
`Deleted table ${name}`,
|
|
22435
|
-
sessionId
|
|
22436
|
-
);
|
|
22822
|
+
await softDeleteUserEntity(active, name, sessionId);
|
|
22437
22823
|
sendJson(res, { ok: true });
|
|
22438
22824
|
return;
|
|
22439
22825
|
}
|
|
@@ -23097,6 +23483,11 @@ data: ${JSON.stringify(data)}
|
|
|
23097
23483
|
) : `SELECT name FROM sqlite_master WHERE type='table' AND name LIKE '\\_%' ESCAPE '\\' ORDER BY name`;
|
|
23098
23484
|
rows = await adapter.allAsync(listSql);
|
|
23099
23485
|
}
|
|
23486
|
+
for (const n of NATIVE_INTERNAL_NAMES) {
|
|
23487
|
+
if (active.validTables.has(n) && !rows.some((r) => r.name === n)) {
|
|
23488
|
+
rows.push({ name: n });
|
|
23489
|
+
}
|
|
23490
|
+
}
|
|
23100
23491
|
const tables = [];
|
|
23101
23492
|
for (const r of rows) {
|
|
23102
23493
|
const cols = await active.db.introspectColumns(r.name);
|
|
@@ -23109,7 +23500,7 @@ data: ${JSON.stringify(data)}
|
|
|
23109
23500
|
if (method === "GET" && /^\/api\/system-tables\/[^/]+\/rows$/.test(pathname)) {
|
|
23110
23501
|
const parts = pathname.split("/");
|
|
23111
23502
|
const sysTable = decodeURIComponent(parts[3] ?? "");
|
|
23112
|
-
if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable)) {
|
|
23503
|
+
if (!/^_+[a-zA-Z0-9_]+$/.test(sysTable) && !isInternalNativeEntity(sysTable)) {
|
|
23113
23504
|
sendJson(res, { error: "Not a system table" }, 400);
|
|
23114
23505
|
return;
|
|
23115
23506
|
}
|
|
@@ -23757,6 +24148,9 @@ data: ${JSON.stringify(data)}
|
|
|
23757
24148
|
// audited, no-reopen primitives the Context Constructor uses.
|
|
23758
24149
|
createEntity: (name, columns) => createUserEntity(active, name, columns, sessionId),
|
|
23759
24150
|
createJunction: (a, b) => createUserJunction(active, a, b, sessionId),
|
|
24151
|
+
// Guarded, reversible table delete — empty tables go immediately;
|
|
24152
|
+
// non-empty ones come back as `needsResolution` so the assistant asks.
|
|
24153
|
+
deleteEntity: (name, resolution) => aiDeleteEntity(active, name, resolution, sessionId),
|
|
23760
24154
|
pathname,
|
|
23761
24155
|
method
|
|
23762
24156
|
});
|
|
@@ -23771,7 +24165,7 @@ data: ${JSON.stringify(data)}
|
|
|
23771
24165
|
entityDescriptions: entityDescriptions(active.configPath, active.outputDir),
|
|
23772
24166
|
createJunction: (otherTable) => createFileJunction(active, otherTable, sessionId),
|
|
23773
24167
|
createEntity: (entity, columns) => createUserEntity(active, entity, columns, sessionId),
|
|
23774
|
-
aggressiveness:
|
|
24168
|
+
aggressiveness: getAggressiveness(),
|
|
23775
24169
|
latticeRoot: dirname11(active.configPath),
|
|
23776
24170
|
pathname,
|
|
23777
24171
|
method
|
|
@@ -23809,7 +24203,12 @@ data: ${JSON.stringify(data)}
|
|
|
23809
24203
|
}
|
|
23810
24204
|
sendJson(res, { error: "Not found" }, 404);
|
|
23811
24205
|
} catch (err) {
|
|
23812
|
-
|
|
24206
|
+
const e = err;
|
|
24207
|
+
console.error(
|
|
24208
|
+
`[gui] ${req.method ?? "?"} ${req.url ?? "?"} failed: ${e.message}
|
|
24209
|
+
${e.stack ?? ""}`
|
|
24210
|
+
);
|
|
24211
|
+
sendJson(res, { error: e.message }, 500);
|
|
23813
24212
|
}
|
|
23814
24213
|
})();
|
|
23815
24214
|
});
|