Carry conversations into new sessions with a visible summary

- Bots get continue_in_new_session: the bot writes a title and summary,
  the session closes for good (read-only) and the new one opens with the
  summary as a "Carried over from the last session" message that links
  back to the old session.
- Continue in new session (conversation menu, sessions modal) and the
  automatic rollover at the session length do the same; New session now
  starts a fresh, unlinked session.
- Session length is an app setting in Settings > General.
- ?session_id= opens a session; a carried-over one shows a bar linking to
  where the conversation went on instead of the composer.
- The thread can open the previous session above the current one (shown
  only, never in the prompt), and replies get search_past_sessions and
  read_past_session.
- Sessions are named from their opening messages, not their latest, and
  a carried summary is labelled as the earlier session's.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 05:35:52 +01:00
co-authored by Claude Opus 5.5
parent 1e6833b5d8
commit cc109bc361
64 changed files with 2074 additions and 128 deletions
@@ -0,0 +1,88 @@
import { AppData } from "@/src/data/app-data";
/**
* Which earlier sessions are read: one user's conversation with one bot,
* in its own chat or one workspace, without the session being answered.
*/
export type PastSessionScope = {
user_id: number | string;
bot_id: number | string;
workspace_id?: number | string | null;
/**
* The session the bot is answering in: its messages are already in
* the prompt.
*/
current_session_id: number | string;
};
export type SearchPastSessionsParams = PastSessionScope & {
/**
* Words to find in the messages; every one must be there.
*/
query: string;
/**
* Only messages older than this one (paging).
*/
before_id?: number | string | null;
limit?: number;
snippet_chars?: number;
};
/**
* Finds messages of the conversation's earlier sessions, newest first,
* with their session's title and the text around the first word's match
* (cut in SQL). Every word must match, in any case; `%` and `_` match
* only themselves.
*/
export default function searchPastSessionsSql({
user_id,
bot_id,
workspace_id,
current_session_id,
query,
before_id,
limit = AppData.PastSessionSearchLimit,
snippet_chars = AppData.PastSessionSnippetChars,
}: SearchPastSessionsParams) {
const words = query.trim().split(/\s+/).filter(Boolean).slice(0, 8);
const first = words[0] || "";
const lead = Math.floor(snippet_chars / 3);
const values: (string | number | null)[] = [
first,
lead,
snippet_chars,
Number(user_id),
Number(bot_id),
workspace_id ? Number(workspace_id) : null,
Number(current_session_id),
];
const where = [
`s.user_id = ?`,
`s.bot_id = ?`,
`s.workspace_id IS ?`,
`s.id != ?`,
];
for (const word of words) {
where.push(`m.text LIKE ? ESCAPE '\\'`);
values.push(`%${word.replace(/[\\%_]/g, (c) => `\\${c}`)}%`);
}
if (before_id) {
where.push(`m.id < ?`);
values.push(Number(before_id));
}
values.push(Math.min(Math.max(Math.floor(limit) || 1, 1), 100));
return {
sql: `SELECT m.id, m.session_id, m.author, m.created_at, s.title AS session_title,
substr(m.text, max(1, instr(lower(m.text), lower(?)) - ?), ?) AS snippet
FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
WHERE ${where.join(" AND ")}
ORDER BY m.id DESC
LIMIT ?`,
values,
};
}