bloby-bot 0.46.2 → 0.46.3

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bloby-bot",
3
- "version": "0.46.2",
3
+ "version": "0.46.3",
4
4
  "releaseNotes": [
5
5
  "1. # voice note (PTT bubble)",
6
6
  "2. # audio file + caption",
@@ -81,14 +81,14 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
81
81
  };
82
82
  }, []);
83
83
 
84
- // Load current conversation from DB (last 20 messages)
84
+ // Load current conversation from DB (last 200 messages — pagination kicks in beyond that)
85
85
  const loadFromDb = useCallback(async () => {
86
86
  try {
87
87
  const ctx = await authFetch('/api/context/current').then((r) => r.json());
88
88
  if (!ctx.conversationId) return;
89
89
  setConversationId(ctx.conversationId);
90
90
 
91
- const limit = 20;
91
+ const limit = 200;
92
92
  const res = await authFetch(`/api/conversations/${ctx.conversationId}/messages?limit=${limit}`);
93
93
  if (!res.ok) return;
94
94
  const data = await res.json();
@@ -100,14 +100,14 @@ export function useBlobyChat(ws: WsClient | null, triggerReload?: number, enable
100
100
  } catch { /* worker not ready yet */ }
101
101
  }, [parseMessage]);
102
102
 
103
- // Load older messages (cursor-based pagination)
103
+ // Load older messages (cursor-based pagination — cursor is the rowid-equivalent on the server)
104
104
  const loadOlder = useCallback(async () => {
105
105
  if (loadingOlder.current || !conversationIdRef.current) return;
106
106
  loadingOlder.current = true;
107
107
  try {
108
108
  const oldestId = messages[0]?.id;
109
109
  if (!oldestId) return;
110
- const limit = 20;
110
+ const limit = 100;
111
111
  const res = await authFetch(`/api/conversations/${conversationIdRef.current}/messages?before=${oldestId}&limit=${limit}`);
112
112
  if (!res.ok) return;
113
113
  const data = await res.json();
@@ -1327,7 +1327,11 @@ ${!connected ? `<script>
1327
1327
  const data = msg.data || {};
1328
1328
  const content = data.content;
1329
1329
  if (!content) return;
1330
- if (data.conversationId) convId = data.conversationId;
1330
+ // Note: we intentionally ignore data.conversationId from the client.
1331
+ // The server is the authority on which DB conversation this WS belongs to —
1332
+ // honoring a client-supplied id let stale browser state drive messages into
1333
+ // an orphan conv whose row had been deleted, causing FK failures on every
1334
+ // INSERT. Server resolution below (clientConvs → context.current → create).
1331
1335
 
1332
1336
  // Re-read config on each message so post-onboard changes are picked up
1333
1337
  const freshConfig = loadConfig();
@@ -1396,6 +1400,10 @@ ${!connected ? `<script>
1396
1400
  });
1397
1401
  } catch (err: any) {
1398
1402
  log.warn(`[bloby] DB persist error: ${err.message}`);
1403
+ // Surface to all clients so they can flag the missing user bubble
1404
+ // instead of pretending it's saved. addMessage() in worker/db.ts is
1405
+ // self-healing for orphan convIds, so this should now be rare.
1406
+ broadcastBloby('chat:persist-error', { conversationId: convId, role: 'user', error: err.message });
1399
1407
  }
1400
1408
 
1401
1409
  // Fetch agent/user names and recent messages in parallel
@@ -1438,7 +1446,7 @@ ${!connected ? `<script>
1438
1446
  // the self-chat mirror (the user's own number).
1439
1447
  const waState = channelManager.createWaStreamState();
1440
1448
 
1441
- await startConversation(convId, freshConfig.ai.model, (type, eventData) => {
1449
+ await startConversation(convId, freshConfig.ai.model, async (type, eventData) => {
1442
1450
  // Track stream buffer for reconnecting clients
1443
1451
  if (type === 'bot:typing') {
1444
1452
  currentStreamConvId = convId;
@@ -1493,19 +1501,23 @@ ${!connected ? `<script>
1493
1501
  return;
1494
1502
  }
1495
1503
 
1496
- // Save assistant response to DB
1504
+ // Save assistant response to DB BEFORE broadcasting so a refresh
1505
+ // immediately after the bubble appears can't race the INSERT and lose
1506
+ // the message. addMessage() in worker/db.ts is self-healing —
1507
+ // it INSERT OR IGNOREs the parent conversation row first, so even an
1508
+ // orphan convId persists cleanly.
1497
1509
  if (type === 'bot:response') {
1498
1510
  currentStreamBuffer = '';
1499
-
1500
- (async () => {
1501
- try {
1502
- await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1503
- role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1504
- });
1505
- } catch (err: any) {
1506
- log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1507
- }
1508
- })();
1511
+ try {
1512
+ await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
1513
+ role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
1514
+ });
1515
+ } catch (err: any) {
1516
+ log.warn(`[bloby] DB persist bot response error: ${err.message}`);
1517
+ // Tell clients the bubble they're about to see is not durable —
1518
+ // they can flag/retry rather than silently losing it on refresh.
1519
+ broadcastBloby('chat:persist-error', { conversationId: convId, role: 'assistant', error: err.message });
1520
+ }
1509
1521
  }
