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:
@@ -88,9 +88,9 @@ Enables SQLite foreign keys (`enableForeignKeys`), `bunextInit()`, `ensureDocker
|
||||
### Bots, sessions, chat
|
||||
|
||||
- **Tables:** `bots` (`kind`, `color` `#rrggbb`, `stone`, `role`, `system_prompt`; `hue` is a legacy fallback), `bot_user_faces`, `bot_sessions`, `bot_messages`, `bot_tasks`, `bot_task_steps` (unused yet). Enums in `src/dict/bot-dict.ts`.
|
||||
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at `AppData.BotSessionMaxMessages`, titled and summarised by `name-bot-session.ts`, summary carried into the next session). Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes.
|
||||
- **Sessions:** one thread per user per bot in its own chat plus one per workspace; split into `bot_sessions` (rollover at the app setting `bot_session_max_messages`, default `AppData.BotSessionMaxMessages`, via `grabBotSessionMaxMessages`; titled and summarised by `name-bot-session.ts` from the session's opening (`grabSessionOpening`; `grabBotMessages` reads the latest), summary carried into the next session only when they're linked by `previous_session_id`, which only a carry-over sets; older implicit links were cleared on 2026-10-26). New session (`startBotSession`) and a conversation with no active session always start fresh. The rollover carries over like the bot does (`rollOverBotSession` -> `continueBotSession`, falling back to the stored summary; kept for the next message while a job is open in the full session or there's nothing to carry), inside send-message, so the user's message lands under the summary. Helpers in `src/functions/backend/db/bot-sessions/`. Job results land in the job's own session, even after it closes, except a reply that called `continue_in_new_session` (`carryOverBotSession`; the menu's and sessions modal's Continue in new session does the same through `continueBotSession`, the model writing title and summary, refused while a job is open in the session): its summary is kept on the closed session and opens the new one as a bot message (`carried_from_session_id`), the job's `session_id` moves so the answer follows, the new session is titled by the bot then (it has no first exchange to be named from), and the old one can never be reopened (`reopenBotSession` refuses; a thread showing it swaps its composer for a link to the new one). Session lists (rail, sessions modal) reopen a session on click, as before; a carried-over one can't be, so it opens to read (`?session_id=`, `conversationHref`). From half the session length the reply prompt suggests moving on (`longSessionNote`). Earlier sessions never enter the prompt (only the last one's summary): the thread can open them above a session (`useOlderMessages.loadPrevious`, a divider per session, quote and reply as usual), and replies get `search_past_sessions`/`read_past_session` (scoped to that user, bot and place, never the current session) when the conversation has any.
|
||||
- **Coordinator:** exactly one, the super admin's alone, created idempotently by `ensureCoordinatorBot`. Default prompt `src/data/coordinator-system-prompt.ts`, backfilled only when empty.
|
||||
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
|
||||
- **Routes:** `/admin/bots` reopens the last visited bot (cookie `cobble_last_visited_bot_id`, never a workspace conversation) if still reachable, else coordinator (super admin) / latest bot / first workspace / empty. `/admin/bots/[bot_id]` conversation (header, thread and composer at 760px, no right panel; `?session_id=` opens another of the user's sessions there: a closed one is reopened, except a carried-over one, which shows with `SessionMovedBar` instead of the composer, pointing where the conversation went on); `/admin/bots/[bot_id]/models`; `/admin/workspaces/[id]` dashboard; `/admin/workspaces/[id]/bots/[bot_id]` same conversation scoped to the workspace.
|
||||
- **Conversation loading:** pages render everything server-side (`grabConversationProps`) **except the history**, which the client loads (`api/admin/bots/conversation-history`, merged with what's already shown, refetched on bfcache restore) and pages older messages on scroll. Rail lists go through `railBots`/`railWorkspaces` (no prompts/memory, previews cut). While a reply is due the page polls `session-messages` (`use-reply-catch-up`) in case a push was missed.
|
||||
- **Sending:** `api/admin/bots/send-message.ts` -> `enqueueBotReply` -> `wakeBotsQueue`. **Never import `src/bots-queue/index.ts` from a page or API route** (pulls the bunext runtime into page bundles). Optimistic bubble in `pending_messages` until the saved message replaces it.
|
||||
- **Composer:** Tiptap editor reading/writing markdown, drafts in `localStorage`, `@` bots / `#` workspaces written as `[bot:ID]`/`[workspace:ID]`. Enter sends (Mod+Enter in lists/code). `@tiptap/react` needs `use-sync-external-store`; the app ships an ESM stand-in in `packages/use-sync-external-store-esm` (file dependency + override; the Dockerfile copies `packages/` before `bun install`).
|
||||
|
||||
@@ -464,6 +464,11 @@ const schema: BUN_SQLITE_DatabaseSchemaType = {
|
||||
fieldDescription: `JSON \`[{ name, ok }]\`: every tool call the run behind a bot message made (names only, never arguments), told to the bot with the message in later turns so it can't build on work it never did. Null: not recorded (user messages, interim ones, older messages)`,
|
||||
dataType: "TEXT",
|
||||
},
|
||||
{
|
||||
fieldName: "carried_from_session_id",
|
||||
fieldDescription: `Set on the message that opens a session the bot moved the conversation into (\`continue_in_new_session\`): the session it carries on from, whose summary is the message's text. Not a foreign key`,
|
||||
dataType: "INTEGER",
|
||||
},
|
||||
],
|
||||
indexes: [
|
||||
{
|
||||
|
||||
@@ -327,6 +327,10 @@ export type BUN_SQLITE_COBBLE_BOT_MESSAGES = {
|
||||
* JSON `[{ name, ok }]`: every tool call the run behind a bot message made (names only, never arguments), told to the bot with the message in later turns so it can't build on work it never did. Null: not recorded (user messages, interim ones, older messages)
|
||||
*/
|
||||
tool_log?: string;
|
||||
/**
|
||||
* Set on the message that opens a session the bot moved the conversation into (`continue_in_new_session`): the session it carries on from, whose summary is the message's text. Not a foreign key
|
||||
*/
|
||||
carried_from_session_id?: number | "";
|
||||
}
|
||||
|
||||
export type BUN_SQLITE_COBBLE_BOT_MESSAGE_REACTIONS = {
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AppData } from "@/src/data/app-data";
|
||||
import { PreviousSessionNote, SessionSummaryPrompt } from "@/src/data/harness-prompts";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import generateText from "@/src/harness/text/generate-text";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
@@ -53,9 +54,7 @@ export default async function grabSessionCarryOver({
|
||||
|
||||
if (!messages.length) return "";
|
||||
|
||||
const transcript = messages
|
||||
.map((message) => `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`)
|
||||
.join("\n\n");
|
||||
const transcript = sessionNameTranscript({ messages });
|
||||
|
||||
const result = await generateText({
|
||||
messages: [
|
||||
|
||||
@@ -2,10 +2,11 @@ import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SessionNamePrompt } from "@/src/data/harness-prompts";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import grabSessionOpening from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
|
||||
import generateText from "@/src/harness/text/generate-text";
|
||||
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||
import parseSessionName from "./parse-session-name";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
@@ -16,7 +17,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Names a session once its first exchange is in: the bot writes the title
|
||||
* the rail and sessions list show and a short summary of it. The summary
|
||||
* the rail and sessions list show and a short summary of it, from the
|
||||
* session's first messages (however late the first reply came). The summary
|
||||
* says how many messages it covers (`summary_message_count`), so
|
||||
* `grabSessionCarryOver` rewrites it before carrying a closed session into
|
||||
* the next one. Already named sessions are left as they are.
|
||||
@@ -39,24 +41,24 @@ export default async function nameBotSession({
|
||||
if (!session?.id) return false;
|
||||
if (session.title?.trim() && session.summary?.trim()) return false;
|
||||
|
||||
const messages = await grabBotMessages({
|
||||
/**
|
||||
* The session's opening, not its latest messages: when the first
|
||||
* replies failed or were stopped, naming first runs well into the
|
||||
* session, and the title must still say what it was started for.
|
||||
*/
|
||||
const messages = await grabSessionOpening({
|
||||
session_id,
|
||||
limit: AppData.BotSessionNameMessages,
|
||||
});
|
||||
|
||||
if (
|
||||
!messages.some((message) => message.author == "user") ||
|
||||
!messages.some((message) => message.author == "bot")
|
||||
!messages.some((message) => message.author == "bot" && !message.carried_from_session_id)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const transcript = messages
|
||||
.map(
|
||||
(message) =>
|
||||
`${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
const transcript = sessionNameTranscript({ messages });
|
||||
|
||||
/**
|
||||
* The naming turn is a model call too, so it is recorded for this
|
||||
|
||||
@@ -226,8 +226,12 @@ export default async function processBotJob({
|
||||
* a model that can't answer now leaves the session for the next
|
||||
* reply to name.
|
||||
*/
|
||||
if (job.kind == "reply" && completion.message && job.session_id) {
|
||||
const session_id = Number(job.session_id);
|
||||
if (job.kind == "reply" && completion.message?.session_id) {
|
||||
/**
|
||||
* The message's session, not the job's as it was claimed: a
|
||||
* reply that moved the conversation on answered in the new one.
|
||||
*/
|
||||
const session_id = Number(completion.message.session_id);
|
||||
|
||||
/**
|
||||
* No job signal: the job is already complete, so its heartbeat
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
chatReplyNote,
|
||||
ComputerFilesNote,
|
||||
coordinatorReplyNote,
|
||||
longSessionNote,
|
||||
CoordinatorSoloNote,
|
||||
TasksNote,
|
||||
TaskReminderNote,
|
||||
@@ -30,6 +31,11 @@ import openBotSkills from "@/src/harness/tools/skills/open-bot-skills";
|
||||
import botMessagesToHarness from "./bot-messages-to-harness";
|
||||
import composeComputersNote from "./compose-computers-note";
|
||||
import grabSessionCarryOver from "./grab-session-carry-over";
|
||||
import continueInNewSessionTool from "@/src/harness/tools/bot/continue-in-new-session-tool";
|
||||
import searchPastSessionsTool from "@/src/harness/tools/bot/sessions/search-past-sessions-tool";
|
||||
import readPastSessionTool from "@/src/harness/tools/bot/sessions/read-past-session-tool";
|
||||
import { grabBotSessionMaxMessages } from "@/src/functions/backend/db/app-settings/grab-bot-session-max-messages";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import jobSessionId from "@/src/functions/backend/db/bots-queue/job-session-id";
|
||||
import loadAttachmentParts from "./load-attachment-parts";
|
||||
import grabMessageRefNames from "@/src/functions/backend/db/bots/grab-message-ref-names";
|
||||
@@ -76,7 +82,12 @@ export default async function runBotReply({
|
||||
signal,
|
||||
report,
|
||||
}: Params): Promise<BotJobOutcome> {
|
||||
const session_id = await jobSessionId({ job });
|
||||
/**
|
||||
* Moves when the bot carries the conversation into a new session
|
||||
* (`continue_in_new_session`): what the user writes from then on
|
||||
* lands there.
|
||||
*/
|
||||
let session_id = await jobSessionId({ job });
|
||||
const workspace_id = job.workspace_id || null;
|
||||
|
||||
const history = await grabBotMessages({
|
||||
@@ -140,6 +151,39 @@ export default async function runBotReply({
|
||||
signal,
|
||||
}).catch(() => "");
|
||||
|
||||
/**
|
||||
* From halfway to the session's length, the bot is told to move on
|
||||
* at a natural break.
|
||||
*/
|
||||
const session_messages =
|
||||
Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
|
||||
values: [session_id],
|
||||
})
|
||||
).payload?.[0]?.n,
|
||||
) || 0;
|
||||
const session_max = await grabBotSessionMaxMessages();
|
||||
const long_session_note =
|
||||
session_messages >= Math.ceil(session_max / 2)
|
||||
? longSessionNote({ messages: session_messages, max: session_max })
|
||||
: "";
|
||||
|
||||
/**
|
||||
* The conversation's earlier sessions are searchable when it has any:
|
||||
* the prompt only carries the last one's summary.
|
||||
*/
|
||||
const past_scope = { user_id, bot_id: bot.id!, workspace_id, current_session_id: session_id };
|
||||
const has_past_sessions = Boolean(
|
||||
(
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ? AND id != ? LIMIT 1`,
|
||||
values: [user_id, bot.id!, workspace_id ? Number(workspace_id) : null, session_id],
|
||||
})
|
||||
).payload?.[0],
|
||||
);
|
||||
|
||||
const take_overs: string[] = [];
|
||||
/**
|
||||
* The hooks the bot proposed, and the MCP servers, tools and skills the
|
||||
@@ -238,6 +282,7 @@ export default async function runBotReply({
|
||||
system: [
|
||||
prompt,
|
||||
carry_over,
|
||||
long_session_note,
|
||||
chatReplyNote({ bot_id: bot.id }),
|
||||
botFilesNote({ bot_id: bot.id }),
|
||||
ComputerFilesNote({ computer_id: computers[0]?.id || 0, dirs, can_import: false }),
|
||||
@@ -262,6 +307,16 @@ export default async function runBotReply({
|
||||
tools: [
|
||||
...tools,
|
||||
sendMessageTool({ bot_id: bot.id, session_id: Number(session_id), workspace_id }),
|
||||
continueInNewSessionTool({
|
||||
bot_id: bot.id!,
|
||||
user_id,
|
||||
session_id: Number(session_id),
|
||||
workspace_id,
|
||||
job_id: job.id || null,
|
||||
onMove: (moved) => {
|
||||
session_id = moved.session_id;
|
||||
},
|
||||
}),
|
||||
...grabBotSecretTools({
|
||||
user_id,
|
||||
bot_id: bot.id,
|
||||
@@ -271,6 +326,9 @@ export default async function runBotReply({
|
||||
onRequest: ({ ref }) => proposals.push(ref),
|
||||
onShow: ({ ref }) => shown.push(ref),
|
||||
}),
|
||||
...(has_past_sessions
|
||||
? [searchPastSessionsTool(past_scope), readPastSessionTool(past_scope)]
|
||||
: []),
|
||||
...file_tools,
|
||||
...extensions.tools,
|
||||
...skills.tools,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import sessionNameTranscript from "./session-name-transcript";
|
||||
|
||||
describe("sessionNameTranscript", () => {
|
||||
test("reads the exchange as the user's and the bot's", () => {
|
||||
expect(
|
||||
sessionNameTranscript({
|
||||
messages: [
|
||||
{ id: 1, author: "user", text: "Create a bot" },
|
||||
{ id: 2, author: "bot", text: "Done." },
|
||||
],
|
||||
}),
|
||||
).toBe("User: Create a bot\n\nYou: Done.");
|
||||
});
|
||||
|
||||
test("marks a carried summary as the earlier session's, not the bot's words", () => {
|
||||
const transcript = sessionNameTranscript({
|
||||
messages: [
|
||||
{ id: 1, author: "bot", text: "- Crew set up", carried_from_session_id: 9 },
|
||||
{ id: 2, author: "user", text: "Continue here" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(transcript).toStartWith("(Summary of the earlier session this one carries on from)\n- Crew set up");
|
||||
expect(transcript).not.toContain("You: - Crew set up");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { SessionOpeningMessage } from "@/src/functions/backend/db/bot-sessions/grab-session-opening";
|
||||
|
||||
type Params = {
|
||||
messages: SessionOpeningMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A session's opening as the naming turn reads it. The summary that opens
|
||||
* a session the bot moved the conversation into is marked as the earlier
|
||||
* session's, so the title says what this one carries on with rather than
|
||||
* echoing everything the summary recaps.
|
||||
*/
|
||||
export default function sessionNameTranscript({ messages }: Params) {
|
||||
return messages
|
||||
.map((message) =>
|
||||
message.carried_from_session_id
|
||||
? `(Summary of the earlier session this one carries on from)\n${message.text || ""}`
|
||||
: `${message.author == "user" ? "User" : "You"}: ${message.text || "(files only)"}`,
|
||||
)
|
||||
.join("\n\n");
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import ContinueSessionButton from "./continue-session-button";
|
||||
import {
|
||||
useEffect,
|
||||
useState,
|
||||
@@ -93,21 +94,27 @@ export default function BotSessionsModal({
|
||||
Sessions
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`Each session keeps ${name}'s context short. A new one starts with a summary of the last; reopen any to pick up where you left off.`}
|
||||
{`Each session keeps ${name}'s context short. New starts fresh; Continue carries a summary of this one into the next. Reopen any to pick up where you left off.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Button
|
||||
title="Start a new session"
|
||||
type="button"
|
||||
size="small"
|
||||
loading={sessions.busy_id == "new"}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onClick={sessions.startSession}
|
||||
className="shrink-0 gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New
|
||||
</Button>
|
||||
<Row className="shrink-0 gap-2! flex-nowrap items-center">
|
||||
<ContinueSessionButton
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace_id}
|
||||
/>
|
||||
<Button
|
||||
title="Start a fresh session, without carrying this one over"
|
||||
type="button"
|
||||
size="small"
|
||||
loading={sessions.busy_id == "new"}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onClick={sessions.startSession}
|
||||
className="shrink-0 gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New
|
||||
</Button>
|
||||
</Row>
|
||||
</Row>
|
||||
<Input
|
||||
id="bot-sessions-search"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquareShare } from "lucide-react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import postSessionApi from "@/src/functions/frontend/post-session-api";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Renders the trigger instead of the default button (the conversation
|
||||
* menu's row), given whether it's working and the click.
|
||||
*/
|
||||
render?: (params: { loading: boolean; onClick: () => void }) => React.ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* Continue in new session: carries the conversation into a new session
|
||||
* with a summary of this one, as the bot does when asked
|
||||
* (`api/admin/bots/continue-session`). Unlike New session, which starts
|
||||
* fresh. Asks first, since this session then stays closed for good. It
|
||||
* stays on screen (a menu stays open) while the summary is written, which
|
||||
* takes a model call.
|
||||
*/
|
||||
export default function ContinueSessionButton({ bot_id, workspace_id, render }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const start = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm("Continue in a new session? A summary of this session carries over, and this one is closed for good (you can still read it).")) return;
|
||||
|
||||
setLoading(true);
|
||||
|
||||
const res = await postSessionApi({
|
||||
path: "continue-session",
|
||||
body: { bot_id, workspace_id },
|
||||
}).catch(() => null);
|
||||
|
||||
if (res?.success) {
|
||||
window.location.assign(conversationHref({ bot_id, workspace_id }));
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
window.alert(res?.msg || "Couldn't continue in a new session.");
|
||||
};
|
||||
|
||||
if (render) return <>{render({ loading, onClick: start })}</>;
|
||||
|
||||
return (
|
||||
<Button
|
||||
title="Continue in a new session, with a summary of this one"
|
||||
type="button"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={start}
|
||||
className="shrink-0 gap-1!"
|
||||
>
|
||||
<MessageSquareShare size={14} aria-hidden />
|
||||
Continue
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -438,8 +438,14 @@ export const AppData = {
|
||||
/**
|
||||
* A session with this many messages is closed when the user writes
|
||||
* again, and the message starts the next one (carrying a summary).
|
||||
* The install may save its own number (the `bot_session_max_messages`
|
||||
* setting, Settings > General), read through `grabBotSessionMaxMessages`.
|
||||
*/
|
||||
BotSessionMaxMessages: 120,
|
||||
/**
|
||||
* The most messages a saved session length may ask for.
|
||||
*/
|
||||
BotSessionMaxMessagesMax: 2000,
|
||||
/**
|
||||
* How much of a closed session the bot reads to summarize it, and how
|
||||
* long the summary may be.
|
||||
@@ -456,6 +462,18 @@ export const AppData = {
|
||||
* Sessions listed per page in a conversation's sessions modal.
|
||||
*/
|
||||
BotSessionsPageSize: 30,
|
||||
/**
|
||||
* What a bot's search of its earlier sessions in a conversation
|
||||
* (`search_past_sessions`) returns at most, and how much of each
|
||||
* message it shows around the match.
|
||||
*/
|
||||
PastSessionSearchLimit: 20,
|
||||
PastSessionSnippetChars: 300,
|
||||
/**
|
||||
* Messages `read_past_session` returns per call, and how much of each.
|
||||
*/
|
||||
PastSessionReadMessages: 20,
|
||||
PastSessionReadMessageChars: 2000,
|
||||
/**
|
||||
* Worker bots the rail lists, most recently active first, before
|
||||
* "See all bots".
|
||||
|
||||
@@ -258,9 +258,20 @@ update_memory and update_instructions save to this workspace by default: what yo
|
||||
export const SessionNamePrompt = `Name and summarize this conversation from its first exchange. Answer with a JSON object only, no markdown fence and no other text:
|
||||
{"title": "...", "summary": "..."}
|
||||
|
||||
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation.
|
||||
- title: what the conversation is about, at most 60 characters, no quotes and no trailing period. Say what the user wanted, not that it is a conversation. When it opens with a summary of an earlier session, name what this one carries on with, not every topic the summary recaps.
|
||||
- summary: what the user wanted and what was done so far. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
|
||||
|
||||
/**
|
||||
* Asks the text model to carry a conversation into a new session when the
|
||||
* user asks to (Continue in new session): what the bot itself writes with
|
||||
* `continue_in_new_session`.
|
||||
*/
|
||||
export const SessionContinuePrompt = `This conversation is moving into a new session: only what you write now carries over, so whoever picks it up must be able to go on from it alone. Answer with a JSON object only, no markdown fence and no other text:
|
||||
{"title": "...", "summary": "..."}
|
||||
|
||||
- title: what the new session carries on with, at most 60 characters, no quotes and no trailing period.
|
||||
- summary: what the user wants, decisions made, what's done, what's in progress or still open, and every name, number, path, link and ref ([bot:ID], [file:ID], ...) exactly. Plain markdown bullets, no preamble.`;
|
||||
|
||||
/**
|
||||
* Asks the text model for a closed session's summary, carried into the
|
||||
* next session's prompt. Used when the session's own summary is stale
|
||||
@@ -268,9 +279,18 @@ export const SessionNamePrompt = `Name and summarize this conversation from its
|
||||
*/
|
||||
export const SessionSummaryPrompt = `Summarize this conversation for whoever picks it up next: what the user wanted, what was done, decisions made, open questions and anything still in progress. Keep names, numbers, paths and links exactly. Plain markdown bullets, no preamble.`;
|
||||
|
||||
/**
|
||||
* Told to a reply once its session is long: offer to move on.
|
||||
*/
|
||||
export function longSessionNote({ messages, max }: { messages: number; max: number }) {
|
||||
return `# Long session
|
||||
|
||||
This session has ${messages} messages (a new one starts on its own after ${max}). Every reply re-reads the recent ones, so each costs more as it grows. When the current topic reaches a natural break, or the user moves on to something new, carry the conversation into a fresh session with continue_in_new_session and say so in your answer.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Heads the previous session's summary in a bot's prompt.
|
||||
*/
|
||||
export const PreviousSessionNote = `# Earlier in this conversation
|
||||
|
||||
This session carries on from an earlier one. What happened there:`;
|
||||
This session carries on from an earlier one. Only this summary of it is in front of you; when you need more (an exact figure, a link, what was agreed), look it up with search_past_sessions and read_past_session instead of guessing. What happened there:`;
|
||||
|
||||
@@ -116,6 +116,13 @@ export const BotSystemPromptAppendSettingKey = "bot_system_prompt_append";
|
||||
*/
|
||||
export const BotReplyMaxToolStepsSettingKey = "bot_reply_max_tool_steps";
|
||||
|
||||
/**
|
||||
* The `app_settings` key holding how many messages a session takes before
|
||||
* the next user message starts a new one. Unset means
|
||||
* `AppData.BotSessionMaxMessages`.
|
||||
*/
|
||||
export const BotSessionMaxMessagesSettingKey = "bot_session_max_messages";
|
||||
|
||||
/**
|
||||
* A bot's face is its status. There are no badges or spinners.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { BotSessionMaxMessagesSettingKey } from "@/src/dict/bot-dict";
|
||||
import type { SessionLengthOverview } from "@/src/types";
|
||||
import readStepLimit from "@/src/utils/read-step-limit";
|
||||
import grabAppSetting from "./grab-app-setting";
|
||||
|
||||
/**
|
||||
* How many messages a session takes before the user's next message starts
|
||||
* a new one: the install's own number (`bot_session_max_messages`,
|
||||
* Settings > General), else `AppData.BotSessionMaxMessages`.
|
||||
*/
|
||||
export async function grabBotSessionMaxMessages() {
|
||||
return readStepLimit({
|
||||
saved: await grabAppSetting({ key: BotSessionMaxMessagesSettingKey }),
|
||||
fallback: AppData.BotSessionMaxMessages,
|
||||
max: AppData.BotSessionMaxMessagesMax,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The number with its default and ceiling, for the settings page.
|
||||
*/
|
||||
export default async function grabSessionLengthOverview() {
|
||||
const overview: SessionLengthOverview = {
|
||||
max_messages: await grabBotSessionMaxMessages(),
|
||||
default_messages: AppData.BotSessionMaxMessages,
|
||||
max: AppData.BotSessionMaxMessagesMax,
|
||||
};
|
||||
|
||||
return overview;
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import insertBotMessage from "../bots/insert-bot-message";
|
||||
import createBotSession from "./create-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* The session the bot is answering in: it must be the conversation's
|
||||
* active one.
|
||||
*/
|
||||
session_id: number | string;
|
||||
user_id: number | string;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* What the next session needs to carry on (markdown), written by the
|
||||
* bot.
|
||||
*/
|
||||
summary: string;
|
||||
/**
|
||||
* The new session's title, written by the bot. Left out: the closed
|
||||
* session's.
|
||||
*/
|
||||
title?: string | null;
|
||||
/**
|
||||
* The reply moving it: its answer goes in the new session, under the
|
||||
* summary.
|
||||
*/
|
||||
job_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Moves a conversation into a fresh session when the bot asks to
|
||||
* (`continue_in_new_session`): the bot's summary is kept on the session it
|
||||
* closes (covering all its messages, so `grabSessionCarryOver` hands it to
|
||||
* the next prompt as written) and opens the new one as a bot message
|
||||
* (`carried_from_session_id`), and the reply's own answer follows it
|
||||
* there. The new session is titled by the bot (else after the old one).
|
||||
* Returns that first message, for the caller to push.
|
||||
*/
|
||||
export default async function carryOverBotSession({
|
||||
session_id,
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
summary,
|
||||
title,
|
||||
job_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
const text = summary.trim().slice(0, AppData.BotSessionSummaryMaxChars);
|
||||
|
||||
if (!text) throw new AppError(`The summary is empty.`);
|
||||
|
||||
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
|
||||
|
||||
if (!active?.id || active.id != Number(session_id)) {
|
||||
throw new AppError(`This session isn't the conversation's current one, so it can't be moved on from.`);
|
||||
}
|
||||
|
||||
const message_count =
|
||||
Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
|
||||
values: [active.id],
|
||||
})
|
||||
).payload?.[0]?.n,
|
||||
) || 0;
|
||||
|
||||
if (!message_count) {
|
||||
throw new AppError(`This session has no messages yet: it's already fresh.`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, summary = ?, summary_message_count = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
values: [text, message_count, now, active.id],
|
||||
});
|
||||
|
||||
/**
|
||||
* A session is named after its first exchange (`nameBotSession`), and
|
||||
* this one opens with the bot's messages alone: it's named now, and
|
||||
* previewed with the summary it opens with. The summary covers none
|
||||
* of its own messages, so it's written again before it's carried on.
|
||||
*/
|
||||
const next = await createBotSession({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id: active.id,
|
||||
title:
|
||||
title?.replace(/\s+/g, " ").trim().slice(0, AppData.BotSessionTitleMaxChars) ||
|
||||
active.title ||
|
||||
null,
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET summary = ?, summary_message_count = 0 WHERE id = ?`,
|
||||
values: [text, next.id!],
|
||||
});
|
||||
|
||||
const message = await insertBotMessage({
|
||||
bot_id,
|
||||
session_id: Number(next.id),
|
||||
workspace_id,
|
||||
text,
|
||||
carried_from_session_id: active.id,
|
||||
});
|
||||
|
||||
if (job_id) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bots_queue SET session_id = ? WHERE id = ?`,
|
||||
values: [next.id!, job_id],
|
||||
});
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { SessionContinuePrompt } from "@/src/data/harness-prompts";
|
||||
import parseSessionName from "@/src/bots-queue/parse-session-name";
|
||||
import sessionNameTranscript from "@/src/bots-queue/session-name-transcript";
|
||||
import generateText from "@/src/harness/text/generate-text";
|
||||
import { runWithHarnessUsageScope } from "@/src/harness/usage/harness-usage-scope";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import carryOverBotSession from "./carry-over-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
import hasOpenSessionJob from "./has-open-session-job";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Continue in new session, asked by the user from the conversation's
|
||||
* menu (and the automatic rollover, `rollOverBotSession`): does what the
|
||||
* bot does with `continue_in_new_session`. The bot's
|
||||
* text model writes the new session's title and the summary from the
|
||||
* session's latest messages, then `carryOverBotSession` closes this
|
||||
* session for good and opens the new one with the summary. Refused while
|
||||
* the bot is still working in the session, since its answer would land in
|
||||
* the closed one. Returns the summary's message, for the caller to push.
|
||||
*/
|
||||
export default async function continueBotSession({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
const active = await grabActiveBotSession({ user_id, bot_id, workspace_id });
|
||||
|
||||
if (!active?.id) throw new AppError(`This conversation has no session to continue from yet.`);
|
||||
|
||||
if (await hasOpenSessionJob({ session_id: active.id })) {
|
||||
throw new AppError(`The bot is still working in this session. Wait for it to finish, or stop it, then continue.`);
|
||||
}
|
||||
|
||||
const messages = await grabBotMessages({
|
||||
session_id: active.id,
|
||||
limit: AppData.BotSessionSummaryMessages,
|
||||
});
|
||||
|
||||
if (!messages.length) throw new AppError(`This session has no messages yet: it's already fresh.`);
|
||||
|
||||
const result = await runWithHarnessUsageScope({
|
||||
scope: {
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id: workspace_id || null,
|
||||
session_id: active.id,
|
||||
job_id: null,
|
||||
message_id: null,
|
||||
},
|
||||
task: () =>
|
||||
generateText({
|
||||
messages: [
|
||||
{ role: "system", content: SessionContinuePrompt },
|
||||
{ role: "user", content: sessionNameTranscript({ messages }) },
|
||||
],
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}),
|
||||
});
|
||||
|
||||
const written = parseSessionName({ text: result.text });
|
||||
|
||||
if (!written?.summary) {
|
||||
throw new AppError(`Couldn't write the summary to carry over. Try again.`);
|
||||
}
|
||||
|
||||
return await carryOverBotSession({
|
||||
session_id: active.id,
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
summary: written.summary,
|
||||
title: written.title,
|
||||
});
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { grabBotSessionMaxMessages } from "../app-settings/grab-bot-session-max-messages";
|
||||
import createBotSession from "./create-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
import startBotSession from "./start-bot-session";
|
||||
import rollOverBotSession from "./roll-over-bot-session";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
@@ -13,8 +13,8 @@ type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Start the next session when the active one is full
|
||||
* (`AppData.BotSessionMaxMessages`). Set when the user writes, so a
|
||||
* Carry the conversation into a new session when the active one is
|
||||
* full (`grabBotSessionMaxMessages`, `rollOverBotSession`). Set when the user writes, so a
|
||||
* message never lands in a session that's about to be cut.
|
||||
*/
|
||||
rollover?: boolean;
|
||||
@@ -22,8 +22,8 @@ type Params = {
|
||||
|
||||
/**
|
||||
* The user's conversation's active session, started first when there is none
|
||||
* (carrying on from its latest closed one) or, with `rollover`, when the
|
||||
* active one is full.
|
||||
* (fresh) or, with `rollover`, carried into a new one when the active one
|
||||
* is full.
|
||||
*/
|
||||
export default async function ensureBotSession({
|
||||
user_id,
|
||||
@@ -46,23 +46,15 @@ export default async function ensureBotSession({
|
||||
).payload?.[0]?.n,
|
||||
) || 0;
|
||||
|
||||
return count < AppData.BotSessionMaxMessages
|
||||
return count < (await grabBotSessionMaxMessages())
|
||||
? active
|
||||
: await startBotSession({ user_id, bot_id, workspace_id });
|
||||
: await rollOverBotSession({
|
||||
user_id,
|
||||
bot_id: Number(bot_id),
|
||||
workspace_id: workspace_id ? Number(workspace_id) : null,
|
||||
active,
|
||||
});
|
||||
}
|
||||
|
||||
const latest = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT id FROM bot_sessions WHERE user_id = ? AND bot_id = ? AND workspace_id IS ?
|
||||
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
|
||||
values: [user_id, bot_id, workspace_id ? Number(workspace_id) : null],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return await createBotSession({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id: latest?.id || null,
|
||||
});
|
||||
return await createBotSession({ user_id, bot_id, workspace_id });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* The session a closed session of the user's was carried into
|
||||
* (`carryOverBotSession`), or null when it wasn't carried over.
|
||||
*/
|
||||
export default async function grabCarriedToSessionId({ session_id, user_id }: Params): Promise<number | null> {
|
||||
const carried = (
|
||||
await BunSQLite.sql<{ session_id: number }>({
|
||||
sql: `SELECT m.session_id FROM bot_messages m JOIN bot_sessions s ON s.id = m.session_id
|
||||
WHERE m.carried_from_session_id = ? AND s.user_id = ? ORDER BY m.id DESC LIMIT 1`,
|
||||
values: [session_id, user_id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return carried?.session_id ? Number(carried.session_id) : null;
|
||||
}
|
||||
@@ -3,6 +3,9 @@ import type { ConversationHistory } from "@/src/types";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
import grabCarriedToSessionId from "./grab-carried-to-session-id";
|
||||
import reopenBotSession from "./reopen-bot-session";
|
||||
import grabUserBotSession from "./grab-user-bot-session";
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
@@ -13,12 +16,20 @@ type Params = {
|
||||
* The workspace the conversation is in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* A session of the conversation to show instead of the active one
|
||||
* (the page's `?session_id=`). One that isn't the user's, or is in
|
||||
* another conversation, is ignored.
|
||||
*/
|
||||
session_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's history, which the conversation pages load on the
|
||||
* client once they render (`api/admin/bots/conversation-history`): the
|
||||
* bot's active session there (marked read), its latest
|
||||
* bot's active session there, or the one asked for (marked read; a closed
|
||||
* one is reopened, unless it was carried over, which only shows, with
|
||||
* where the conversation went on), its latest
|
||||
* `AppData.ConversationFirstMessages` messages (the thread loads older
|
||||
* ones as it scrolls) and the bot's active task in it. Everything else on
|
||||
* the page comes with it from the server (`grabConversationProps`).
|
||||
@@ -28,6 +39,7 @@ export default async function grabConversationHistory({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
session_id,
|
||||
}: Params): Promise<ConversationHistory | null> {
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||
() => null,
|
||||
@@ -36,11 +48,37 @@ export default async function grabConversationHistory({
|
||||
if (!conversation?.bot?.id) return null;
|
||||
|
||||
const open_bot_id = conversation.bot.id;
|
||||
const session = await ensureBotSession({
|
||||
user_id,
|
||||
bot_id: open_bot_id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
});
|
||||
const conversation_workspace_id = conversation.workspace_id || null;
|
||||
|
||||
const asked = session_id
|
||||
? await grabUserBotSession({ session_id, user_id })
|
||||
: null;
|
||||
|
||||
const in_conversation =
|
||||
asked?.id &&
|
||||
Number(asked.bot_id) == Number(open_bot_id) &&
|
||||
(asked.workspace_id ? Number(asked.workspace_id) : null) ==
|
||||
(conversation_workspace_id ? Number(conversation_workspace_id) : null);
|
||||
|
||||
/**
|
||||
* A closed session asked for is reopened, as clicking it in a list
|
||||
* does, unless it was carried over into a newer one: that one only
|
||||
* shows, with the way to where the conversation went on.
|
||||
*/
|
||||
const carried_to =
|
||||
in_conversation && asked.status != "active"
|
||||
? await grabCarriedToSessionId({ session_id: asked.id!, user_id })
|
||||
: null;
|
||||
|
||||
const session = !in_conversation
|
||||
? await ensureBotSession({
|
||||
user_id,
|
||||
bot_id: open_bot_id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
})
|
||||
: asked.status == "active" || carried_to
|
||||
? asked
|
||||
: await reopenBotSession({ session_id: asked.id!, user_id });
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
@@ -63,5 +101,6 @@ export default async function grabConversationHistory({
|
||||
messages: has_more ? messages.slice(1) : messages,
|
||||
has_more,
|
||||
task,
|
||||
moved_to_session_id: carried_to,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES, BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import type { PastSessionScope } from "./past-session-messages-sql";
|
||||
|
||||
type Params = PastSessionScope & {
|
||||
/**
|
||||
* Read around this message (and in its session).
|
||||
*/
|
||||
message_id?: number | null;
|
||||
/**
|
||||
* Read this session from its start, or from after `after_id`. Left
|
||||
* out (with no `message_id`): the session the current one carries on
|
||||
* from.
|
||||
*/
|
||||
session_id?: number | null;
|
||||
after_id?: number | null;
|
||||
/**
|
||||
* Messages to return (around a message: about half before it).
|
||||
*/
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type PastSessionRead = {
|
||||
session: Pick<BUN_SQLITE_COBBLE_BOT_SESSIONS, "id" | "title" | "previous_session_id">;
|
||||
messages: Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "id" | "author" | "text" | "created_at">[];
|
||||
/**
|
||||
* More of the session comes after the last message returned.
|
||||
*/
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reads messages of one of the conversation's earlier sessions, oldest
|
||||
* first: around a message, or a session in order. A session outside the
|
||||
* conversation (another user's, another bot's, another place's) or the
|
||||
* one being answered is never read.
|
||||
*/
|
||||
export default async function grabPastSessionMessages({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
current_session_id,
|
||||
message_id,
|
||||
session_id,
|
||||
after_id,
|
||||
limit,
|
||||
}: Params): Promise<PastSessionRead> {
|
||||
const target_session_id = message_id
|
||||
? (
|
||||
await BunSQLite.sql<{ session_id: number }>({
|
||||
sql: `SELECT session_id FROM bot_messages WHERE id = ? LIMIT 1`,
|
||||
values: [message_id],
|
||||
})
|
||||
).payload?.[0]?.session_id
|
||||
: session_id ||
|
||||
(
|
||||
await BunSQLite.sql<{ previous_session_id: number }>({
|
||||
sql: `SELECT previous_session_id FROM bot_sessions WHERE id = ? LIMIT 1`,
|
||||
values: [current_session_id],
|
||||
})
|
||||
).payload?.[0]?.previous_session_id;
|
||||
|
||||
if (!target_session_id) {
|
||||
throw new AppError(
|
||||
message_id
|
||||
? `Message #${message_id} wasn't found.`
|
||||
: `This session doesn't carry on from an earlier one. Name a session, or search with search_past_sessions.`,
|
||||
);
|
||||
}
|
||||
|
||||
const session = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT id, title, previous_session_id FROM bot_sessions
|
||||
WHERE id = ? AND user_id = ? AND bot_id = ? AND workspace_id IS ? AND id != ? LIMIT 1`,
|
||||
values: [
|
||||
target_session_id,
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id ? Number(workspace_id) : null,
|
||||
current_session_id,
|
||||
],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!session?.id) {
|
||||
throw new AppError(`That isn't an earlier session of this conversation.`);
|
||||
}
|
||||
|
||||
const columns = `id, author, text, created_at`;
|
||||
let messages: PastSessionRead["messages"];
|
||||
let has_more: boolean;
|
||||
|
||||
if (message_id) {
|
||||
const before = Math.floor(limit / 2);
|
||||
const earlier =
|
||||
(
|
||||
await BunSQLite.sql<PastSessionRead["messages"][number]>({
|
||||
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id < ? ORDER BY id DESC LIMIT ?`,
|
||||
values: [session.id, message_id, before],
|
||||
})
|
||||
).payload || [];
|
||||
const later =
|
||||
(
|
||||
await BunSQLite.sql<PastSessionRead["messages"][number]>({
|
||||
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id >= ? ORDER BY id ASC LIMIT ?`,
|
||||
values: [session.id, message_id, limit - before + 1],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
has_more = later.length > limit - before;
|
||||
messages = [...earlier.reverse(), ...later.slice(0, limit - before)];
|
||||
} else {
|
||||
const rows =
|
||||
(
|
||||
await BunSQLite.sql<PastSessionRead["messages"][number]>({
|
||||
sql: `SELECT ${columns} FROM bot_messages WHERE session_id = ? AND id > ? ORDER BY id ASC LIMIT ?`,
|
||||
values: [session.id, after_id || 0, limit + 1],
|
||||
})
|
||||
).payload || [];
|
||||
|
||||
has_more = rows.length > limit;
|
||||
messages = rows.slice(0, limit);
|
||||
}
|
||||
|
||||
return { session, messages, has_more };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
limit: number;
|
||||
};
|
||||
|
||||
export type SessionOpeningMessage = Pick<
|
||||
BUN_SQLITE_COBBLE_BOT_MESSAGES,
|
||||
"id" | "author" | "text" | "carried_from_session_id"
|
||||
>;
|
||||
|
||||
/**
|
||||
* A session's first messages, oldest first: what it was started for,
|
||||
* however long it has gone on since. (`grabBotMessages` reads the latest.)
|
||||
*/
|
||||
export default async function grabSessionOpening({ session_id, limit }: Params): Promise<SessionOpeningMessage[]> {
|
||||
return (
|
||||
(
|
||||
await BunSQLite.sql<SessionOpeningMessage>({
|
||||
sql: `SELECT id, author, text, carried_from_session_id FROM bot_messages
|
||||
WHERE session_id = ? ORDER BY created_at ASC, id ASC LIMIT ?`,
|
||||
values: [session_id, limit],
|
||||
})
|
||||
).payload || []
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether the bot still has work queued or running in a session, whose
|
||||
* answer lands there: a session isn't carried over meanwhile.
|
||||
*/
|
||||
export default async function hasOpenSessionJob({ session_id }: Params) {
|
||||
return Boolean(
|
||||
(
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM bots_queue WHERE session_id = ? AND status IN ('pending', 'processing') LIMIT 1`,
|
||||
values: [session_id],
|
||||
})
|
||||
).payload?.[0],
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Database } from "bun:sqlite";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import searchPastSessionsSql, { type SearchPastSessionsParams } from "./past-session-messages-sql";
|
||||
|
||||
/**
|
||||
* A bot's search of its earlier sessions reads only that user's
|
||||
* conversation with it (in the same place), never the session it's
|
||||
* answering. Run against real SQLite.
|
||||
*/
|
||||
const db = new Database(":memory:");
|
||||
|
||||
db.run(`CREATE TABLE bot_sessions (id INTEGER PRIMARY KEY, user_id INTEGER, bot_id INTEGER, workspace_id INTEGER, title TEXT)`);
|
||||
db.run(`CREATE TABLE bot_messages (id INTEGER PRIMARY KEY, session_id INTEGER, author TEXT, text TEXT, created_at INTEGER)`);
|
||||
db.run(`INSERT INTO bot_sessions VALUES
|
||||
(1, 1, 10, NULL, 'Invoices'),
|
||||
(2, 1, 10, NULL, 'Current'),
|
||||
(3, 2, 10, NULL, 'Another user'),
|
||||
(4, 1, 11, NULL, 'Another bot'),
|
||||
(5, 1, 10, 7, 'In a workspace')`);
|
||||
db.run(`INSERT INTO bot_messages (id, session_id, author, text) VALUES
|
||||
(1, 1, 'user', 'Send the Acme invoice to [email protected]'),
|
||||
(2, 1, 'bot', 'Done: invoice INV-42 sent to Acme.'),
|
||||
(3, 2, 'user', 'What happened with the Acme invoice?'),
|
||||
(4, 3, 'user', 'Acme invoice for someone else'),
|
||||
(5, 4, 'user', 'Acme invoice to another bot'),
|
||||
(6, 5, 'user', 'Acme invoice in the workspace'),
|
||||
(7, 1, 'user', 'Discount of 100% off'),
|
||||
(8, 1, 'user', 'Discount of 10 off')`);
|
||||
|
||||
function ids(params: Partial<SearchPastSessionsParams>) {
|
||||
const { sql, values } = searchPastSessionsSql({
|
||||
user_id: 1,
|
||||
bot_id: 10,
|
||||
current_session_id: 2,
|
||||
query: "",
|
||||
...params,
|
||||
});
|
||||
|
||||
return (db.query(sql).all(...values) as { id: number }[]).map((row) => row.id);
|
||||
}
|
||||
|
||||
describe("searchPastSessionsSql", () => {
|
||||
test("finds only this conversation's earlier sessions, newest first", () => {
|
||||
expect(ids({ query: "acme invoice" })).toEqual([2, 1]);
|
||||
});
|
||||
|
||||
test("keeps the workspace's conversation apart from the bot's own chat", () => {
|
||||
expect(ids({ query: "acme", workspace_id: 7, current_session_id: 99 })).toEqual([6]);
|
||||
});
|
||||
|
||||
test("every word must match", () => {
|
||||
expect(ids({ query: "invoice INV-42" })).toEqual([2]);
|
||||
});
|
||||
|
||||
test("% matches only itself", () => {
|
||||
expect(ids({ query: "100%" })).toEqual([7]);
|
||||
});
|
||||
|
||||
test("pages with before_id", () => {
|
||||
expect(ids({ query: "acme", before_id: 2 })).toEqual([1]);
|
||||
});
|
||||
|
||||
test("cuts the text around the match", () => {
|
||||
const { sql, values } = searchPastSessionsSql({
|
||||
user_id: 1,
|
||||
bot_id: 10,
|
||||
current_session_id: 2,
|
||||
query: "billing",
|
||||
snippet_chars: 12,
|
||||
});
|
||||
const row = db.query(sql).get(...values) as { snippet: string; session_title: string };
|
||||
|
||||
expect(row.snippet).toContain("billing");
|
||||
expect(row.snippet.length).toBeLessThanOrEqual(12);
|
||||
expect(row.session_title).toBe("Invoices");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -11,7 +11,8 @@ type Params = {
|
||||
/**
|
||||
* Makes a closed session of one of the user's conversations the active
|
||||
* one again, so the bot carries on from where it left off. The session
|
||||
* that was active is closed (an empty one is deleted).
|
||||
* that was active is closed (an empty one is deleted). A session the bot
|
||||
* moved the conversation on from is never reopened.
|
||||
*/
|
||||
export default async function reopenBotSession({
|
||||
session_id,
|
||||
@@ -25,6 +26,22 @@ export default async function reopenBotSession({
|
||||
|
||||
if (session.status == "active") return session;
|
||||
|
||||
/**
|
||||
* The bot moved the conversation on from it (`continue_in_new_session`):
|
||||
* reopened, the conversation would go on without what was said since,
|
||||
* and the user's last word there was already answered in the new one.
|
||||
*/
|
||||
const carried_on = (
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM bot_messages WHERE carried_from_session_id = ? LIMIT 1`,
|
||||
values: [session.id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (carried_on) {
|
||||
throw new AppError(`This conversation moved on to a newer session, so this one stays closed. You can still read it.`);
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const workspace_id = session.workspace_id || null;
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import { logError } from "@/src/functions/backend/logger";
|
||||
import carryOverBotSession from "./carry-over-bot-session";
|
||||
import continueBotSession from "./continue-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
import hasOpenSessionJob from "./has-open-session-job";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The conversation's active session, now full.
|
||||
*/
|
||||
active: BUN_SQLITE_COBBLE_BOT_SESSIONS;
|
||||
};
|
||||
|
||||
/**
|
||||
* The automatic rollover at the session length, when the user writes: the
|
||||
* conversation is carried into a new session the same way the bot or
|
||||
* Continue in new session carry it (`continueBotSession`: a written title
|
||||
* and summary, the full session closed for good). A model that can't
|
||||
* write the summary now falls back to the summary the session already
|
||||
* has. The full session is kept (and the rollover tried again on the
|
||||
* next message) while the bot still works in it, or when there's nothing
|
||||
* to carry. Returns the session the user's message goes in.
|
||||
*/
|
||||
export default async function rollOverBotSession({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
active,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||
if (await hasOpenSessionJob({ session_id: active.id! })) return active;
|
||||
|
||||
const message = await continueBotSession({ user_id, bot_id, workspace_id }).catch(async (error: unknown) => {
|
||||
await logError({ title: `Session #${active.id} rollover summary not written`, error }).catch(() => {});
|
||||
|
||||
if (!active.summary?.trim()) return null;
|
||||
|
||||
return await carryOverBotSession({
|
||||
session_id: active.id!,
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
summary: active.summary,
|
||||
title: active.title,
|
||||
}).catch(() => null);
|
||||
});
|
||||
|
||||
if (!message) return active;
|
||||
|
||||
await pushBotNotice({ bot_id, message, quiet_push: true });
|
||||
|
||||
return (await grabActiveBotSession({ user_id, bot_id, workspace_id })) || active;
|
||||
}
|
||||
@@ -13,10 +13,11 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the conversation's active session and starts the next one, which
|
||||
* carries on from it (its summary goes into the next reply's prompt,
|
||||
* rewritten first when it covers fewer messages than the session ended
|
||||
* with). An active session with no messages yet is kept as it is.
|
||||
* New session: closes the conversation's active session and starts a
|
||||
* fresh one, linked to nothing and carrying nothing over. Carrying a
|
||||
* conversation on is `carryOverBotSession` (the bot, Continue in new
|
||||
* session, the rollover). An active session with no messages yet is kept
|
||||
* as it is.
|
||||
*/
|
||||
export default async function startBotSession({
|
||||
user_id,
|
||||
@@ -41,10 +42,5 @@ export default async function startBotSession({
|
||||
});
|
||||
}
|
||||
|
||||
return await createBotSession({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id: active?.id || null,
|
||||
});
|
||||
return await createBotSession({ user_id, bot_id, workspace_id });
|
||||
}
|
||||
|
||||
@@ -28,6 +28,11 @@ type Params = {
|
||||
* message in later turns (`botMessagesToHarness`).
|
||||
*/
|
||||
tool_log?: HarnessToolUse[];
|
||||
/**
|
||||
* Opens a session the conversation moved into
|
||||
* (`carryOverBotSession`): the session it carries on from.
|
||||
*/
|
||||
carried_from_session_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -43,6 +48,7 @@ export default async function insertBotMessage({
|
||||
interim,
|
||||
media_ids = [],
|
||||
tool_log,
|
||||
carried_from_session_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
|
||||
const now = Date.now();
|
||||
const needs_you_flag = needs_you ? 1 : 0;
|
||||
@@ -64,6 +70,7 @@ export default async function insertBotMessage({
|
||||
needs_you: needs_you_flag,
|
||||
interim: interim_flag,
|
||||
tool_log: tool_log_json,
|
||||
carried_from_session_id,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -96,6 +103,7 @@ export default async function insertBotMessage({
|
||||
needs_you: needs_you_flag,
|
||||
interim: interim_flag,
|
||||
tool_log: tool_log_json,
|
||||
carried_from_session_id,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ type Params = {
|
||||
*/
|
||||
path: | "list-sessions"
|
||||
| "new-session"
|
||||
| "continue-session"
|
||||
| "reopen-session"
|
||||
| "session-messages"
|
||||
| "older-messages";
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import botMessagesToHarness from "@/src/bots-queue/bot-messages-to-harness";
|
||||
import continueInNewSessionTool from "./continue-in-new-session-tool";
|
||||
|
||||
const signal = new AbortController().signal;
|
||||
|
||||
function tool() {
|
||||
const summaries: string[] = [];
|
||||
const titles: (string | undefined)[] = [];
|
||||
const moved_to: number[] = [];
|
||||
const move = continueInNewSessionTool({
|
||||
bot_id: 2,
|
||||
user_id: 1,
|
||||
session_id: 100,
|
||||
move: async ({ summary, title }) => {
|
||||
summaries.push(summary);
|
||||
titles.push(title);
|
||||
return { session_id: 101 };
|
||||
},
|
||||
onMove: ({ session_id }) => moved_to.push(session_id),
|
||||
});
|
||||
|
||||
return { move, summaries, titles, moved_to };
|
||||
}
|
||||
|
||||
describe("continueInNewSessionTool", () => {
|
||||
test("moves with the bot's summary and hands the run the new session", async () => {
|
||||
const { move, summaries, moved_to } = tool();
|
||||
|
||||
const result = await move.run({ args: { summary: "- Wants the Q3 report as a PDF" }, signal });
|
||||
|
||||
expect(summaries).toEqual(["- Wants the Q3 report as a PDF"]);
|
||||
expect(moved_to).toEqual([101]);
|
||||
expect(result).toContain("new session");
|
||||
});
|
||||
|
||||
test("passes the new session's title on", async () => {
|
||||
const { move, titles } = tool();
|
||||
|
||||
await move.run({ args: { summary: "- Q3 report", title: "Q3 report PDF" }, signal });
|
||||
|
||||
expect(titles).toEqual(["Q3 report PDF"]);
|
||||
});
|
||||
|
||||
test("moves once per reply", async () => {
|
||||
const { move, summaries } = tool();
|
||||
|
||||
await move.run({ args: { summary: "- First" }, signal });
|
||||
|
||||
await expect(move.run({ args: { summary: "- Again" }, signal })).rejects.toThrow("already moved");
|
||||
expect(summaries).toEqual(["- First"]);
|
||||
});
|
||||
|
||||
test("the summary opening a session never reaches the model as a turn (the prompt carries it)", () => {
|
||||
const turns = botMessagesToHarness({
|
||||
messages: [
|
||||
{ id: 1, author: "bot", text: "- Wants the Q3 report as a PDF" },
|
||||
{ id: 2, author: "bot", text: "Fresh session, carrying on." },
|
||||
{ id: 3, author: "user", text: "Great, go on." },
|
||||
],
|
||||
});
|
||||
|
||||
expect(turns).toEqual([{ role: "user", content: "Great, go on." }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
import { z } from "zod";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import carryOverBotSession from "@/src/functions/backend/db/bot-sessions/carry-over-bot-session";
|
||||
import type { HarnessTool } from "../../types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
user_id: number | string;
|
||||
/**
|
||||
* The session being answered: the one moved on from.
|
||||
*/
|
||||
session_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The reply's job, whose answer follows into the new session.
|
||||
*/
|
||||
job_id?: number | null;
|
||||
/**
|
||||
* Called with the new session, so the rest of the run reads and
|
||||
* writes there.
|
||||
*/
|
||||
onMove?: (params: { session_id: number }) => void;
|
||||
/**
|
||||
* Moves the conversation and pushes the summary
|
||||
* (`carryOverBotSession`, `pushBotNotice`), returning the summary's
|
||||
* message.
|
||||
*/
|
||||
move?: (params: { summary: string; title?: string }) => Promise<Pick<BUN_SQLITE_COBBLE_BOT_MESSAGES, "session_id">>;
|
||||
};
|
||||
|
||||
type Args = {
|
||||
summary: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply move the conversation into a fresh session, so later replies
|
||||
* stop re-reading a long one: the bot writes what matters, which closes
|
||||
* this session and opens the next (`carryOverBotSession`). Once per run.
|
||||
*/
|
||||
export default function continueInNewSessionTool({
|
||||
bot_id,
|
||||
user_id,
|
||||
session_id,
|
||||
workspace_id,
|
||||
job_id,
|
||||
onMove,
|
||||
move = async ({ summary, title }) => {
|
||||
const message = await carryOverBotSession({ session_id, user_id, bot_id, workspace_id, summary, title, job_id });
|
||||
|
||||
await pushBotNotice({ bot_id, message, quiet_push: true });
|
||||
|
||||
return message;
|
||||
},
|
||||
}: Params): HarnessTool<Args> {
|
||||
let moved = false;
|
||||
|
||||
return {
|
||||
name: "continue_in_new_session",
|
||||
description: `Move this conversation into a fresh session, carrying over a summary you write. Every reply re-reads the session's recent messages, so a long one costs more each time. Always use it when the user asks to carry on in a new, fresh or different session ("let's continue this in another session", "start a new chat for this"): don't just agree, move. Also use it when the session has grown long and the current topic is at a natural break (say so in your answer). The summary opens the new session, shown to the user and given to you from then on in place of these messages, so put in everything needed to carry on: what the user wants, decisions made, what's done, what's in progress or open, and every name, number, path, link and ref ([bot:ID], [file:ID], ...) exactly. Call it last: your answer then goes in the new session under the summary, so keep it to a line.`,
|
||||
schema: z.object({
|
||||
summary: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(AppData.BotSessionSummaryMaxChars)
|
||||
.describe(`What the next session needs (markdown bullets, no preamble)`),
|
||||
title: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(AppData.BotSessionTitleMaxChars)
|
||||
.optional()
|
||||
.describe(`A few words naming what the new session carries on with, shown in the session lists`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
if (moved) {
|
||||
throw new Error(`The conversation already moved to a new session in this reply.`);
|
||||
}
|
||||
|
||||
const message = await move({ summary: args.summary, title: args.title });
|
||||
|
||||
moved = true;
|
||||
onMove?.({ session_id: Number(message.session_id) });
|
||||
|
||||
return `Moved: the new session opens with your summary. Finish with a one-line answer; it goes in the new session.`;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
type Params = {
|
||||
id?: number | "";
|
||||
author?: string;
|
||||
text: string;
|
||||
created_at?: number | "";
|
||||
};
|
||||
|
||||
/**
|
||||
* One message of an earlier session, as a bot reads it: its id (to read
|
||||
* around it or reply to it), when, who, and the text.
|
||||
*/
|
||||
export default function describePastMessage({ id, author, text, created_at }: Params) {
|
||||
const when = created_at ? new Date(Number(created_at)).toUTCString() : "";
|
||||
|
||||
return `#${id} ${when ? `(${when}) ` : ""}${author == "user" ? "User" : "You"}: ${text.trim() || "(files only)"}`;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { z } from "zod";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import grabPastSessionMessages from "@/src/functions/backend/db/bot-sessions/grab-past-session-messages";
|
||||
import type { PastSessionScope } from "@/src/functions/backend/db/bot-sessions/past-session-messages-sql";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import describePastMessage from "./describe-past-message";
|
||||
|
||||
type Args = {
|
||||
message_id?: number;
|
||||
session_id?: number;
|
||||
after_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply read an earlier session of the conversation word for word:
|
||||
* around a message found with `search_past_sessions`, or a session in
|
||||
* order (the one this session carries on from by default).
|
||||
*/
|
||||
export default function readPastSessionTool(scope: PastSessionScope): HarnessTool<Args> {
|
||||
return {
|
||||
name: "read_past_session",
|
||||
description: `Read messages of an earlier session with this user here, oldest first. With message_id: the messages around it. Otherwise a session in order from its start (session_id, or the session this one carries on from), after_id for the next page. Read only what you need: it is long.`,
|
||||
schema: z.object({
|
||||
message_id: z.number().int().positive().optional(),
|
||||
session_id: z.number().int().positive().optional(),
|
||||
after_id: z.number().int().positive().optional(),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const read = await grabPastSessionMessages({
|
||||
...scope,
|
||||
message_id: args.message_id,
|
||||
session_id: args.session_id,
|
||||
after_id: args.after_id,
|
||||
limit: AppData.PastSessionReadMessages,
|
||||
});
|
||||
|
||||
const head = `Session #${read.session.id}${read.session.title ? ` "${read.session.title}"` : ""}${read.session.previous_session_id ? `, carrying on from session #${read.session.previous_session_id}` : ""}:`;
|
||||
|
||||
if (!read.messages.length) return `${head}\n(no messages${args.after_id ? " after that" : ""})`;
|
||||
|
||||
const lines = read.messages.map((message) =>
|
||||
describePastMessage({
|
||||
...message,
|
||||
text: (message.text || "").slice(0, AppData.PastSessionReadMessageChars),
|
||||
}),
|
||||
);
|
||||
const last = read.messages[read.messages.length - 1];
|
||||
|
||||
return [
|
||||
head,
|
||||
...lines,
|
||||
read.has_more ? `\nMore follows: read again with session_id ${read.session.id} and after_id ${last?.id}.` : "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { z } from "zod";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import searchPastSessionsSql, { type PastSessionScope } from "@/src/functions/backend/db/bot-sessions/past-session-messages-sql";
|
||||
import type { HarnessTool } from "../../../types";
|
||||
import describePastMessage from "./describe-past-message";
|
||||
|
||||
type Args = {
|
||||
query: string;
|
||||
before_id?: number;
|
||||
};
|
||||
|
||||
type Match = {
|
||||
id: number;
|
||||
session_id: number;
|
||||
author: string;
|
||||
created_at: number;
|
||||
session_title: string | null;
|
||||
snippet: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Lets a reply search what was said in the conversation's earlier
|
||||
* sessions (this user, this bot, this place), which its prompt only has
|
||||
* as a summary.
|
||||
*/
|
||||
export default function searchPastSessionsTool(scope: PastSessionScope): HarnessTool<Args> {
|
||||
return {
|
||||
name: "search_past_sessions",
|
||||
description: `Search the messages of your earlier sessions with this user here, newest first, when you need more detail than the summary of what came before (an exact figure, a link, what was agreed). Every word in query must be in the message. Each match shows its message id and session; read_past_session reads around it.`,
|
||||
schema: z.object({
|
||||
query: z.string().trim().min(1).max(200).describe(`Words to find`),
|
||||
before_id: z.number().int().positive().optional().describe(`Only messages older than this id, for the next page`),
|
||||
}),
|
||||
run: async ({ args }) => {
|
||||
const { sql, values } = searchPastSessionsSql({ ...scope, query: args.query, before_id: args.before_id });
|
||||
const matches = (await BunSQLite.sql<Match>({ sql, values })).payload || [];
|
||||
|
||||
if (!matches.length) return `No messages of earlier sessions match.`;
|
||||
|
||||
const list = matches
|
||||
.map(
|
||||
(match) =>
|
||||
`${describePastMessage({ ...match, text: `...${match.snippet || ""}...` })} [session #${match.session_id}${match.session_title ? ` "${match.session_title}"` : ""}]`,
|
||||
)
|
||||
.join("\n");
|
||||
const last = matches[matches.length - 1];
|
||||
|
||||
return matches.length >= AppData.PastSessionSearchLimit
|
||||
? `${list}\n\nMore may be older: search again with before_id ${last?.id}.`
|
||||
: list;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BotSessionChangedData, BotSessionListItem } from "@/src/types";
|
||||
import postSessionApi from "@/src/functions/frontend/post-session-api";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
import useServerEvent from "./use-server-event";
|
||||
|
||||
type Params = {
|
||||
@@ -44,6 +45,8 @@ function belongsToConversation({
|
||||
/**
|
||||
* A conversation's sessions, latest first, searched as the user types
|
||||
* (debounced). Starting or reopening a session reloads the page onto it.
|
||||
* A session that can't be reopened (carried over into a newer one) opens
|
||||
* to read instead.
|
||||
* New messages and session changes pushed live reload the list, so a
|
||||
* session started in the conversation (or named once its first reply is
|
||||
* in) shows without a reload.
|
||||
@@ -118,8 +121,21 @@ export default function useBotSessions({ bot_id, workspace_id, open, limit }: Pa
|
||||
|
||||
const res = await postSessionApi({ path, body }).catch(() => null);
|
||||
|
||||
/**
|
||||
* The plain address, not a reload: one showing a session to read
|
||||
* (`?session_id=`) would stay on it.
|
||||
*/
|
||||
if (res?.success) {
|
||||
window.location.reload();
|
||||
window.location.assign(conversationHref({ bot_id, workspace_id }));
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* Carried over into a newer session, it stays closed: it opens to
|
||||
* read, with the way to where the conversation went on.
|
||||
*/
|
||||
if (res && path == "reopen-session") {
|
||||
window.location.assign(conversationHref({ bot_id, workspace_id, session_id: id }));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotConversationRequest } from "@/src/schemas/bot-schema";
|
||||
import type { ConversationHistoryRequest } from "@/src/schemas/bot-schema";
|
||||
import type { ConversationHistoryPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* A conversation's history, loaded once the page is on screen.
|
||||
* A conversation's history, loaded once the page is on screen: its active
|
||||
* session, or the one `session_id` names.
|
||||
*/
|
||||
export default async function fetchConversationHistory({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: BotConversationRequest) {
|
||||
session_id,
|
||||
}: ConversationHistoryRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
ConversationHistoryRequest,
|
||||
APIResponseObject<ConversationHistoryPayload>
|
||||
>(`/api/admin/bots/conversation-history`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
body: { bot_id, workspace_id, session_id },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ type Params = {
|
||||
* Loads the open conversation's history once the page is on screen, and
|
||||
* again whenever the browser restores the page from its back-forward
|
||||
* cache (`pageshow` with `persisted`), so going back never shows an old
|
||||
* thread. Everything else on the page (the header, the composer) comes
|
||||
* thread. The page's `?session_id=` picks a session other than the
|
||||
* active one, read on every load. Everything else on the page (the header, the composer) comes
|
||||
* from the server and shows at once. `loaded` turns true with the first
|
||||
* load; a conversation the user can no longer open sends them where the
|
||||
* server says.
|
||||
@@ -37,7 +38,10 @@ export default function useConversationHistory({ bot_id, workspace_id, onLoaded
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await fetchConversationHistory({ bot_id, workspace_id }).catch(() => null);
|
||||
const session_id =
|
||||
Number(new URLSearchParams(window.location.search).get("session_id")) || null;
|
||||
|
||||
const res = await fetchConversationHistory({ bot_id, workspace_id, session_id }).catch(() => null);
|
||||
|
||||
/** A newer load started meanwhile: it has the last word. */
|
||||
if (id != request.current) return;
|
||||
|
||||
@@ -20,9 +20,12 @@ type Params = {
|
||||
|
||||
/**
|
||||
* Loads the session's older messages a page at a time, when the thread
|
||||
* is scrolled to its top. A session the thread moves onto starts with
|
||||
* its first message, so it has none older; `reset` starts over from a
|
||||
* loaded history instead.
|
||||
* is scrolled to its top. Once the session's first message is in, the
|
||||
* session it carries on from can be opened above it (`loadPrevious`, on
|
||||
* the user's ask) and paged the same way, and so on back. Those messages
|
||||
* are only shown: the bot's prompt still reads the session it answers in.
|
||||
* A session the thread moves onto starts with its first message, so it
|
||||
* has none older; `reset` starts over from a loaded history instead.
|
||||
*/
|
||||
export default function useOlderMessages({
|
||||
session_id,
|
||||
@@ -31,6 +34,11 @@ export default function useOlderMessages({
|
||||
onLoaded,
|
||||
}: Params) {
|
||||
const [has_more, setHasMore] = useState(initial_has_more);
|
||||
/**
|
||||
* The session the thread's top belongs to, and the one before it.
|
||||
*/
|
||||
const [paging_session_id, setPagingSessionId] = useState<number | null>(session_id || null);
|
||||
const [previous_session_id, setPreviousSessionId] = useState<number | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const base_session = useRef(session_id);
|
||||
@@ -40,30 +48,41 @@ export default function useOlderMessages({
|
||||
|
||||
base_session.current = session_id;
|
||||
setHasMore(false);
|
||||
setPagingSessionId(session_id || null);
|
||||
setPreviousSessionId(null);
|
||||
setError(undefined);
|
||||
}, [session_id]);
|
||||
|
||||
/**
|
||||
* A history loaded for the session: whether it has older messages.
|
||||
* A history loaded for the session: whether it has older messages,
|
||||
* and the session it carries on from.
|
||||
*/
|
||||
const reset = useCallback(
|
||||
({ session_id, has_more }: { session_id: number; has_more: boolean }) => {
|
||||
({
|
||||
session_id,
|
||||
has_more,
|
||||
previous_session_id,
|
||||
}: {
|
||||
session_id: number;
|
||||
has_more: boolean;
|
||||
previous_session_id?: number | null;
|
||||
}) => {
|
||||
base_session.current = session_id;
|
||||
setHasMore(has_more);
|
||||
setPagingSessionId(session_id);
|
||||
setPreviousSessionId(previous_session_id || null);
|
||||
setError(undefined);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (loading || !has_more || !session_id || !first_message_id) return;
|
||||
|
||||
const load = async ({ session_id, before_id }: { session_id: number; before_id?: number | null }) => {
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postSessionApi<OlderBotMessagesData>({
|
||||
path: "older-messages",
|
||||
body: { session_id, before_id: first_message_id },
|
||||
body: { session_id, before_id },
|
||||
}).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
@@ -75,7 +94,29 @@ export default function useOlderMessages({
|
||||
|
||||
onLoaded({ messages: res.singleRes.messages });
|
||||
setHasMore(res.singleRes.has_more);
|
||||
setPagingSessionId(res.singleRes.session.id);
|
||||
setPreviousSessionId(res.singleRes.session.previous_session_id);
|
||||
};
|
||||
|
||||
return { has_more, loading, error, loadOlder, reset };
|
||||
const loadOlder = async () => {
|
||||
if (loading || !has_more || !paging_session_id || !first_message_id) return;
|
||||
|
||||
await load({ session_id: paging_session_id, before_id: first_message_id });
|
||||
};
|
||||
|
||||
const loadPrevious = async () => {
|
||||
if (loading || has_more || !previous_session_id) return;
|
||||
|
||||
await load({ session_id: previous_session_id });
|
||||
};
|
||||
|
||||
return {
|
||||
has_more,
|
||||
has_previous: !has_more && Boolean(previous_session_id),
|
||||
loading,
|
||||
error,
|
||||
loadOlder,
|
||||
loadPrevious,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { BotMessageActionsContext } from "../(data)/message-actions-context";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
import { QuotableAttribute } from "../(hooks)/use-message-selection";
|
||||
import CarriedOverChip from "./carried-over-chip";
|
||||
import GroupTimestamp from "./group-timestamp";
|
||||
import MessageActions from "./message-actions";
|
||||
import MessageReactions from "./message-reactions";
|
||||
@@ -34,7 +35,8 @@ type Props = {
|
||||
|
||||
/**
|
||||
* One bot message, rendered from markdown (GFM; raw HTML is never
|
||||
* rendered). Computers it names (`computerRef`) show as buttons that
|
||||
* rendered). The summary opening a session the bot moved into is
|
||||
* headed by a link to the session it carries on from. Computers it names (`computerRef`) show as buttons that
|
||||
* switch the bot to them. A quoted reply sits above it, its reactions
|
||||
* below, and a row under it replies, reacts, reads it aloud and shows
|
||||
* why it needs you. Its text can be selected and quoted.
|
||||
@@ -48,6 +50,13 @@ function BotMessageLine({ message, bot_name, at }: Props) {
|
||||
|
||||
return (
|
||||
<Stack className="w-full min-w-0 gap-1.5! items-start max-w-full">
|
||||
{message.carried_from_session_id && message.bot_id ? (
|
||||
<CarriedOverChip
|
||||
bot_id={message.bot_id}
|
||||
workspace_id={message.workspace_id}
|
||||
session_id={message.carried_from_session_id}
|
||||
/>
|
||||
) : null}
|
||||
{message.reply_to ? (
|
||||
<MessageReplyQuote
|
||||
reply_to={message.reply_to}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { History } from "lucide-react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
|
||||
type Props = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The session the summary carries on from.
|
||||
*/
|
||||
session_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Heads the summary that opens a session the bot moved the conversation
|
||||
* into (`continue_in_new_session`), and opens the session it carries on
|
||||
* from to read. The thread's "Show the previous session" shows the same
|
||||
* messages in place; this is the other way there, on purpose.
|
||||
*/
|
||||
export default function CarriedOverChip({ bot_id, workspace_id, session_id }: Props) {
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="smaller"
|
||||
title="Open the session this one carries on from"
|
||||
href={conversationHref({ bot_id, workspace_id, session_id })}
|
||||
beforeIcon={<History size={14} aria-hidden className="shrink-0" />}
|
||||
className="inline-flex h-6 gap-1.5! flex-nowrap align-middle px-2! text-[11px] font-bold whitespace-nowrap text-ink dark:text-ink bg-transparent hover:bg-sunken"
|
||||
>
|
||||
Carried over from the last session
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -2,10 +2,16 @@ import { useContext } from "react";
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import { FolderOpen, History, Settings } from "lucide-react";
|
||||
import {
|
||||
FolderOpen,
|
||||
History,
|
||||
MessageSquareShare,
|
||||
Settings,
|
||||
} from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import BotSessionsModal from "@/src/components/general/bot-sessions-modal";
|
||||
import ContinueSessionButton from "@/src/components/general/continue-session-button";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
@@ -58,7 +64,8 @@ type Props = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation header's menu: the sessions, a new session, copying
|
||||
* The conversation header's menu: the sessions, a new (fresh) session,
|
||||
* continuing in a new session with a summary of this one, copying
|
||||
* the session, its files, tasks, routines and hooks, the bot's computer, its models, in a workspace its
|
||||
* instructions there and making it the admin there, its
|
||||
* settings and, while it works, stopping it. A
|
||||
@@ -84,7 +91,9 @@ export default function ConversationHeaderMenu({
|
||||
/**
|
||||
* Hooks start work, so a read-only user doesn't get them.
|
||||
*/
|
||||
const can_make_hooks = !isReadOnlyUser({ user_types: pageProps?.user_types });
|
||||
const can_make_hooks = !isReadOnlyUser({
|
||||
user_types: pageProps?.user_types,
|
||||
});
|
||||
/**
|
||||
* A bot's instructions for one workspace, only there, for crew
|
||||
* managers (the coordinator's too: only the super admin sees it).
|
||||
@@ -98,8 +107,8 @@ export default function ConversationHeaderMenu({
|
||||
*/
|
||||
const can_set_admin = Boolean(
|
||||
workspace?.id &&
|
||||
bot.kind != "coordinator" &&
|
||||
canManageCrew({ user_types: pageProps?.user_types }),
|
||||
bot.kind != "coordinator" &&
|
||||
canManageCrew({ user_types: pageProps?.user_types }),
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -133,6 +142,19 @@ export default function ConversationHeaderMenu({
|
||||
workspace_id={workspace?.id || null}
|
||||
on_open={on_open}
|
||||
/>
|
||||
<ContinueSessionButton
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
render={({ loading, onClick }) => (
|
||||
<HeaderMenuItem
|
||||
icon={MessageSquareShare}
|
||||
title="Continue in a new session"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={onClick}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
<CopyChatMenuItem
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace?.id || null}
|
||||
@@ -157,7 +179,11 @@ export default function ConversationHeaderMenu({
|
||||
on_open={on_open}
|
||||
/>
|
||||
{can_make_hooks ? (
|
||||
<BotHooksModal bot={bot} workspace={workspace} on_open={on_open} />
|
||||
<BotHooksModal
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
on_open={on_open}
|
||||
/>
|
||||
) : null}
|
||||
<BotComputersModal
|
||||
menu
|
||||
|
||||
@@ -2,16 +2,31 @@ import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessageGroup from "./user-message-group";
|
||||
import BotMessageGroup from "./bot-message-group";
|
||||
import SessionDivider from "./session-divider";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* One author's group, under a divider when it starts a later session than
|
||||
* the messages above it.
|
||||
*/
|
||||
export default function MessageGroupBlock({ bot, group }: Props) {
|
||||
if (group.author === "user") {
|
||||
return <UserMessageGroup bot={bot} group={group} />;
|
||||
}
|
||||
const block =
|
||||
group.author === "user" ? (
|
||||
<UserMessageGroup bot={bot} group={group} />
|
||||
) : (
|
||||
<BotMessageGroup bot={bot} group={group} />
|
||||
);
|
||||
|
||||
return <BotMessageGroup bot={bot} group={group} />;
|
||||
if (!group.starts_session) return block;
|
||||
|
||||
return (
|
||||
<>
|
||||
<SessionDivider />
|
||||
{block}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ type Props = {
|
||||
* This session has an active task.
|
||||
*/
|
||||
has_task?: boolean;
|
||||
/**
|
||||
* The conversation went on in another session: no reply is due here.
|
||||
*/
|
||||
moved_on?: boolean;
|
||||
/**
|
||||
* Quotes text selected in a message in the composer.
|
||||
*/
|
||||
@@ -42,9 +46,15 @@ type Props = {
|
||||
*/
|
||||
older?: {
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The thread reaches the start of its oldest session, which carries
|
||||
* on from another one that can be opened above it.
|
||||
*/
|
||||
has_previous?: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadOlder: () => void;
|
||||
loadPrevious?: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
@@ -53,7 +63,9 @@ type Props = {
|
||||
* upward. Empty space belongs at the top. New messages announce politely.
|
||||
* While the bot works on a reply or a task, a status row sits under the
|
||||
* last one. Text selected in a message can be quoted in the composer.
|
||||
* Older messages load as it scrolls to the top; the reversed column keeps
|
||||
* Older messages load as it scrolls to the top, and at the start of a
|
||||
* session that carries on from another, the earlier one can be opened
|
||||
* above it (shown only, never put in the bot's prompt); the reversed column keeps
|
||||
* the view where it was as they land above it. The thread follows new
|
||||
* messages while it sits at its bottom, stays put while it's scrolled up
|
||||
* and then offers a button to get back down.
|
||||
@@ -65,6 +77,7 @@ export default function MessageThread({
|
||||
reply_progress,
|
||||
receiving,
|
||||
has_task,
|
||||
moved_on,
|
||||
onQuote,
|
||||
older,
|
||||
}: Props) {
|
||||
@@ -92,6 +105,14 @@ export default function MessageThread({
|
||||
error={older.error}
|
||||
onLoad={older.loadOlder}
|
||||
/>
|
||||
) : older?.has_previous && older.loadPrevious ? (
|
||||
<OlderMessagesLoader
|
||||
loading={older.loading}
|
||||
error={older.error}
|
||||
onLoad={older.loadPrevious}
|
||||
label="Show the previous session"
|
||||
manual
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock
|
||||
@@ -104,10 +125,13 @@ export default function MessageThread({
|
||||
bot={bot}
|
||||
progress={progress}
|
||||
reply_progress={reply_progress}
|
||||
awaiting_reply={isAwaitingReply({
|
||||
last_message: messages[messages.length - 1],
|
||||
face: bot.face,
|
||||
})}
|
||||
awaiting_reply={
|
||||
!moved_on &&
|
||||
isAwaitingReply({
|
||||
last_message: messages[messages.length - 1],
|
||||
face: bot.face,
|
||||
})
|
||||
}
|
||||
receiving={receiving}
|
||||
has_task={has_task}
|
||||
/>
|
||||
|
||||
@@ -7,13 +7,28 @@ type Props = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onLoad: () => void;
|
||||
/**
|
||||
* Defaults to "Show earlier messages".
|
||||
*/
|
||||
label?: string;
|
||||
/**
|
||||
* Loads only on a click, never as it scrolls into view.
|
||||
*/
|
||||
manual?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The top of a thread with older messages: loads them as it scrolls into
|
||||
* view, with a button for when it can't (or a load failed).
|
||||
* view (unless `manual`), with a button for when it can't (or a load
|
||||
* failed).
|
||||
*/
|
||||
export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
export default function OlderMessagesLoader({
|
||||
loading,
|
||||
error,
|
||||
onLoad,
|
||||
label = "Show earlier messages",
|
||||
manual,
|
||||
}: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const load = useRef(onLoad);
|
||||
|
||||
@@ -22,7 +37,7 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || error || typeof IntersectionObserver == "undefined") return;
|
||||
if (!el || error || manual || typeof IntersectionObserver == "undefined") return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
@@ -34,7 +49,7 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [error, loading]);
|
||||
}, [error, loading, manual]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
@@ -45,14 +60,14 @@ export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
<Loading size="smaller" />
|
||||
) : (
|
||||
<Button
|
||||
title="Show earlier messages"
|
||||
title={label}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onLoad}
|
||||
className="text-[13px]! leading-[18px]! text-ink-secondary"
|
||||
>
|
||||
{error ? `${error} Try again` : "Show earlier messages"}
|
||||
{error ? `${error} Try again` : label}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import Divider from "@moduletrace/twui/layout/Divider";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
|
||||
/**
|
||||
* Between an earlier session opened above the thread and the session
|
||||
* that carries on from it. What's above is shown only: the bot doesn't
|
||||
* read it unless it looks it up.
|
||||
*/
|
||||
export default function SessionDivider() {
|
||||
return (
|
||||
<Row className="w-full flex-nowrap gap-4! items-center">
|
||||
<Divider className="border-hairline dark:border-hairline" />
|
||||
<Span className="shrink-0 text-[13px] leading-[18px] text-ink-secondary">
|
||||
New session
|
||||
</Span>
|
||||
<Divider className="border-hairline dark:border-hairline" />
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { ArrowRight } from "lucide-react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import conversationHref from "@/src/utils/conversation-href";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* The session the one on screen was carried into.
|
||||
*/
|
||||
moved_to_session_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Takes the composer's place under a session that was carried over into
|
||||
* a newer one (by the bot, Continue in new session or the rollover): it
|
||||
* stays closed, to read, and points to where the conversation went on.
|
||||
*/
|
||||
export default function SessionMovedBar({ bot_id, workspace_id, moved_to_session_id }: Props) {
|
||||
return (
|
||||
<Stack className="shrink-0 gap-0! items-stretch border-0 border-t border-solid border-hairline">
|
||||
<Row className="w-full max-w-container mx-auto min-h-[88px] py-7 gap-3! md:gap-4! flex-nowrap items-center justify-between px-3 md:px-6">
|
||||
<Span className="text-sm text-ink-secondary">
|
||||
This conversation moved to a new session. This one stays as it was, to read.
|
||||
</Span>
|
||||
<Button
|
||||
title="Continue in the newer session"
|
||||
type="button"
|
||||
size="small"
|
||||
href={conversationHref({ bot_id, workspace_id, session_id: moved_to_session_id })}
|
||||
afterIcon={<ArrowRight size={14} aria-hidden />}
|
||||
variant="outlined"
|
||||
className="shrink-0"
|
||||
>
|
||||
Continue there
|
||||
</Button>
|
||||
</Row>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -38,6 +38,8 @@ import HistoryErrorRow from "../(partials)/history-error-row";
|
||||
import useConversationHistory from "../(hooks)/use-conversation-history";
|
||||
import mergeLoadedHistory from "../(utils)/merge-loaded-history";
|
||||
import LoadingLine from "@/src/components/general/loading-line";
|
||||
import SessionMovedBar from "../(partials)/session-moved-bar";
|
||||
import syncSessionQuery from "../(utils)/sync-session-query";
|
||||
|
||||
/**
|
||||
* The conversation column: header, bottom-anchored thread, composer, the
|
||||
@@ -52,7 +54,9 @@ import LoadingLine from "@/src/components/general/loading-line";
|
||||
* the history (the session's latest messages and the bot's task there)
|
||||
* loads on the client with a loading line under the header, and again
|
||||
* when the back button restores the page. Older messages load as the
|
||||
* thread scrolls up.
|
||||
* thread scrolls up. `?session_id=` opens another session of the
|
||||
* conversation; a closed one shows, in the composer's place, a link to
|
||||
* where the conversation went on.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -73,6 +77,12 @@ export default function Conversation() {
|
||||
BotMessageWithAttachments[]
|
||||
>([]);
|
||||
const [session_id, setSessionId] = useState<number | null>(null);
|
||||
/**
|
||||
* Set when the session shown was carried over into a newer one
|
||||
* (opened by `?session_id=`, or carried on from while on screen): that
|
||||
* session, which the composer gives way to a link to.
|
||||
*/
|
||||
const [moved_to_session_id, setMovedToSessionId] = useState<number | null>(null);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
|
||||
null,
|
||||
@@ -85,6 +95,12 @@ export default function Conversation() {
|
||||
* before the history landed stays.
|
||||
*/
|
||||
const known_session = useRef<number | null>(null);
|
||||
/**
|
||||
* Set when the user's own message carried the conversation into a new
|
||||
* session (the rollover at the session length): the thread follows
|
||||
* it, and what's on its way goes there, so it stays.
|
||||
*/
|
||||
const following_send = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const moved = Boolean(known_session.current);
|
||||
@@ -93,6 +109,12 @@ export default function Conversation() {
|
||||
|
||||
if (!moved) return;
|
||||
|
||||
if (following_send.current) {
|
||||
following_send.current = false;
|
||||
setReplyTo(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setReplyTo(null);
|
||||
setPendingMessages([]);
|
||||
}, [session_id]);
|
||||
@@ -148,8 +170,14 @@ export default function Conversation() {
|
||||
const loaded_session_id = Number(history.session.id);
|
||||
|
||||
setSessionId(loaded_session_id);
|
||||
setMovedToSessionId(history.moved_to_session_id || null);
|
||||
syncSessionQuery({ session: history.session });
|
||||
setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
|
||||
older.reset({ session_id: loaded_session_id, has_more: history.has_more });
|
||||
older.reset({
|
||||
session_id: loaded_session_id,
|
||||
has_more: history.has_more,
|
||||
previous_session_id: Number(history.session.previous_session_id) || null,
|
||||
});
|
||||
progress.reset({ task: history.task });
|
||||
},
|
||||
});
|
||||
@@ -161,7 +189,13 @@ export default function Conversation() {
|
||||
|
||||
const last_saved_id = messages[messages.length - 1]?.id || null;
|
||||
const last_thread_message = thread_messages[thread_messages.length - 1];
|
||||
const awaiting_reply = isAwaitingReply({ last_message: last_thread_message, face: bot?.face });
|
||||
/**
|
||||
* A session moved on from gets no reply here: the bot answered in the
|
||||
* new one.
|
||||
*/
|
||||
const awaiting_reply =
|
||||
!moved_to_session_id &&
|
||||
isAwaitingReply({ last_message: last_thread_message, face: bot?.face });
|
||||
|
||||
const reply_progress = useReplyProgress({
|
||||
bot_id: bot?.id || 0,
|
||||
@@ -208,9 +242,34 @@ export default function Conversation() {
|
||||
message.session_id &&
|
||||
message.session_id != session_id
|
||||
) {
|
||||
/**
|
||||
* This session's conversation was carried into a new one,
|
||||
* whose summary opens it. While the user's message is on
|
||||
* its way, it was that message (the rollover at the
|
||||
* session length): the thread follows into the new
|
||||
* session. Otherwise (the bot, Continue in new session in
|
||||
* another tab) the composer gives way to a link there.
|
||||
*/
|
||||
if (Number(message.carried_from_session_id) == session_id) {
|
||||
if (pending_messages.length) {
|
||||
following_send.current = true;
|
||||
history.retry();
|
||||
return;
|
||||
}
|
||||
|
||||
setMovedToSessionId(Number(message.session_id));
|
||||
return;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user wrote in a newer session (another tab, or a
|
||||
* rollover whose summary push was missed): the thread
|
||||
* moves there, and loads it so its opening shows too.
|
||||
*/
|
||||
if (message.author == "user" && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
history.retry();
|
||||
} else if (message.author == "user") {
|
||||
window.location.reload();
|
||||
} else {
|
||||
@@ -341,11 +400,19 @@ export default function Conversation() {
|
||||
reply_progress={reply_progress}
|
||||
receiving={pending_messages.length > 0}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
moved_on={Boolean(moved_to_session_id)}
|
||||
older={older}
|
||||
onQuote={({ text }) =>
|
||||
composer.current?.insertQuote({ text })
|
||||
}
|
||||
/>
|
||||
{moved_to_session_id ? (
|
||||
<SessionMovedBar
|
||||
bot_id={Number(bot.id)}
|
||||
workspace_id={workspace_id}
|
||||
moved_to_session_id={moved_to_session_id}
|
||||
/>
|
||||
) : (
|
||||
<Composer
|
||||
handle_ref={composer}
|
||||
bot={bot}
|
||||
@@ -381,10 +448,12 @@ export default function Conversation() {
|
||||
startsNewSession(message) &&
|
||||
message.session_id != session_id
|
||||
) {
|
||||
following_send.current = true;
|
||||
setSessionId(
|
||||
Number(message.session_id),
|
||||
);
|
||||
setMessages([message]);
|
||||
history.retry();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -395,6 +464,7 @@ export default function Conversation() {
|
||||
);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</BotMessageActionsContext.Provider>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
|
||||
@@ -32,4 +32,19 @@ describe("groupMessages", () => {
|
||||
|
||||
expect(groups).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("never joins two sessions, and marks where the later one starts", () => {
|
||||
const groups = groupMessages({
|
||||
messages: [
|
||||
{ id: 1, session_id: 7, author: "bot", text: "a", created_at: 0 },
|
||||
{ id: 2, session_id: 8, author: "bot", text: "summary", created_at: MIN },
|
||||
{ id: 3, session_id: 8, author: "bot", text: "b", created_at: 2 * MIN },
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups.map((g) => [g.messages.length, g.starts_session])).toEqual([
|
||||
[1, undefined],
|
||||
[2, true],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,6 +8,11 @@ export type MessageGroup = {
|
||||
* Time of the group's last message, shown once per group.
|
||||
*/
|
||||
at: number;
|
||||
/**
|
||||
* The first group of a later session than the one above it (an
|
||||
* earlier session opened above the thread): a divider goes above it.
|
||||
*/
|
||||
starts_session?: boolean;
|
||||
};
|
||||
|
||||
type Params = {
|
||||
@@ -19,7 +24,8 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Splits a conversation (oldest first) into author groups.
|
||||
* Splits a conversation (oldest first) into author groups. A group never
|
||||
* spans two sessions.
|
||||
*/
|
||||
export default function groupMessages({
|
||||
messages,
|
||||
@@ -32,7 +38,12 @@ export default function groupMessages({
|
||||
const at = Number(message.created_at || 0);
|
||||
const last = groups[groups.length - 1];
|
||||
|
||||
if (last && last.author === author && at - last.at <= gap_ms) {
|
||||
const last_session = last?.messages[last.messages.length - 1]?.session_id;
|
||||
const new_session = Boolean(
|
||||
last && message.session_id && last_session && message.session_id != last_session,
|
||||
);
|
||||
|
||||
if (last && !new_session && last.author === author && at - last.at <= gap_ms) {
|
||||
last.messages.push(message);
|
||||
last.at = at;
|
||||
continue;
|
||||
@@ -43,6 +54,7 @@ export default function groupMessages({
|
||||
author,
|
||||
messages: [message],
|
||||
at,
|
||||
starts_session: new_session || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
|
||||
type Params = {
|
||||
session: Pick<BUN_SQLITE_COBBLE_BOT_SESSIONS, "id" | "status">;
|
||||
};
|
||||
|
||||
/**
|
||||
* Keeps the address on the session the thread shows: `?session_id=` for a
|
||||
* closed one, a plain address for the active one (or when the asked one
|
||||
* couldn't be shown). Replaces the entry, so the back button isn't
|
||||
* filled with it.
|
||||
*/
|
||||
export default function syncSessionQuery({ session }: Params) {
|
||||
const url = new URL(window.location.href);
|
||||
|
||||
if (session.status == "active" || !session.id) {
|
||||
url.searchParams.delete("session_id");
|
||||
} else {
|
||||
url.searchParams.set("session_id", String(session.id));
|
||||
}
|
||||
|
||||
if (url.href != window.location.href) {
|
||||
window.history.replaceState(window.history.state, "", url.href);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabRunLimitsOverview from "@/src/functions/backend/db/app-settings/grab-run-limits";
|
||||
import grabSkillLimitsOverview from "@/src/functions/backend/db/app-settings/grab-skill-limits";
|
||||
import grabHookCallRetentionOverview from "@/src/functions/backend/db/app-settings/grab-hook-events-keep-days";
|
||||
import grabSessionLengthOverview from "@/src/functions/backend/db/app-settings/grab-bot-session-max-messages";
|
||||
import grabStorageQuotaOverview from "@/src/functions/backend/db/app-settings/grab-storage-quota-overview";
|
||||
import { grabSystemPromptAppendOverview } from "@/src/functions/backend/db/app-settings/grab-system-prompt-append";
|
||||
import runningVersion from "@/src/utils/running-version";
|
||||
@@ -51,6 +52,7 @@ export default async function grabSettingsServerProps({ req }: Params) {
|
||||
run_limits: await grabRunLimitsOverview().catch(() => null),
|
||||
skill_limits: await grabSkillLimitsOverview().catch(() => null),
|
||||
hook_call_retention: await grabHookCallRetentionOverview().catch(() => null),
|
||||
session_length: await grabSessionLengthOverview().catch(() => null),
|
||||
running_version: runningVersion({ env: process.env }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useState } from "react";
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import type { SessionLengthOverview } from "@/src/types";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
|
||||
type Props = {
|
||||
length?: SessionLengthOverview | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many messages a session takes, saved on its own without a reload.
|
||||
* Zero puts the code's default back.
|
||||
*/
|
||||
export default function SessionLengthForm({ length }: Props) {
|
||||
const [overview, setOverview] = useState(length || undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [max_messages, setMaxMessages] = useState(String(length?.max_messages ?? ""));
|
||||
/**
|
||||
* The input only reads `defaultValue` when it mounts, so discarding or
|
||||
* saving remounts it with the saved number.
|
||||
*/
|
||||
const [form_key, setFormKey] = useState(0);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<{
|
||||
msg?: string;
|
||||
error?: boolean;
|
||||
success?: boolean;
|
||||
}>();
|
||||
|
||||
const save = async () => {
|
||||
if (!window.confirm("Save how many messages a session takes? Sessions already past it move on at the user's next message.")) return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<SessionLengthOverview>>(
|
||||
"/api/admin/settings/session-length",
|
||||
{
|
||||
method: "POST",
|
||||
body: { max_messages: Number(max_messages) || 0 },
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({
|
||||
error: true,
|
||||
msg: res?.msg || `Couldn't save it.`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setOverview(res.singleRes);
|
||||
setMaxMessages(String(res.singleRes.max_messages));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus({
|
||||
success: true,
|
||||
msg: `Saved: sessions move on after ${res.singleRes.max_messages} messages.`,
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setMaxMessages(String(overview?.max_messages ?? ""));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
save().catch(() => {});
|
||||
}}
|
||||
className="w-full gap-3! items-stretch"
|
||||
>
|
||||
<Input
|
||||
key={`messages-${form_key}`}
|
||||
label="Start a new session after"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={max_messages}
|
||||
onChange={(event) => {
|
||||
setMaxMessages(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix={`messages`}
|
||||
info={`Counts the user's and the bot's messages. Fewer means cheaper replies but less said word for word; the summary carries the rest. 0 uses the default (${overview?.default_messages ?? ""}).`}
|
||||
/>
|
||||
<SettingsSaveBar
|
||||
dirty={dirty}
|
||||
loading={loading}
|
||||
status={status}
|
||||
save_label="Save"
|
||||
onDiscard={discard}
|
||||
/>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import SettingsGroup from "../../(partials)/settings-group";
|
||||
import SessionLengthForm from "../(partials)/session-length-form";
|
||||
|
||||
/**
|
||||
* How many messages a session takes. Only the super admin sees it, since
|
||||
* it changes every conversation.
|
||||
*/
|
||||
export default function SessionLengthSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Sessions"
|
||||
description="Every reply re-reads the session's recent messages, so a long session costs more tokens each time. Once a session has this many messages, the user's next message starts a new one, carrying a summary of the last. Bots can also move a conversation on sooner."
|
||||
id="sessions"
|
||||
>
|
||||
<SessionLengthForm length={pageProps?.session_length} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import InstallNameSection from "./(sections)/install-name-section";
|
||||
import NotificationsSection from "./(sections)/notifications-section";
|
||||
import PromptSection from "./(sections)/prompt-section";
|
||||
import RunLimitsSection from "./(sections)/run-limits-section";
|
||||
import SessionLengthSection from "./(sections)/session-length-section";
|
||||
import SkillLimitsSection from "./(sections)/skill-limits-section";
|
||||
import StorageSection from "./(sections)/storage-section";
|
||||
import VersionSection from "./(sections)/version-section";
|
||||
@@ -23,6 +24,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
<NotificationsSection />
|
||||
<PromptSection />
|
||||
<RunLimitsSection />
|
||||
<SessionLengthSection />
|
||||
<SkillLimitsSection />
|
||||
<HookCallRetentionSection />
|
||||
<StorageSection />
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import continueBotSession from "@/src/functions/backend/db/bot-sessions/continue-bot-session";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Continue in new session: carries one of the session user's
|
||||
* conversations into a new session with a summary of the active one, as
|
||||
* the bot does when asked (`continueBotSession`). The active one is closed
|
||||
* for good. Unlike `new-session`, which starts fresh.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_BOT_MESSAGES>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
|
||||
const message = await continueBotSession({
|
||||
user_id: user.id,
|
||||
bot_id: Number(conversation.bot.id),
|
||||
workspace_id: conversation.workspace_id ? Number(conversation.workspace_id) : null,
|
||||
});
|
||||
|
||||
await pushBotNotice({ bot_id: Number(conversation.bot.id), message, quiet_push: true });
|
||||
|
||||
return { success: true, singleRes: message };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationHistory from "@/src/functions/backend/db/bot-sessions/grab-conversation-history";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import { ConversationHistoryRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { ConversationHistoryPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
@@ -12,7 +12,8 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A conversation's history (`grabConversationHistory`: its active session,
|
||||
* A conversation's history (`grabConversationHistory`: its active session
|
||||
* or the one `session_id` names,
|
||||
* latest messages and the bot's active task there), which the
|
||||
* conversation pages load once they are on screen instead of rendering it
|
||||
* on the server, so a page restored by the back button never shows an
|
||||
@@ -34,8 +35,8 @@ export const handler: BunextAPIRouteHandler<
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
const { bot_id, workspace_id, session_id } = parseWithSchema({
|
||||
schema: ConversationHistoryRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
@@ -43,6 +44,7 @@ export const handler: BunextAPIRouteHandler<
|
||||
user_id: Number(user.id),
|
||||
bot_id,
|
||||
workspace_id,
|
||||
session_id,
|
||||
});
|
||||
|
||||
if (!history) {
|
||||
|
||||
@@ -13,8 +13,9 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Closes the active session of one of the session user's conversations and
|
||||
* starts the next one, which carries on from it.
|
||||
* New session: closes the active session of one of the session user's
|
||||
* conversations and starts a fresh one, linked to nothing and carrying no
|
||||
* summary. Carrying the conversation on is `continue-session`.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_BOT_SESSIONS>
|
||||
|
||||
@@ -13,9 +13,10 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The messages before `before_id` in one of the session user's own
|
||||
* sessions, `AppData.ConversationOlderMessages` at a time, for the thread
|
||||
* scrolled to its top.
|
||||
* The messages before `before_id` (or the latest) in one of the session
|
||||
* user's own sessions, `AppData.ConversationOlderMessages` at a time, for
|
||||
* the thread scrolled to its top or opening the previous session above
|
||||
* it. Reading an old session never puts it in the bot's prompt.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<OlderBotMessagesData>
|
||||
@@ -52,7 +53,17 @@ export const handler: BunextAPIRouteHandler<
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: { messages: has_more ? messages.slice(1) : messages, has_more },
|
||||
singleRes: {
|
||||
messages: has_more ? messages.slice(1) : messages,
|
||||
has_more,
|
||||
session: {
|
||||
id: Number(session.id),
|
||||
title: session.title || null,
|
||||
previous_session_id: session.previous_session_id
|
||||
? Number(session.previous_session_id)
|
||||
: null,
|
||||
},
|
||||
},
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import grabSuperAdminUser from "@/src/functions/backend/backups/grab-super-admin-user";
|
||||
import grabSessionLengthOverview from "@/src/functions/backend/db/app-settings/grab-bot-session-max-messages";
|
||||
import saveAppSetting from "@/src/functions/backend/db/app-settings/save-app-setting";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { BotSessionMaxMessagesSettingKey } from "@/src/dict/bot-dict";
|
||||
import { SaveSessionLengthRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { SessionLengthOverview } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type { APIResponseObject, BunextAPIRouteHandler } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Super admin only: how many messages a session takes before the user's
|
||||
* next message starts a new one. Zero puts the code's default back.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<SessionLengthOverview>> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") throw new AppError(`Method not allowed`);
|
||||
|
||||
const user = await grabSuperAdminUser({ req });
|
||||
|
||||
if (!user?.id) throw new AppError(`Only a super admin can change how long sessions run.`);
|
||||
|
||||
const { max_messages } = parseWithSchema({ schema: SaveSessionLengthRequestSchema, data: body });
|
||||
|
||||
await saveAppSetting({
|
||||
key: BotSessionMaxMessagesSettingKey,
|
||||
value: max_messages > 0 ? String(max_messages) : null,
|
||||
});
|
||||
|
||||
await logEvent({
|
||||
title: "Session length changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} set sessions to take ${max_messages || "the default number of"} messages.`,
|
||||
metadata: { max_messages },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, singleRes: await grabSessionLengthOverview() };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -24,6 +24,14 @@ export const SaveSystemPromptAppendRequestSchema = z.object({
|
||||
.default(""),
|
||||
});
|
||||
|
||||
/**
|
||||
* How many messages a session takes before the next one starts (Settings >
|
||||
* General). Zero puts the code's default back.
|
||||
*/
|
||||
export const SaveSessionLengthRequestSchema = z.object({
|
||||
max_messages: z.coerce.number().int().min(0).max(AppData.BotSessionMaxMessagesMax),
|
||||
});
|
||||
|
||||
export const SendBotMessageRequestSchema = z.object({
|
||||
bot_id: z.coerce.number().int().positive("Pick a bot to message"),
|
||||
workspace_id: WorkspaceIdField,
|
||||
@@ -88,8 +96,12 @@ export const BotSessionIdRequestSchema = z.object({
|
||||
|
||||
export type BotSessionIdRequest = z.infer<typeof BotSessionIdRequestSchema>;
|
||||
|
||||
/**
|
||||
* A page of a session's messages before `before_id`, or (left out) its
|
||||
* latest: the previous session opened above a thread.
|
||||
*/
|
||||
export const OlderBotMessagesRequestSchema = BotSessionIdRequestSchema.extend({
|
||||
before_id: z.coerce.number().int().positive("Pick a message"),
|
||||
before_id: z.coerce.number().int().positive("Pick a message").nullish(),
|
||||
});
|
||||
|
||||
export type OlderBotMessagesRequest = z.infer<typeof OlderBotMessagesRequestSchema>;
|
||||
@@ -101,6 +113,16 @@ export const BotConversationRequestSchema = z.object({
|
||||
|
||||
export type BotConversationRequest = z.infer<typeof BotConversationRequestSchema>;
|
||||
|
||||
/**
|
||||
* A conversation's history: its active session, or the one the page's
|
||||
* `?session_id=` names.
|
||||
*/
|
||||
export const ConversationHistoryRequestSchema = BotConversationRequestSchema.extend({
|
||||
session_id: z.coerce.number().int().positive().nullish(),
|
||||
});
|
||||
|
||||
export type ConversationHistoryRequest = z.infer<typeof ConversationHistoryRequestSchema>;
|
||||
|
||||
export const ListBotSessionsRequestSchema = BotConversationRequestSchema.extend({
|
||||
query: z.string().trim().max(200).optional(),
|
||||
page: z.coerce.number().int().positive().optional(),
|
||||
|
||||
@@ -997,6 +997,16 @@ export type McpServersOverviewPayload = ExtensionTargets & {
|
||||
servers: PublicMcpServer[];
|
||||
};
|
||||
|
||||
/**
|
||||
* How many messages a session takes (Settings > General,
|
||||
* `grab-bot-session-max-messages.ts`).
|
||||
*/
|
||||
export type SessionLengthOverview = {
|
||||
max_messages: number;
|
||||
default_messages: number;
|
||||
max: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* How long hook calls are kept (Settings > General,
|
||||
* `grab-hook-events-keep-days.ts`).
|
||||
@@ -1434,6 +1444,15 @@ export type MessageDetails = {
|
||||
export type OlderBotMessagesData = {
|
||||
messages: BotMessageWithAttachments[];
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The session paged, and the one it carries on from (opened above it
|
||||
* next).
|
||||
*/
|
||||
session: {
|
||||
id: number;
|
||||
title: string | null;
|
||||
previous_session_id: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type BotMessageReactionData = {
|
||||
@@ -1518,6 +1537,11 @@ export type ConversationHistory = {
|
||||
* far.
|
||||
*/
|
||||
task: BotTaskWithSteps | null;
|
||||
/**
|
||||
* When the session shown was carried over into a newer one (it stays
|
||||
* closed, to read): that session. Null otherwise.
|
||||
*/
|
||||
moved_to_session_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -1691,6 +1715,11 @@ export type PagePropsType = {
|
||||
* General settings page).
|
||||
*/
|
||||
hook_call_retention?: HookCallRetentionOverview | null;
|
||||
/**
|
||||
* How many messages a session takes, with the code's default (the
|
||||
* General settings page).
|
||||
*/
|
||||
session_length?: SessionLengthOverview | null;
|
||||
/**
|
||||
* The name the install goes by as an installed app (Settings >
|
||||
* General, and the root's head tags). Empty when never named.
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import conversationHref from "./conversation-href";
|
||||
|
||||
describe("conversationHref", () => {
|
||||
test("the bot's own chat, on its active session", () => {
|
||||
expect(conversationHref({ bot_id: 3 })).toBe("/admin/bots/3");
|
||||
});
|
||||
|
||||
test("a workspace's conversation, on a given session", () => {
|
||||
expect(conversationHref({ bot_id: 3, workspace_id: 4, session_id: 7 })).toBe(
|
||||
"/admin/workspaces/4/bots/3?session_id=7",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,22 @@
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the conversation is in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* A session to show other than the active one.
|
||||
*/
|
||||
session_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page of a conversation (`/admin/bots/<id>` or
|
||||
* `/admin/workspaces/<id>/bots/<id>`), opened on one of its sessions when
|
||||
* `session_id` is given.
|
||||
*/
|
||||
export default function conversationHref({ bot_id, workspace_id, session_id }: Params) {
|
||||
const path = workspace_id ? `/admin/workspaces/${workspace_id}/bots/${bot_id}` : `/admin/bots/${bot_id}`;
|
||||
|
||||
return session_id ? `${path}?session_id=${session_id}` : path;
|
||||
}
|
||||
Reference in New Issue
Block a user