openvibers/openvibe.chat Commits
The Dock lists a person's Vibe conversations and where each began (#428)
Changes
nnew file mode 100644@@@ -0,0 +1,3 @@++-- phase: expand++-- A Vibe conversation remembers the OpenVibe site where it began for the Dock's cross-site list.++ALTER TABLE rooms ADD COLUMN IF NOT EXISTS vibe_origin_site text COLLATE "C";@@@ -133,6 +133,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin/** The Bearer-only DM reads and writes the shared navbar and Dock make from any OpenVibe site. */// /api/dm/users/search: the share sheet's "OpenVibe Chat" (Shared 4.144.0) starts a conversation by a person's name.const bearerDmPath = (p) => p === '/api/dm/unread' || p === '/api/dm/conversations' || p.startsWith('/api/dm/conversations/') || p === '/api/dm/users/search';++ const bearerDockVibePath = (p) => p.startsWith('/api/vibe/') || p.startsWith('/api/chat/vibe/') || p === '/api/chat/vibe/picture' || /^\/api\/chat\/rooms\/[^/]+\/pictures$/.test(p);// ── Internal (loopback; never routed by nginx) ─────────────app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));@@@ -277,7 +278,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin// echoed with no Allow-Credentials, so the Bearer is the whole key, a browser refuses a credentialed read or// preflight, and a foreign web origin is still refused.const origin = req.headers.origin;-- if (origin && bearerDmPath(req.path) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);++ if (origin && (bearerDmPath(req.path) || bearerDockVibePath(req.path)) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);return allowlistedCors(req, res, next);});// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.@@@ -67,6 +67,13 @@ const wire = (m) => {...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };};const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);++const siteOf = (req) => {++ try {++ const host = new URL(String(req.get('origin') || '')).hostname.toLowerCase();++ // Any OpenVibe site (the Dock runs on all of them): openvibe.<site>, or a subdomain of one.++ return /^(?:[a-z0-9-]+\.)?openvibe\.[a-z]{2,15}$/.test(host) ? host : null;++ } catch { return null; }++};async function latest(user) {// The chat they last wrote in (vibe.current): an answer arriving later in an older chat does not take its place.@@@ -84,6 +91,11 @@ router.get('/chat', personOnly, async (req, res) => {messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });});++router.get('/conversations', personOnly, async (req, res) => {++ const conversations = await vibe.list(req.user, { limit: req.query.limit });++ return res.set('Cache-Control', 'private, no-store').json({ conversations, count: await vibe.count(req.user) });++});++router.get('/models', personOnly, async (_req, res) => {try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }@@@ -188,14 +200,14 @@ router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.modelsif (last && Date.now() - moved < SIX_HOURS) room = last;}if (!room && b.models) {-- room = await vibe.createRoom(req.user, message);++ room = await vibe.createRoom(req.user, message, { site: siteOf(req) });try {const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });return res.status(201).json({ chat: chatOf(room), message: wire(posted) });} catch (err) { await vibe.erase(room.id); throw err; }}if (!room) {-- const out = await vibe.start(req.user, message, { timezone });++ const out = await vibe.start(req.user, message, { timezone, site: siteOf(req) });const full = await vibe.ownChat(out.room.slug, req.user);return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });}@@@ -59,7 +59,7 @@ const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null)router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {try {const b = req.body || {};-- const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone });++ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone, site: 'openvibe.chat' });res.status(201).json(out);} catch (err) { send(res, err); }});@@@ -97,7 +97,7 @@ async function setEngine(user, own) {return { own: false, label: null };}--async function createRoom(user, title) {++async function createRoom(user, title, { site = null } = {}) {if (!user) fail(401, 'vibe.sign_in', 'Sign in to chat with Vibe');if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');const engine = await engineOf(user);@@@ -105,8 +105,8 @@ async function createRoom(user, title) {// The oldest chats beyond the limit go, so starting one never fails on it.const extra = await db.all('SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1 ORDER BY COALESCE(last_message_at, created_at) DESC OFFSET ?', [user.id, MAX_CHATS - 1]);for (const r of extra) await erase(r.id);-- const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant) VALUES (?, ?, 'community', 'private', ?, ?, 1)",-- [newSlug(), titleOf(title), user.id, user.subject_id || null]);++ const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant, vibe_origin_site) VALUES (?, ?, 'community', 'private', ?, ?, 1, ?)",++ [newSlug(), titleOf(title), user.id, user.subject_id || null, site]);const roomId = Number(r.lastInsertRowid);await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [roomId, user.id]);await db.run('INSERT INTO room_ai_members (room_id, name, handle, persona, memory, created_by_subject, engine, engine_subject, engine_label) VALUES (?, ?, ?, \'\', \'\', ?, ?, ?, ?)',@@@ -116,10 +116,10 @@ async function createRoom(user, title) {return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);}--async function start(user, text, { timezone = null } = {}) {++async function start(user, text, { timezone = null, site = null } = {}) {const body = String(text || '').replace(/\r\n/g, '\n').trim();if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');-- const room = await createRoom(user, body);++ const room = await createRoom(user, body, { site });const id = room.id;try {const message = await rooms.post(room, user, body, { timezone });@@@ -182,9 +182,20 @@ async function startWork(user, text, mode) {async function list(user, { limit = 100 } = {}) {if (!user) return [];const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));-- const rows = await db.all(`SELECT slug, name, last_message_at, created_at FROM rooms-- WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?`, [user.id, lim]);-- return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at }));++ const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,++ (SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,++ (SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_at++ FROM rooms r++ WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);++ return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,++ last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,++ last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null }));++}++++async function count(user) {++ if (!user) return 0;++ const row = await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL', [user.id]);++ return Number(row && row.n || 0);}/**@@@ -272,4 +283,4 @@ async function countReply(user) {ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);}--module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };++module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, count, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };@@@ -67,6 +67,20 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);});++t('the Dock lists only your conversations with a preview and the site where each began', async () => {++ const started = await h.http('POST', '/api/vibe/ask', { token: ann.token, headers: { origin: 'https://openvibe.today' }, body: { message: 'What is on today?', fresh: true } });++ assert.strictEqual(started.status, 201, started.text);++ const r = await h.http('GET', '/api/vibe/conversations', { token: ann.token });++ assert.strictEqual(r.status, 200, r.text);++ const row = r.body.conversations.find((c) => c.slug === started.body.chat.slug);++ assert.deepStrictEqual([row.last_line, row.site], ['What is on today?', 'openvibe.today']);++ assert.ok(r.body.count >= 1);++ const short = await h.http('GET', '/api/vibe/conversations?limit=1', { token: ann.token });++ assert.strictEqual(short.body.conversations.length, 1);++ assert.ok(short.body.count > 1, 'the Chats row counts the full history, beyond this page');++ assert.ok(!(await h.http('GET', '/api/vibe/conversations', { token: bob.token })).body.conversations.some((c) => c.slug === row.slug));++});++t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {require('../server/net/actor-limits').limits.reset();const seen = [];
new file mode 100644
new file mode 100644
@@ -0,0 +1,3 @@
@@ -0,0 +1,3 @@
+-- phase: expand
+-- A Vibe conversation remembers the OpenVibe site where it began for the Dock's cross-site list.
+ALTER TABLE rooms ADD COLUMN IF NOT EXISTS vibe_origin_site text COLLATE "C";
@@ -133,6 +133,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -133,6 +133,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
/** The Bearer-only DM reads and writes the shared navbar and Dock make from any OpenVibe site. */
/** The Bearer-only DM reads and writes the shared navbar and Dock make from any OpenVibe site. */
// /api/dm/users/search: the share sheet's "OpenVibe Chat" (Shared 4.144.0) starts a conversation by a person's name.
// /api/dm/users/search: the share sheet's "OpenVibe Chat" (Shared 4.144.0) starts a conversation by a person's name.
const bearerDmPath = (p) => p === '/api/dm/unread' || p === '/api/dm/conversations' || p.startsWith('/api/dm/conversations/') || p === '/api/dm/users/search';
const bearerDmPath = (p) => p === '/api/dm/unread' || p === '/api/dm/conversations' || p.startsWith('/api/dm/conversations/') || p === '/api/dm/users/search';
+ const bearerDockVibePath = (p) => p.startsWith('/api/vibe/') || p.startsWith('/api/chat/vibe/') || p === '/api/chat/vibe/picture' || /^\/api\/chat\/rooms\/[^/]+\/pictures$/.test(p);
// ── Internal (loopback; never routed by nginx) ─────────────
// ── Internal (loopback; never routed by nginx) ─────────────
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
app.use('/internal/chat', express.json({ limit: '64kb' }), createInternalIngress({ chatServer }));
@@ -277,7 +278,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
@@ -277,7 +278,7 @@ function createApp({ chatServer, relay, events = null, callServer = null, settin
// echoed with no Allow-Credentials, so the Bearer is the whole key, a browser refuses a credentialed read or
// echoed with no Allow-Credentials, so the Bearer is the whole key, a browser refuses a credentialed read or
// preflight, and a foreign web origin is still refused.
// preflight, and a foreign web origin is still refused.
const origin = req.headers.origin;
const origin = req.headers.origin;
- if (origin && bearerDmPath(req.path) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);
+ if (origin && (bearerDmPath(req.path) || bearerDockVibePath(req.path)) && !isAllowedOrigin(origin) && FIRST_PARTY.has(origin)) return openCors(req, res, next);
return allowlistedCors(req, res, next);
return allowlistedCors(req, res, next);
});
});
// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
// A workspace room's incoming hooks check a signature over the raw body, so they read it before the JSON parser does.
@@ -67,6 +67,13 @@ const wire = (m) => {
@@ -67,6 +67,13 @@ const wire = (m) => {
...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };
...(m.vibe_model ? { model: m.vibe_model } : {}), ...(results.length ? { results } : {}) };
};
};
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
const chatOf = (room) => (room ? { slug: room.slug, name: room.name || 'Vibe', url: `https://openvibe.chat/vibe/${encodeURIComponent(room.slug)}` } : null);
+const siteOf = (req) => {
+ try {
+ const host = new URL(String(req.get('origin') || '')).hostname.toLowerCase();
+ // Any OpenVibe site (the Dock runs on all of them): openvibe.<site>, or a subdomain of one.
+ return /^(?:[a-z0-9-]+\.)?openvibe\.[a-z]{2,15}$/.test(host) ? host : null;
+ } catch { return null; }
+};
async function latest(user) {
async function latest(user) {
// The chat they last wrote in (vibe.current): an answer arriving later in an older chat does not take its place.
// The chat they last wrote in (vibe.current): an answer arriving later in an older chat does not take its place.
@@ -84,6 +91,11 @@ router.get('/chat', personOnly, async (req, res) => {
@@ -84,6 +91,11 @@ router.get('/chat', personOnly, async (req, res) => {
messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
messages: rows.filter((m) => !m.is_deleted).map((m) => ({ ...wire(m), ...(by.has(Number(m.id)) ? { answers: by.get(Number(m.id)) } : {}) })) });
});
});
+router.get('/conversations', personOnly, async (req, res) => {
+ const conversations = await vibe.list(req.user, { limit: req.query.limit });
+ return res.set('Cache-Control', 'private, no-store').json({ conversations, count: await vibe.count(req.user) });
+});
+
router.get('/models', personOnly, async (_req, res) => {
router.get('/models', personOnly, async (_req, res) => {
try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }
try { return res.set('Cache-Control', 'private, max-age=120').json(await aiClient.catalog()); }
catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }
catch { return res.status(503).json({ error: 'Models are unavailable right now' }); }
@@ -188,14 +200,14 @@ router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.models
@@ -188,14 +200,14 @@ router.post('/ask', personOnly, (req, res, next) => (req.body && req.body.models
if (last && Date.now() - moved < SIX_HOURS) room = last;
if (last && Date.now() - moved < SIX_HOURS) room = last;
}
}
if (!room && b.models) {
if (!room && b.models) {
- room = await vibe.createRoom(req.user, message);
+ room = await vibe.createRoom(req.user, message, { site: siteOf(req) });
try {
try {
const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });
const posted = await compare.ask(room, req.user, message, b.models, { system_prompt: b.system_prompt, temperature: b.temperature });
return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
return res.status(201).json({ chat: chatOf(room), message: wire(posted) });
} catch (err) { await vibe.erase(room.id); throw err; }
} catch (err) { await vibe.erase(room.id); throw err; }
}
}
if (!room) {
if (!room) {
- const out = await vibe.start(req.user, message, { timezone });
+ const out = await vibe.start(req.user, message, { timezone, site: siteOf(req) });
const full = await vibe.ownChat(out.room.slug, req.user);
const full = await vibe.ownChat(out.room.slug, req.user);
return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
return res.status(201).json({ chat: chatOf(full || out.room), message: wire(out.message) });
}
}
@@ -59,7 +59,7 @@ const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null)
@@ -59,7 +59,7 @@ const modeOf = (b) => (b && b.mode && b.mode !== 'chat' ? String(b.mode) : null)
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
router.post('/', requireAuth, personOnly, startLimit, async (req, res) => {
try {
try {
const b = req.body || {};
const b = req.body || {};
- const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone });
+ const out = modeOf(b) ? await vibe.startWork(req.user, b.message, modeOf(b)) : await vibe.start(req.user, b.message, { timezone: b.timezone, site: 'openvibe.chat' });
res.status(201).json(out);
res.status(201).json(out);
} catch (err) { send(res, err); }
} catch (err) { send(res, err); }
});
});
@@ -97,7 +97,7 @@ async function setEngine(user, own) {
@@ -97,7 +97,7 @@ async function setEngine(user, own) {
return { own: false, label: null };
return { own: false, label: null };
}
}
-async function createRoom(user, title) {
+async function createRoom(user, title, { site = null } = {}) {
if (!user) fail(401, 'vibe.sign_in', 'Sign in to chat with Vibe');
if (!user) fail(401, 'vibe.sign_in', 'Sign in to chat with Vibe');
if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');
if (!enabled()) fail(503, 'vibe.off', 'Vibe is resting right now. Try again later.');
const engine = await engineOf(user);
const engine = await engineOf(user);
@@ -105,8 +105,8 @@ async function createRoom(user, title) {
@@ -105,8 +105,8 @@ async function createRoom(user, title) {
// The oldest chats beyond the limit go, so starting one never fails on it.
// The oldest chats beyond the limit go, so starting one never fails on it.
const extra = await db.all('SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1 ORDER BY COALESCE(last_message_at, created_at) DESC OFFSET ?', [user.id, MAX_CHATS - 1]);
const extra = await db.all('SELECT id FROM rooms WHERE owner_id = ? AND assistant = 1 ORDER BY COALESCE(last_message_at, created_at) DESC OFFSET ?', [user.id, MAX_CHATS - 1]);
for (const r of extra) await erase(r.id);
for (const r of extra) await erase(r.id);
- const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant) VALUES (?, ?, 'community', 'private', ?, ?, 1)",
- [newSlug(), titleOf(title), user.id, user.subject_id || null]);
+ const r = await db.run("INSERT INTO rooms (slug, name, kind, visibility, owner_id, owner_subject, assistant, vibe_origin_site) VALUES (?, ?, 'community', 'private', ?, ?, 1, ?)",
+ [newSlug(), titleOf(title), user.id, user.subject_id || null, site]);
const roomId = Number(r.lastInsertRowid);
const roomId = Number(r.lastInsertRowid);
await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [roomId, user.id]);
await db.run("INSERT INTO room_members (room_id, user_id, role, last_seen_at) VALUES (?, ?, 'owner', ov_now())", [roomId, user.id]);
await db.run('INSERT INTO room_ai_members (room_id, name, handle, persona, memory, created_by_subject, engine, engine_subject, engine_label) VALUES (?, ?, ?, \'\', \'\', ?, ?, ?, ?)',
await db.run('INSERT INTO room_ai_members (room_id, name, handle, persona, memory, created_by_subject, engine, engine_subject, engine_label) VALUES (?, ?, ?, \'\', \'\', ?, ?, ?, ?)',
@@ -116,10 +116,10 @@ async function createRoom(user, title) {
@@ -116,10 +116,10 @@ async function createRoom(user, title) {
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
return await db.get('SELECT * FROM rooms WHERE id = ?', [id]);
}
}
-async function start(user, text, { timezone = null } = {}) {
+async function start(user, text, { timezone = null, site = null } = {}) {
const body = String(text || '').replace(/\r\n/g, '\n').trim();
const body = String(text || '').replace(/\r\n/g, '\n').trim();
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
if (user && enabled() && !body) fail(422, 'rooms.empty', 'Write something first');
- const room = await createRoom(user, body);
+ const room = await createRoom(user, body, { site });
const id = room.id;
const id = room.id;
try {
try {
const message = await rooms.post(room, user, body, { timezone });
const message = await rooms.post(room, user, body, { timezone });
@@ -182,9 +182,20 @@ async function startWork(user, text, mode) {
@@ -182,9 +182,20 @@ async function startWork(user, text, mode) {
async function list(user, { limit = 100 } = {}) {
async function list(user, { limit = 100 } = {}) {
if (!user) return [];
if (!user) return [];
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
const lim = Math.min(MAX_CHATS, Math.max(1, Number(limit) || 100));
- const rows = await db.all(`SELECT slug, name, last_message_at, created_at FROM rooms
- WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL ORDER BY COALESCE(last_message_at, created_at) DESC LIMIT ?`, [user.id, lim]);
- return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at }));
+ const rows = await db.all(`SELECT r.slug, r.name, r.last_message_at, r.created_at, r.vibe_origin_site,
+ (SELECT m.message FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 ORDER BY m.id DESC LIMIT 1) AS last_line,
+ (SELECT m.created_at FROM room_messages m WHERE m.room_id = r.id AND m.is_deleted = 0 AND m.ai_member_id IS NOT NULL ORDER BY m.id DESC LIMIT 1) AS last_answer_at
+ FROM rooms r
+ WHERE r.owner_id = ? AND r.assistant = 1 AND r.archived_at IS NULL ORDER BY COALESCE(r.last_message_at, r.created_at) DESC LIMIT ?`, [user.id, lim]);
+ return rows.map((r) => ({ slug: r.slug, name: r.name, last_message_at: r.last_message_at || r.created_at, created_at: r.created_at,
+ last_line: r.last_line ? String(r.last_line).replace(/\s+/g, ' ').slice(0, 160) : null,
+ last_answer_at: r.last_answer_at || null, site: r.vibe_origin_site || null }));
+}
+
+async function count(user) {
+ if (!user) return 0;
+ const row = await db.get('SELECT COUNT(*) AS n FROM rooms WHERE owner_id = ? AND assistant = 1 AND archived_at IS NULL', [user.id]);
+ return Number(row && row.n || 0);
}
}
/**
/**
@@ -272,4 +283,4 @@ async function countReply(user) {
@@ -272,4 +283,4 @@ async function countReply(user) {
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
ON CONFLICT (user_id, day) DO UPDATE SET replies = vibe_usage.replies + 1`, [user.id, today()]);
}
}
-module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
+module.exports = { VIBE, current, MAX_CHATS, TITLE_MAX, SEARCH_MAX, DEFAULT_TITLE, MODES, VibeError, enabled, isVibeRoom, titleOf, start, startWork, work, createRoom, erase: (roomId) => db.tx(() => erase(roomId)), list, count, search, ownChat, rename, remove, retry, lastExchange, repliesToday, countReply, engineOf: async (user) => { const e = await engineOf(user); return { own: e.own, label: e.label, ...(e.own ? { you: youOf(e.cred) } : {}) }; }, setEngine };
@@ -67,6 +67,20 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
@@ -67,6 +67,20 @@ t('a chat is its person\'s alone, and an empty message is refused', async () =>
assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
assert.strictEqual((await h.http('POST', '/api/vibe/ask', { token: ann.token, body: { message: ' ' } })).status, 422);
});
});
+t('the Dock lists only your conversations with a preview and the site where each began', async () => {
+ const started = await h.http('POST', '/api/vibe/ask', { token: ann.token, headers: { origin: 'https://openvibe.today' }, body: { message: 'What is on today?', fresh: true } });
+ assert.strictEqual(started.status, 201, started.text);
+ const r = await h.http('GET', '/api/vibe/conversations', { token: ann.token });
+ assert.strictEqual(r.status, 200, r.text);
+ const row = r.body.conversations.find((c) => c.slug === started.body.chat.slug);
+ assert.deepStrictEqual([row.last_line, row.site], ['What is on today?', 'openvibe.today']);
+ assert.ok(r.body.count >= 1);
+ const short = await h.http('GET', '/api/vibe/conversations?limit=1', { token: ann.token });
+ assert.strictEqual(short.body.conversations.length, 1);
+ assert.ok(short.body.count > 1, 'the Chats row counts the full history, beyond this page');
+ assert.ok(!(await h.http('GET', '/api/vibe/conversations', { token: bob.token })).body.conversations.some((c) => c.slug === row.slug));
+});
+
t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {
t('two and three explicit models run in parallel, keep receipts, and isolate a failure', async () => {
require('../server/net/actor-limits').limits.reset();
require('../server/net/actor-limits').limits.reset();
const seen = [];
const seen = [];