Carry conversations into new sessions with a visible summary

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

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 05:35:52 +01:00
co-authored by Claude Opus 5.5
parent 1e6833b5d8
commit cc109bc361
64 changed files with 2074 additions and 128 deletions
@@ -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 />