Load a conversation's history on the client
The conversation pages rendered their messages on the server, so a page the back button restored from the back-forward cache showed an old thread. The server now renders everything else (rail, header, composer) and the history (active session, latest messages, active task) loads from api/admin/bots/conversation-history once the page is on screen, with a loading line under the header, and again on a pageshow restore. Messages sent or pushed while it loads are merged with it. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
type Props = {
|
||||
label?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A thin line sweeping across, set just under a pane header while its
|
||||
* page loads. Takes no height (it overlays the top of what follows), so
|
||||
* nothing moves when it goes. Under reduced motion it holds still.
|
||||
*/
|
||||
export default function LoadingLine({ label = "Loading", className }: Props) {
|
||||
return (
|
||||
<Stack
|
||||
role="progressbar"
|
||||
aria-label={label}
|
||||
className={twMerge("relative h-0 w-full shrink-0 gap-0! z-10", className)}
|
||||
>
|
||||
<Stack className="absolute inset-x-0 top-0 h-0.5 gap-0! overflow-hidden bg-ink/5">
|
||||
<Span
|
||||
className={twMerge(
|
||||
"block h-full w-1/3 bg-linear-to-r from-transparent via-ink to-transparent",
|
||||
"animate-loading-line",
|
||||
"motion-reduce:animate-none motion-reduce:w-full motion-reduce:opacity-30",
|
||||
)}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
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 grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the conversation is in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_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
|
||||
* `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`).
|
||||
* Null when the user can't chat with the bot there.
|
||||
*/
|
||||
export default async function grabConversationHistory({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<ConversationHistory | null> {
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
session.unread = 0;
|
||||
}
|
||||
|
||||
const [messages, task] = await Promise.all([
|
||||
grabBotMessages({
|
||||
session_id: session.id!,
|
||||
user_id,
|
||||
limit: AppData.ConversationFirstMessages + 1,
|
||||
}),
|
||||
grabActiveBotTask({ bot_id: open_bot_id, session_id: session.id }),
|
||||
]);
|
||||
|
||||
const has_more = messages.length > AppData.ConversationFirstMessages;
|
||||
|
||||
return {
|
||||
session,
|
||||
messages: has_more ? messages.slice(1) : messages,
|
||||
has_more,
|
||||
task,
|
||||
};
|
||||
}
|
||||
@@ -1,19 +1,14 @@
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotTextModel, PagePropsType } from "@/src/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
import grabBots from "../bots/grab-bots";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import grabBotExtensionsOverview from "../extensions/grab-bot-extensions-overview";
|
||||
import grabConversationFiles from "../bot-files/grab-conversation-files";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import resolveHarnessCandidates from "@/src/harness/resolve/resolve-harness-candidates";
|
||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
@@ -27,11 +22,12 @@ type Params = {
|
||||
/**
|
||||
* The page props of a conversation (`/admin/bots/<id>` or
|
||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||
* in a workspace) and workspaces, the bot, its active session (marked
|
||||
* read) and its latest `AppData.ConversationFirstMessages` messages (the
|
||||
* thread loads older ones as it scrolls), the text model it runs on, its computers,
|
||||
* tools and MCP servers and active task there, and whether its mic and
|
||||
* read-aloud have a model to run on. The full model settings are left to
|
||||
* in a workspace) and workspaces, the bot, the text model it runs on, its
|
||||
* computers, tools, MCP servers and files there, and whether its mic and
|
||||
* read-aloud have a model to run on. The history (its session, messages
|
||||
* and active task) is left to the client (`grabConversationHistory`,
|
||||
* `api/admin/bots/conversation-history`), so a page the back button
|
||||
* restores never shows an old thread. The full model settings are left to
|
||||
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||
* and the conversation's small setting
|
||||
* (`api/admin/bots/harness-overview`). They are most of a page's weight
|
||||
@@ -50,12 +46,6 @@ export default async function grabConversationProps({
|
||||
if (!conversation) return null;
|
||||
|
||||
const scope = conversation.workspace_id;
|
||||
const session = await ensureBotSession({ user_id, bot_id: conversation.bot.id, workspace_id: scope });
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
session.unread = 0;
|
||||
}
|
||||
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
grabBots({ user_id, workspace_id: scope }),
|
||||
@@ -66,36 +56,25 @@ export default async function grabConversationProps({
|
||||
|
||||
if (!bot?.id) return null;
|
||||
|
||||
/** Loading its history marks the open session read. */
|
||||
bot.unread = 0;
|
||||
|
||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||
grabBotMessages({
|
||||
session_id: session.id!,
|
||||
user_id,
|
||||
limit: AppData.ConversationFirstMessages + 1,
|
||||
}),
|
||||
const [bot_text_model, bot_computers, bot_extensions, bot_files, transcription, speech] = await Promise.all([
|
||||
grabTextModel({ bot_id: bot.id, workspace_id: scope }),
|
||||
grabBotComputers({ bot_id: bot.id, workspace_id: scope }),
|
||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||
grabBotExtensionsOverview({ user_id, bot_id: bot.id, workspace_id: scope }),
|
||||
grabConversationFiles({ bot_id: bot.id, workspace_id: scope }),
|
||||
hasHarnessModel({ harness: "transcription", bot_id: bot.id, workspace_id: scope }),
|
||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||
]);
|
||||
|
||||
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
|
||||
|
||||
return {
|
||||
bots: railBots({ bots }),
|
||||
bot: railBots({ bots: [bot] })[0],
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
workspace: conversation.workspace ? withoutContext(conversation.workspace) : null,
|
||||
bot_session: session,
|
||||
bot_messages: bot_messages_more ? bot_messages.slice(1) : bot_messages,
|
||||
bot_messages_more,
|
||||
bot_text_model,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
bot_extensions,
|
||||
bot_files,
|
||||
bot_voice: { transcription, speech },
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
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 { ConversationHistoryPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* A conversation's history, loaded once the page is on screen.
|
||||
*/
|
||||
export default async function fetchConversationHistory({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: BotConversationRequest) {
|
||||
return await fetchApi<
|
||||
BotConversationRequest,
|
||||
APIResponseObject<ConversationHistoryPayload>
|
||||
>(`/api/admin/bots/conversation-history`, {
|
||||
method: "POST",
|
||||
body: { bot_id, workspace_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { ConversationHistory } from "@/src/types";
|
||||
import fetchConversationHistory from "../(functions)/fetch-conversation-history";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id: number | null;
|
||||
/**
|
||||
* Takes each load: the thread starts over from it.
|
||||
*/
|
||||
onLoaded: (params: { history: ConversationHistory }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
export default function useConversationHistory({ bot_id, workspace_id, onLoaded }: Params) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const request = useRef(0);
|
||||
const on_loaded = useRef(onLoaded);
|
||||
|
||||
on_loaded.current = onLoaded;
|
||||
|
||||
const load = async () => {
|
||||
if (!bot_id) return;
|
||||
|
||||
const id = ++request.current;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await fetchConversationHistory({ bot_id, workspace_id }).catch(() => null);
|
||||
|
||||
/** A newer load started meanwhile: it has the last word. */
|
||||
if (id != request.current) return;
|
||||
|
||||
if (res?.success && res.singleRes?.redirect) {
|
||||
window.location.replace(res.singleRes.redirect);
|
||||
return;
|
||||
}
|
||||
|
||||
const history = res?.success ? res.singleRes?.history : null;
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!history?.session?.id) {
|
||||
setError(res?.msg || "Couldn't load the conversation.");
|
||||
return;
|
||||
}
|
||||
|
||||
on_loaded.current({ history });
|
||||
setLoaded(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
|
||||
const onPageShow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) load();
|
||||
};
|
||||
|
||||
window.addEventListener("pageshow", onPageShow);
|
||||
return () => window.removeEventListener("pageshow", onPageShow);
|
||||
}, [bot_id, workspace_id]);
|
||||
|
||||
return { loading, loaded, error, retry: load };
|
||||
}
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import postSessionApi from "@/src/functions/frontend/post-session-api";
|
||||
import type { BotMessageWithAttachments, OlderBotMessagesData } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
session_id?: number | null;
|
||||
/**
|
||||
* The page rendered with only the session's latest messages.
|
||||
* The thread starts with only the session's latest messages.
|
||||
*/
|
||||
has_more: boolean;
|
||||
/**
|
||||
@@ -21,7 +21,8 @@ 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.
|
||||
* its first message, so it has none older; `reset` starts over from a
|
||||
* loaded history instead.
|
||||
*/
|
||||
export default function useOlderMessages({
|
||||
session_id,
|
||||
@@ -32,15 +33,28 @@ export default function useOlderMessages({
|
||||
const [has_more, setHasMore] = useState(initial_has_more);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const first_session = useRef(session_id);
|
||||
const base_session = useRef(session_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (session_id == first_session.current) return;
|
||||
if (session_id == base_session.current) return;
|
||||
|
||||
base_session.current = session_id;
|
||||
setHasMore(false);
|
||||
setError(undefined);
|
||||
}, [session_id]);
|
||||
|
||||
/**
|
||||
* A history loaded for the session: whether it has older messages.
|
||||
*/
|
||||
const reset = useCallback(
|
||||
({ session_id, has_more }: { session_id: number; has_more: boolean }) => {
|
||||
base_session.current = session_id;
|
||||
setHasMore(has_more);
|
||||
setError(undefined);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (loading || !has_more || !session_id || !first_message_id) return;
|
||||
|
||||
@@ -63,5 +77,5 @@ export default function useOlderMessages({
|
||||
setHasMore(res.singleRes.has_more);
|
||||
};
|
||||
|
||||
return { has_more, loading, error, loadOlder };
|
||||
return { has_more, loading, error, loadOlder, reset };
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from "react";
|
||||
import { useCallback, useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/types";
|
||||
|
||||
@@ -9,7 +9,7 @@ type Params = {
|
||||
*/
|
||||
session_id?: number | null;
|
||||
/**
|
||||
* The bot's active task when the page loaded.
|
||||
* The bot's active task when the thread started.
|
||||
*/
|
||||
task: BotTaskWithSteps | null;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
|
||||
* The open session's active task and its latest step (a
|
||||
* `bots_queue_updates` row's text), kept current from
|
||||
* `server:bot-job-update`. Starts over when `server:bot-updated` brings a
|
||||
* different task, or none.
|
||||
* different task, or none, or `reset` brings a loaded history's.
|
||||
*/
|
||||
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
|
||||
@@ -60,5 +60,9 @@ export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
},
|
||||
});
|
||||
|
||||
return progress;
|
||||
const reset = useCallback(({ task }: { task: BotTaskWithSteps | null }) => {
|
||||
setProgress(fromTask({ task }));
|
||||
}, []);
|
||||
|
||||
return { ...progress, reset };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
error: string;
|
||||
onRetry: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the header when the conversation's history couldn't load: why,
|
||||
* and a way to try again.
|
||||
*/
|
||||
export default function HistoryErrorRow({ error, onRetry }: Props) {
|
||||
return (
|
||||
<Row className="shrink-0 gap-3! flex-nowrap justify-center px-6 py-3 border-0 border-b border-solid border-hairline">
|
||||
<Span className="text-sm text-ink-secondary">{error}</Span>
|
||||
<Button title="Try again" type="button" variant="ghost" color="text" onClick={onRetry}>
|
||||
Try again
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -33,6 +33,10 @@ import ConversationHeader from "../(partials)/conversation-header";
|
||||
import MessageThread from "../(partials)/message-thread";
|
||||
import Composer, { type ComposerHandle } from "../(partials)/composer";
|
||||
import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
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";
|
||||
|
||||
/**
|
||||
* The conversation column: header, bottom-anchored thread, composer, the
|
||||
@@ -42,8 +46,12 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
* buttons in the thread, so a switch from either shows in both; either
|
||||
* can open one in the viewer. Bot messages can be read aloud, one at a
|
||||
* time. Text selected in the thread can be quoted in the composer, which
|
||||
* mentions the crew and workspaces the user sees. The page brings the
|
||||
* session's latest messages; older ones load as the thread scrolls up.
|
||||
* mentions the crew and workspaces the user sees. The page brings
|
||||
* everything but the history, so the header and composer show at once;
|
||||
* 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.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -52,7 +60,7 @@ export default function Conversation() {
|
||||
const workspace_id = workspace?.id || null;
|
||||
|
||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||
pageProps?.bot_messages || [],
|
||||
[],
|
||||
);
|
||||
/**
|
||||
* Messages sent here that the server hasn't confirmed yet, shown after
|
||||
@@ -63,9 +71,7 @@ export default function Conversation() {
|
||||
const [pending_messages, setPendingMessages] = useState<
|
||||
BotMessageWithAttachments[]
|
||||
>([]);
|
||||
const [session_id, setSessionId] = useState<number | null>(
|
||||
pageProps?.bot_session?.id || null,
|
||||
);
|
||||
const [session_id, setSessionId] = useState<number | null>(null);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
const [reply_to, setReplyTo] = useState<BotMessageWithAttachments | null>(
|
||||
null,
|
||||
@@ -73,9 +79,19 @@ export default function Conversation() {
|
||||
|
||||
/**
|
||||
* A quote picked in one session doesn't follow the thread to another,
|
||||
* and neither does a message still on its way to the old one.
|
||||
* and neither does a message still on its way to the old one. The
|
||||
* first session the thread learns of isn't a move: what was sent
|
||||
* before the history landed stays.
|
||||
*/
|
||||
const known_session = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const moved = Boolean(known_session.current);
|
||||
|
||||
known_session.current = session_id;
|
||||
|
||||
if (!moved) return;
|
||||
|
||||
setReplyTo(null);
|
||||
setPendingMessages([]);
|
||||
}, [session_id]);
|
||||
@@ -108,12 +124,12 @@ export default function Conversation() {
|
||||
const progress = useTaskProgress({
|
||||
bot_id: bot?.id || 0,
|
||||
session_id,
|
||||
task: pageProps?.bot_task || null,
|
||||
task: null,
|
||||
});
|
||||
|
||||
const older = useOlderMessages({
|
||||
session_id,
|
||||
has_more: Boolean(pageProps?.bot_messages_more),
|
||||
has_more: false,
|
||||
first_message_id: Number(messages[0]?.id) || null,
|
||||
onLoaded: ({ messages: older_messages }) =>
|
||||
setMessages((prev) => [
|
||||
@@ -124,6 +140,19 @@ export default function Conversation() {
|
||||
]),
|
||||
});
|
||||
|
||||
const history = useConversationHistory({
|
||||
bot_id: Number(bot?.id) || 0,
|
||||
workspace_id,
|
||||
onLoaded: ({ history }) => {
|
||||
const loaded_session_id = Number(history.session.id);
|
||||
|
||||
setSessionId(loaded_session_id);
|
||||
setMessages((prev) => mergeLoadedHistory({ shown: prev, history }));
|
||||
older.reset({ session_id: loaded_session_id, has_more: history.has_more });
|
||||
progress.reset({ task: history.task });
|
||||
},
|
||||
});
|
||||
|
||||
const thread_messages = useMemo(
|
||||
() => [...messages, ...pending_messages],
|
||||
[messages, pending_messages],
|
||||
@@ -169,7 +198,15 @@ export default function Conversation() {
|
||||
dropSentMessage({ pending: prev, message }),
|
||||
);
|
||||
|
||||
if (message.session_id && message.session_id != session_id) {
|
||||
/**
|
||||
* Before the history lands the thread has no session yet: the
|
||||
* message waits in the thread, and the history merges with it.
|
||||
*/
|
||||
if (
|
||||
session_id &&
|
||||
message.session_id &&
|
||||
message.session_id != session_id
|
||||
) {
|
||||
if (message.author == "user" && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
@@ -276,6 +313,15 @@ export default function Conversation() {
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
{history.loading ? (
|
||||
<LoadingLine label="Loading the conversation" />
|
||||
) : null}
|
||||
{history.error ? (
|
||||
<HistoryErrorRow
|
||||
error={history.error}
|
||||
onRetry={history.retry}
|
||||
/>
|
||||
) : null}
|
||||
<MessageThread
|
||||
bot={live_bot}
|
||||
messages={thread_messages}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
|
||||
import mergeLoadedHistory from "./merge-loaded-history";
|
||||
|
||||
function message({ id, session_id = 1 }: { id: number; session_id?: number }) {
|
||||
return { id, session_id, author: "bot", text: `#${id}` } as BotMessageWithAttachments;
|
||||
}
|
||||
|
||||
function history({ ids, session_id = 1 }: { ids: number[]; session_id?: number }) {
|
||||
return {
|
||||
session: { id: session_id },
|
||||
messages: ids.map((id) => message({ id, session_id })),
|
||||
has_more: false,
|
||||
task: null,
|
||||
} as ConversationHistory;
|
||||
}
|
||||
|
||||
const ids = (messages: BotMessageWithAttachments[]) => messages.map((m) => m.id);
|
||||
|
||||
describe("mergeLoadedHistory", () => {
|
||||
test("an empty thread takes the history", () => {
|
||||
expect(ids(mergeLoadedHistory({ shown: [], history: history({ ids: [1, 2] }) }))).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
test("keeps what arrived after the history, once", () => {
|
||||
const shown = [message({ id: 2 }), message({ id: 3 })];
|
||||
|
||||
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [1, 2] }) }))).toEqual([1, 2, 3]);
|
||||
});
|
||||
|
||||
test("an older copy of the thread gives way", () => {
|
||||
const shown = [message({ id: 1 }), message({ id: 2 })];
|
||||
|
||||
expect(ids(mergeLoadedHistory({ shown, history: history({ ids: [2, 4, 5] }) }))).toEqual([2, 4, 5]);
|
||||
});
|
||||
|
||||
test("another session's messages give way", () => {
|
||||
const shown = [message({ id: 9, session_id: 1 })];
|
||||
|
||||
expect(
|
||||
ids(mergeLoadedHistory({ shown, history: history({ ids: [10], session_id: 2 }) })),
|
||||
).toEqual([10]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import type { BotMessageWithAttachments, ConversationHistory } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* What the thread shows now.
|
||||
*/
|
||||
shown: BotMessageWithAttachments[];
|
||||
history: ConversationHistory;
|
||||
};
|
||||
|
||||
/**
|
||||
* The thread once a history lands: its messages, then any the thread
|
||||
* already has from the same session that came after them (sent or pushed
|
||||
* while the history was on its way). Everything else the thread showed
|
||||
* (an older copy of the thread, another session's) gives way to it.
|
||||
*/
|
||||
export default function mergeLoadedHistory({ shown, history }: Params) {
|
||||
const loaded = history.messages;
|
||||
const last_id = Number(loaded[loaded.length - 1]?.id) || 0;
|
||||
|
||||
const later = shown.filter(
|
||||
(message) =>
|
||||
message.session_id == history.session.id &&
|
||||
Number(message.id) > last_id &&
|
||||
!loaded.some((m) => m.id == message.id),
|
||||
);
|
||||
|
||||
return [...loaded, ...later];
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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 type { ConversationHistoryPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A conversation's history (`grabConversationHistory`: its active session,
|
||||
* 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
|
||||
* old thread. When the user can't chat with the bot there any more,
|
||||
* `redirect` says where the page goes instead: their bots, or the
|
||||
* workspace (its settings for managers).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<ConversationHistoryPayload>
|
||||
> = 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 };
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const history = await grabConversationHistory({
|
||||
user_id: Number(user.id),
|
||||
bot_id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
if (!history) {
|
||||
const redirect = !workspace_id
|
||||
? "/admin/bots"
|
||||
: canManageCrew({ user_types })
|
||||
? `/admin/settings/workspaces/${workspace_id}`
|
||||
: `/admin/workspaces/${workspace_id}`;
|
||||
|
||||
return { success: true, singleRes: { redirect } };
|
||||
}
|
||||
|
||||
return { success: true, singleRes: { history } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -184,6 +184,19 @@
|
||||
/* The rail's full (named) width: below it the rail shows icons only.
|
||||
A container query, so the mobile drawer opens full too. */
|
||||
--container-rail: 220px;
|
||||
|
||||
/* The thin line under a pane header while its page loads
|
||||
(`LoadingLine`): a short bar sweeping across, again and again. */
|
||||
--animate-loading-line: loading-line 1.2s ease-in-out infinite;
|
||||
|
||||
@keyframes loading-line {
|
||||
0% {
|
||||
transform: translateX(-100%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(300%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
|
||||
+26
-13
@@ -1080,6 +1080,32 @@ export type BotTextModel = {
|
||||
provider_name: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's history, which its page loads on the client once it
|
||||
* renders (`grabConversationHistory`): the active session, its latest
|
||||
* messages, whether it has older ones, and the bot's active task in it.
|
||||
*/
|
||||
export type ConversationHistory = {
|
||||
session: BUN_SQLITE_COBBLE_BOT_SESSIONS;
|
||||
messages: BotMessageWithAttachments[];
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The bot's active task in the session and what its job has done so
|
||||
* far.
|
||||
*/
|
||||
task: BotTaskWithSteps | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* What `api/admin/bots/conversation-history` answers: the history, or
|
||||
* where to go instead when the user can't chat with the bot there any
|
||||
* more.
|
||||
*/
|
||||
export type ConversationHistoryPayload = {
|
||||
history?: ConversationHistory;
|
||||
redirect?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One build's `.bunext/public` files, downloaded by the service worker
|
||||
* before the app runs (`grabPrecacheManifest`).
|
||||
@@ -1107,15 +1133,6 @@ export type PagePropsType = {
|
||||
crew_workspaces?: BUN_SQLITE_COBBLE_WORKSPACES[] | null;
|
||||
bots?: BotWithLastMessage[] | null;
|
||||
bot?: BotWithLastMessage | null;
|
||||
bot_messages?: BotMessageWithAttachments[] | null;
|
||||
/**
|
||||
* The session has messages older than `bot_messages`.
|
||||
*/
|
||||
bot_messages_more?: boolean | null;
|
||||
/**
|
||||
* The open conversation's active session.
|
||||
*/
|
||||
bot_session?: BUN_SQLITE_COBBLE_BOT_SESSIONS | null;
|
||||
/**
|
||||
* The user's workspaces, for the rail.
|
||||
*/
|
||||
@@ -1158,10 +1175,6 @@ export type PagePropsType = {
|
||||
* The open bot's computers, the one it's on first.
|
||||
*/
|
||||
bot_computers?: ComputerWithStatus[] | null;
|
||||
/**
|
||||
* The open bot's active task and what its job has done so far.
|
||||
*/
|
||||
bot_task?: BotTaskWithSteps | null;
|
||||
/**
|
||||
* The routines page: every schedule the user's bots have.
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user