1510
1522
 
1511
1523
  // Stream all events to every connected client
package/worker/db.ts CHANGED
@@ -93,10 +93,18 @@ export function deleteConversation(id: string) {
93
93
 
94
94
  // Messages
95
95
  export function addMessage(convId: string, role: string, content: string, meta?: { tokens_in?: number; tokens_out?: number; model?: string; audio_data?: string; attachments?: string }) {
96
- const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model, audio_data, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *')
97
- .get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null, meta?.audio_data ?? null, meta?.attachments ?? null);
98
- db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
99
- return msg as any;
96
+ // Self-heal: if the conversation row is missing (orphan live convId, harness session
97
+ // drift, deleted parent, etc.), create it so the FK constraint never fires.
98
+ // Use the first user message as title; assistant-first stays NULL (filled by UI).
99
+ const tx = db.transaction(() => {
100
+ db.prepare('INSERT OR IGNORE INTO conversations (id, title, model) VALUES (?, ?, ?)')
101
+ .run(convId, role === 'user' ? content.slice(0, 80) : null, meta?.model ?? null);
102
+ const msg = db.prepare('INSERT INTO messages (conversation_id, role, content, tokens_in, tokens_out, model, audio_data, attachments) VALUES (?, ?, ?, ?, ?, ?, ?, ?) RETURNING *')
103
+ .get(convId, role, content, meta?.tokens_in ?? null, meta?.tokens_out ?? null, meta?.model ?? null, meta?.audio_data ?? null, meta?.attachments ?? null);
104
+ db.prepare('UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(convId);
105
+ return msg;
106
+ });
107
+ return tx() as any;
100
108
  }
101
109
  export function getMessages(convId: string) {
102
110
  return db.prepare('SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at ASC').all(convId);
@@ -177,22 +185,29 @@ export function deleteAllTrustedDevices() {
177
185
  db.prepare('DELETE FROM trusted_devices').run();
178
186
  }
179
187
 
180
- // Recent messages (for context injection)
188
+ // Recent messages (for context injection).
189
+ // Order by rowid (monotonic insertion order) — created_at has 1-second resolution
190
+ // so rapid-fire messages can collide. rowid never does.
191
+ // rowid is a hidden column, so `SELECT *` omits it — we must alias it explicitly
192
+ // in the inner query for the outer ORDER BY to reach it.
181
193
  export function getRecentMessages(convId: string, limit = 20) {
182
194
  return db.prepare(`
183
195
  SELECT * FROM (
184
- SELECT * FROM messages WHERE conversation_id = ? ORDER BY created_at DESC LIMIT ?
185
- ) sub ORDER BY created_at ASC
196
+ SELECT messages.*, messages.rowid AS _rid FROM messages
197
+ WHERE conversation_id = ? ORDER BY messages.rowid DESC LIMIT ?
198
+ ) sub ORDER BY _rid ASC
186
199
  `).all(convId, limit);
187
200
  }
188
201
 
189
- // Cursor-based pagination: messages before a given ID
202
+ // Cursor-based pagination: messages before a given message id.
203
+ // Use rowid for comparison — message.id is random hex so `id < ?` is meaningless.
190
204
  export function getMessagesBefore(convId: string, beforeId: string, limit = 20) {
191
205
  return db.prepare(`
192
206
  SELECT * FROM (
193
- SELECT * FROM messages
194
- WHERE conversation_id = ? AND id < ?
195
- ORDER BY id DESC LIMIT ?
196
- ) sub ORDER BY id ASC
207
+ SELECT messages.*, messages.rowid AS _rid FROM messages
208
+ WHERE conversation_id = ?
209
+ AND messages.rowid < (SELECT rowid FROM messages WHERE id = ?)
210
+ ORDER BY messages.rowid DESC LIMIT ?
211
+ ) sub ORDER BY _rid ASC
197
212
  `).all(convId, beforeId, limit);
198
213
  }
package/worker/index.ts CHANGED
@@ -133,12 +133,12 @@ app.post('/api/conversations/:id/messages', (req, res) => {
133
133
  });
134
134
  app.get('/api/conversations/:id/messages/recent', (req, res) => {
135
135
  const limit = parseInt(req.query.limit as string) || 20;
136
- const msgs = getRecentMessages(req.params.id, Math.min(limit, 100));
136
+ const msgs = getRecentMessages(req.params.id, Math.min(limit, 1000));
137
137
  res.json(msgs);
138
138
  });
139
139
  app.get('/api/conversations/:id/messages', (req, res) => {
140
140
  const before = req.query.before as string;
141
- const limit = Math.min(parseInt(req.query.limit as string) || 20, 100);
141
+ const limit = Math.min(parseInt(req.query.limit as string) || 20, 1000);
142
142
  if (before) {
143
143
  res.json(getMessagesBefore(req.params.id, before, limit));
144
144
  } else {