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:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user