Governed substrate for autonomous agents: scoped identity (passports), audited actions, MCP workspace. Infra IPs and secrets redacted for public release.
127 lines
3.7 KiB
JavaScript
127 lines
3.7 KiB
JavaScript
/**
|
|
* Bubble-based pagination utility for chat messages.
|
|
*
|
|
* A "bubble" represents what the user sees in the UI:
|
|
* - 1 user message (role='user') = 1 bubble
|
|
* - A contiguous sequence of assistant messages (thinking, tool_call,
|
|
* tool_result, text) between two user messages = 1 bubble
|
|
* - Standalone assistant text = 1 bubble
|
|
* - System messages (role='system') = don't count
|
|
*/
|
|
|
|
export const BUBBLE_PAGE_SIZE = 50;
|
|
|
|
/**
|
|
* Count how many bubbles are in a message array.
|
|
* Messages can be in any order (ASC or DESC) — the function normalises to ASC
|
|
* internally so the grouping logic is consistent.
|
|
*
|
|
* @param {Array} messages - Array of message objects with at least a `role` field.
|
|
* @returns {number} The number of visible bubbles.
|
|
*/
|
|
export function countBubbles(messages) {
|
|
if (!messages || messages.length === 0) return 0;
|
|
|
|
let bubbles = 0;
|
|
let inAssistantGroup = false;
|
|
|
|
for (const msg of messages) {
|
|
const role = msg.role;
|
|
|
|
// System messages are invisible — skip
|
|
if (role === 'system') continue;
|
|
|
|
if (role === 'user') {
|
|
// If we were accumulating an assistant group, close it first
|
|
if (inAssistantGroup) {
|
|
inAssistantGroup = false;
|
|
}
|
|
bubbles += 1;
|
|
} else {
|
|
// assistant / tool_call / tool_result / thinking — all part of assistant group
|
|
if (!inAssistantGroup) {
|
|
bubbles += 1;
|
|
inAssistantGroup = true;
|
|
}
|
|
// else: still inside the same assistant bubble, don't count again
|
|
}
|
|
}
|
|
|
|
return bubbles;
|
|
}
|
|
|
|
/**
|
|
* Count bubbles in a message array and return pagination info.
|
|
*
|
|
* @param {Array} messages - Raw messages from DB, ordered by created_at DESC, id DESC (newest first).
|
|
* @param {number} bubbleLimit - How many bubbles to return (default 50).
|
|
* @returns {{
|
|
* messages: Array,
|
|
* hasMore: boolean,
|
|
* nextCursor: number|null,
|
|
* bubbleCount: number
|
|
* }}
|
|
*/
|
|
export function paginateByBubbles(messages, bubbleLimit = BUBBLE_PAGE_SIZE) {
|
|
if (!messages || messages.length === 0) {
|
|
return { messages: [], hasMore: false, nextCursor: null, bubbleCount: 0 };
|
|
}
|
|
|
|
// Messages arrive DESC (newest first).
|
|
// We walk from index 0 (newest) towards the end (oldest), collecting
|
|
// bubbles until we reach bubbleLimit.
|
|
|
|
let bubbleCount = 0;
|
|
let inAssistantGroup = false;
|
|
let cutoffIndex = messages.length; // will hold the exclusive end of collected messages
|
|
|
|
for (let i = 0; i < messages.length; i++) {
|
|
const role = messages[i].role;
|
|
|
|
// System messages are invisible — always include but don't count
|
|
if (role === 'system') continue;
|
|
|
|
if (role === 'user') {
|
|
// Close any open assistant group first
|
|
if (inAssistantGroup) {
|
|
inAssistantGroup = false;
|
|
}
|
|
|
|
// This user message is a new bubble
|
|
if (bubbleCount >= bubbleLimit) {
|
|
// We've already collected enough bubbles; stop here
|
|
cutoffIndex = i;
|
|
break;
|
|
}
|
|
bubbleCount += 1;
|
|
} else {
|
|
// assistant / tool / thinking
|
|
if (!inAssistantGroup) {
|
|
// Starting a new assistant group — that's a new bubble
|
|
if (bubbleCount >= bubbleLimit) {
|
|
cutoffIndex = i;
|
|
break;
|
|
}
|
|
bubbleCount += 1;
|
|
inAssistantGroup = true;
|
|
}
|
|
// If already in a group, it's still the same bubble
|
|
}
|
|
}
|
|
|
|
const collected = messages.slice(0, cutoffIndex);
|
|
const hasMore = cutoffIndex < messages.length;
|
|
|
|
// Reverse to ASC for display (chronological order)
|
|
collected.reverse();
|
|
|
|
// nextCursor = ID of the oldest message we're returning (first after reverse)
|
|
const nextCursor = hasMore && collected.length > 0 ? collected[0].id : null;
|
|
|
|
return {
|
|
messages: collected,
|
|
hasMore,
|
|
nextCursor,
|
|
bubbleCount,
|
|
};
|
|
}
|