Trim page props and page the conversation's messages
Bot and workspace lists in page props leave out instructions, memory and workspace context, and carry the last message as a short preview. The header avatar's edit form loads the bot's instructions when it opens. The conversation renders its latest 30 messages and loads older ones as the thread scrolls to its top. Also: the composer shows a placeholder until its editor loads, and `bun start` no longer builds first. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -297,6 +297,20 @@ export const AppData = {
|
||||
* How much of the conversation a bot sees when it replies.
|
||||
*/
|
||||
BotReplyHistoryMessages: 40,
|
||||
/**
|
||||
* How many of a session's latest messages the conversation page
|
||||
* renders with; older ones load as the thread is scrolled up.
|
||||
*/
|
||||
ConversationFirstMessages: 30,
|
||||
/**
|
||||
* How many older messages each scroll to the top of the thread loads.
|
||||
*/
|
||||
ConversationOlderMessages: 30,
|
||||
/**
|
||||
* How much of a bot's last message the rail's lists carry, as plain
|
||||
* text (`messagePreview`): a row shows one line of it.
|
||||
*/
|
||||
RailPreviewMaxChars: 160,
|
||||
/**
|
||||
* A session with this many messages is closed when the user writes
|
||||
* again, and the message starts the next one (carrying a summary).
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
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";
|
||||
@@ -23,7 +27,8 @@ 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 messages, the text model it runs on, its computers,
|
||||
* 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
|
||||
* the models modal: the install-wide catalog (`api/admin/harness/catalog`)
|
||||
@@ -63,7 +68,11 @@ export default async function grabConversationProps({
|
||||
bot.unread = 0;
|
||||
|
||||
const [bot_messages, bot_text_model, bot_computers, bot_task, bot_extensions, transcription, speech] = await Promise.all([
|
||||
grabBotMessages({ session_id: session.id!, user_id }),
|
||||
grabBotMessages({
|
||||
session_id: session.id!,
|
||||
user_id,
|
||||
limit: AppData.ConversationFirstMessages + 1,
|
||||
}),
|
||||
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 }),
|
||||
@@ -72,13 +81,16 @@ export default async function grabConversationProps({
|
||||
hasHarnessModel({ harness: "speech", bot_id: bot.id, workspace_id: scope }),
|
||||
]);
|
||||
|
||||
const bot_messages_more = bot_messages.length > AppData.ConversationFirstMessages;
|
||||
|
||||
return {
|
||||
bots,
|
||||
bot,
|
||||
workspaces,
|
||||
workspace: conversation.workspace,
|
||||
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: bot_messages_more ? bot_messages.slice(1) : bot_messages,
|
||||
bot_messages_more,
|
||||
bot_text_model,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
@@ -87,6 +99,14 @@ export default async function grabConversationProps({
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The open workspace without its shared context, which the conversation
|
||||
* never shows (the bot reads it from the database).
|
||||
*/
|
||||
function withoutContext({ system_prompt, ...workspace }: BUN_SQLITE_COBBLE_WORKSPACES) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the harness is on and has a model to run on for the bot there.
|
||||
*/
|
||||
|
||||
@@ -9,6 +9,10 @@ import mediaToAttachment from "./media-to-attachment";
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
limit?: number;
|
||||
/**
|
||||
* Only messages older than this one, for loading further back.
|
||||
*/
|
||||
before_id?: number | null;
|
||||
/**
|
||||
* Whose conversation it is, so a reaction can show as the user's own.
|
||||
* Left out for the bot's own history reads, which don't need it.
|
||||
@@ -17,12 +21,14 @@ type Params = {
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest messages of a session, oldest first, with their attachments,
|
||||
* The latest messages of a session (or the latest before `before_id`),
|
||||
* oldest first, with their attachments,
|
||||
* what each replies to, and (when `user_id` is given) its reactions.
|
||||
*/
|
||||
export default async function grabBotMessages({
|
||||
session_id,
|
||||
limit = 100,
|
||||
before_id,
|
||||
user_id,
|
||||
}: Params): Promise<BotMessageWithAttachments[]> {
|
||||
let sql = ``;
|
||||
@@ -30,13 +36,18 @@ export default async function grabBotMessages({
|
||||
sql += `SELECT * FROM (\n`;
|
||||
sql += ` SELECT * FROM bot_messages\n`;
|
||||
sql += ` WHERE session_id = ?\n`;
|
||||
|
||||
if (before_id) {
|
||||
sql += ` AND (created_at, id) < (SELECT created_at, id FROM bot_messages WHERE id = ?)\n`;
|
||||
}
|
||||
|
||||
sql += ` ORDER BY created_at DESC, id DESC\n`;
|
||||
sql += ` LIMIT ?\n`;
|
||||
sql += `) ORDER BY created_at ASC, id ASC\n`;
|
||||
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
|
||||
sql,
|
||||
values: [session_id, limit],
|
||||
values: before_id ? [session_id, before_id, limit] : [session_id, limit],
|
||||
});
|
||||
|
||||
const messages = res.payload || [];
|
||||
|
||||
@@ -5,7 +5,11 @@ type Params = {
|
||||
/**
|
||||
* Under `/api/admin/bots/`.
|
||||
*/
|
||||
path: "list-sessions" | "new-session" | "reopen-session" | "session-messages";
|
||||
path: | "list-sessions"
|
||||
| "new-session"
|
||||
| "reopen-session"
|
||||
| "session-messages"
|
||||
| "older-messages";
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { BotIdRequest } from "@/src/schemas/bot-schema";
|
||||
|
||||
/**
|
||||
* A bot's core instructions, for its edit form.
|
||||
*/
|
||||
export default async function fetchBotInstructions({ bot_id }: BotIdRequest) {
|
||||
return await fetchApi<BotIdRequest, APIResponseObject<{ system_prompt: string }>>(
|
||||
`/api/admin/bots/bot-instructions`,
|
||||
{ method: "POST", body: { bot_id } },
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import fetchBotInstructions from "../(functions)/fetch-bot-instructions";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
/**
|
||||
* Loads each time this turns true (the edit form opens), so the form
|
||||
* starts from what was last saved.
|
||||
*/
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The bot's core instructions for its edit form, loaded when it opens:
|
||||
* page props leave them out of every bot row.
|
||||
*/
|
||||
export default function useBotInstructions({ bot_id, enabled }: Params) {
|
||||
const [system_prompt, setSystemPrompt] = useState<string>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
/** Cleared on close, so the next open never shows an old copy. */
|
||||
setSystemPrompt(undefined);
|
||||
setError(undefined);
|
||||
|
||||
if (!enabled || !bot_id) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
fetchBotInstructions({ bot_id })
|
||||
.then((res) => {
|
||||
if (cancelled) return;
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setSystemPrompt(res.singleRes.system_prompt);
|
||||
} else {
|
||||
setError(res.msg || "Couldn't load the instructions.");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Couldn't load the instructions.");
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [enabled, bot_id]);
|
||||
|
||||
return { system_prompt, error };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { 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.
|
||||
*/
|
||||
has_more: boolean;
|
||||
/**
|
||||
* The thread's oldest message: the next page ends before it.
|
||||
*/
|
||||
first_message_id?: number | null;
|
||||
/**
|
||||
* Takes a loaded page, oldest first, to put above the thread.
|
||||
*/
|
||||
onLoaded: (params: { messages: BotMessageWithAttachments[] }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export default function useOlderMessages({
|
||||
session_id,
|
||||
has_more: initial_has_more,
|
||||
first_message_id,
|
||||
onLoaded,
|
||||
}: Params) {
|
||||
const [has_more, setHasMore] = useState(initial_has_more);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const first_session = useRef(session_id);
|
||||
|
||||
useEffect(() => {
|
||||
if (session_id == first_session.current) return;
|
||||
|
||||
setHasMore(false);
|
||||
setError(undefined);
|
||||
}, [session_id]);
|
||||
|
||||
const loadOlder = async () => {
|
||||
if (loading || !has_more || !session_id || !first_message_id) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postSessionApi<OlderBotMessagesData>({
|
||||
path: "older-messages",
|
||||
body: { session_id, before_id: first_message_id },
|
||||
}).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setError(res?.msg || "Couldn't load earlier messages.");
|
||||
return;
|
||||
}
|
||||
|
||||
onLoaded({ messages: res.singleRes.messages });
|
||||
setHasMore(res.singleRes.has_more);
|
||||
};
|
||||
|
||||
return { has_more, loading, error, loadOlder };
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
@@ -10,6 +11,7 @@ import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import useBotInstructions from "../(hooks)/use-bot-instructions";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -18,12 +20,17 @@ type Props = {
|
||||
/**
|
||||
* The header's avatar: opens the bot's profile (picture, name, job,
|
||||
* colour, instructions) to edit, for super admins and admins. Everyone
|
||||
* else sees the avatar alone.
|
||||
* else sees the avatar alone. The instructions load when it opens.
|
||||
*/
|
||||
export default function BotProfileModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
const name = bot.name || "This bot";
|
||||
const can_edit = canManageCrew({ user_types: pageProps?.user_types });
|
||||
const { system_prompt, error } = useBotInstructions({
|
||||
bot_id: Number(bot.id) || 0,
|
||||
enabled: open && can_edit,
|
||||
});
|
||||
|
||||
const avatar = (
|
||||
<BotAvatar
|
||||
@@ -34,7 +41,7 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
/>
|
||||
);
|
||||
|
||||
if (!canManageCrew({ user_types: pageProps?.user_types })) {
|
||||
if (!can_edit) {
|
||||
return <Stack className="shrink-0">{avatar}</Stack>;
|
||||
}
|
||||
|
||||
@@ -65,9 +72,15 @@ export default function BotProfileModal({ bot }: Props) {
|
||||
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
{open ? (
|
||||
<BotForm bot={bot} onCancel={() => setOpen(false)} />
|
||||
) : null}
|
||||
{!open ? null : system_prompt != undefined ? (
|
||||
<BotForm bot={{ ...bot, system_prompt }} onCancel={() => setOpen(false)} />
|
||||
) : error ? (
|
||||
<Span className="py-6 text-[13px] leading-[18px] text-ink-secondary">{error}</Span>
|
||||
) : (
|
||||
<Stack className="w-full py-10 items-center">
|
||||
<Loading />
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { EditorContent, type Editor } from "@tiptap/react";
|
||||
import ComposerFormatBubble from "./composer-format-bubble";
|
||||
import LoadingBlock from "@moduletrace/twui/elements/LoadingBlock";
|
||||
|
||||
type Props = {
|
||||
editor: Editor | null;
|
||||
@@ -21,7 +22,11 @@ export default function ComposerEditor({ editor, invalid, title }: Props) {
|
||||
title={title}
|
||||
className="grow min-w-0 self-center"
|
||||
/>
|
||||
{editor ? <ComposerFormatBubble editor={editor} /> : null}
|
||||
{editor ? (
|
||||
<ComposerFormatBubble editor={editor} />
|
||||
) : (
|
||||
<LoadingBlock className="w-full h-[30px] -ml-6" />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useContext, useEffect, useImperativeHandle, useRef, useState, type Ref } from "react";
|
||||
import {
|
||||
useContext,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type Ref,
|
||||
} from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import P from "@moduletrace/twui/layout/P";
|
||||
@@ -125,7 +132,11 @@ export default function Composer({
|
||||
});
|
||||
const editor = input.editor;
|
||||
|
||||
useImperativeHandle(handle_ref, () => ({ insertQuote: input.insertQuote }), [input.insertQuote]);
|
||||
useImperativeHandle(
|
||||
handle_ref,
|
||||
() => ({ insertQuote: input.insertQuote }),
|
||||
[input.insertQuote],
|
||||
);
|
||||
|
||||
/**
|
||||
* The saved draft goes into the editor once per conversation, after
|
||||
@@ -134,7 +145,8 @@ export default function Composer({
|
||||
const applied_key = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key) return;
|
||||
if (!editor || !draft.loaded || applied_key.current == draft.key)
|
||||
return;
|
||||
|
||||
applied_key.current = draft.key;
|
||||
|
||||
@@ -156,7 +168,11 @@ export default function Composer({
|
||||
onRecorded: async ({ audio }) => {
|
||||
if (!bot.id) return;
|
||||
|
||||
const res = await transcribeVoice({ bot_id: bot.id, workspace_id, audio });
|
||||
const res = await transcribeVoice({
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
audio,
|
||||
});
|
||||
|
||||
if (!res.success || !res.singleRes?.text) {
|
||||
recorder.setError(res.msg || "Couldn't write that out");
|
||||
@@ -294,7 +310,8 @@ export default function Composer({
|
||||
/>
|
||||
) : null}
|
||||
{input.menu ? <ComposerMentionList menu={input.menu} /> : null}
|
||||
<Row className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
<Row
|
||||
className={`w-full max-w-container mx-auto min-h-[88px] ${reply_to ? "pt-3 pb-7" : "py-7"} gap-3! md:gap-4! flex-nowrap items-end px-3 md:px-6 cursor-text`}
|
||||
onMouseDown={(e) => {
|
||||
const target = e.target as HTMLElement;
|
||||
|
||||
@@ -308,7 +325,13 @@ export default function Composer({
|
||||
disabled={files.full}
|
||||
onFiles={files.add}
|
||||
/>
|
||||
<ComposerEditor editor={editor} invalid={Boolean(error)} title={error} />
|
||||
|
||||
<ComposerEditor
|
||||
editor={editor}
|
||||
invalid={Boolean(error)}
|
||||
title={error}
|
||||
/>
|
||||
|
||||
{recorder.supported ? (
|
||||
<ComposerVoiceButton
|
||||
state={recorder.state}
|
||||
@@ -327,11 +350,18 @@ export default function Composer({
|
||||
/>
|
||||
</Row>
|
||||
{recorder.error ? (
|
||||
<P role="alert" className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary">
|
||||
<P
|
||||
role="alert"
|
||||
className="w-full max-w-container mx-auto px-3 md:px-6 -mt-5 pb-3 text-xs text-ink-secondary"
|
||||
>
|
||||
{recorder.error}
|
||||
</P>
|
||||
) : null}
|
||||
<ComposerTools bot={bot} workspace_id={workspace_id} initial={extensions} />
|
||||
<ComposerTools
|
||||
bot={bot}
|
||||
workspace_id={workspace_id}
|
||||
initial={extensions}
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import useMessageSelection from "../(hooks)/use-message-selection";
|
||||
import groupMessages from "../(utils)/group-messages";
|
||||
import BotThinkingRow from "./bot-thinking-row";
|
||||
import MessageGroupBlock from "./message-group-block";
|
||||
import OlderMessagesLoader from "./older-messages-loader";
|
||||
import QuoteSelectionButton from "./quote-selection-button";
|
||||
|
||||
type Props = {
|
||||
@@ -33,6 +34,15 @@ type Props = {
|
||||
* Quotes text selected in a message in the composer.
|
||||
*/
|
||||
onQuote?: (params: { text: string }) => void;
|
||||
/**
|
||||
* The session's older messages, loaded at the top of the thread.
|
||||
*/
|
||||
older?: {
|
||||
has_more: boolean;
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
loadOlder: () => void;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -40,6 +50,8 @@ 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
|
||||
* the view where it was as they land above it.
|
||||
*/
|
||||
export default function MessageThread({
|
||||
bot,
|
||||
@@ -49,6 +61,7 @@ export default function MessageThread({
|
||||
receiving,
|
||||
has_task,
|
||||
onQuote,
|
||||
older,
|
||||
}: Props) {
|
||||
const groups = useMemo(() => groupMessages({ messages }), [messages]);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
@@ -64,6 +77,13 @@ export default function MessageThread({
|
||||
aria-label={`Conversation with ${bot.name}`}
|
||||
className="w-full max-w-container mx-auto gap-4! items-stretch px-3 md:px-6 pt-6 pb-6"
|
||||
>
|
||||
{older?.has_more ? (
|
||||
<OlderMessagesLoader
|
||||
loading={older.loading}
|
||||
error={older.error}
|
||||
onLoad={older.loadOlder}
|
||||
/>
|
||||
) : null}
|
||||
{groups.map((group) => (
|
||||
<MessageGroupBlock
|
||||
key={group.key}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useEffect, useRef, type RefObject } from "react";
|
||||
import Loading from "@moduletrace/twui/elements/Loading";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
loading: boolean;
|
||||
error?: string;
|
||||
onLoad: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
export default function OlderMessagesLoader({ loading, error, onLoad }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const load = useRef(onLoad);
|
||||
|
||||
load.current = onLoad;
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
|
||||
if (!el || error || typeof IntersectionObserver == "undefined") return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
if (entries.some((entry) => entry.isIntersecting)) load.current();
|
||||
},
|
||||
{ rootMargin: "400px 0px 0px 0px" },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
|
||||
return () => observer.disconnect();
|
||||
}, [error, loading]);
|
||||
|
||||
return (
|
||||
<Stack
|
||||
componentRef={ref as RefObject<HTMLDivElement>}
|
||||
className="w-full items-center justify-center min-h-8"
|
||||
>
|
||||
{loading ? (
|
||||
<Loading size="smaller" />
|
||||
) : (
|
||||
<Button
|
||||
title="Show earlier messages"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onLoad}
|
||||
className="text-[13px]! leading-[18px]! text-ink-secondary"
|
||||
>
|
||||
{error ? `${error} Try again` : "Show earlier messages"}
|
||||
</Button>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import useTaskProgress from "../(hooks)/use-task-progress";
|
||||
import useReplyProgress from "../(hooks)/use-reply-progress";
|
||||
import useReplyCatchUp from "../(hooks)/use-reply-catch-up";
|
||||
import useSpeakMessage from "../(hooks)/use-speak-message";
|
||||
import useOlderMessages from "../(hooks)/use-older-messages";
|
||||
import ConversationHeader from "../(partials)/conversation-header";
|
||||
import MessageThread from "../(partials)/message-thread";
|
||||
import Composer, { type ComposerHandle } from "../(partials)/composer";
|
||||
@@ -35,7 +36,8 @@ 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.
|
||||
* mentions the crew and workspaces the user sees. The page brings the
|
||||
* session's latest messages; older ones load as the thread scrolls up.
|
||||
*/
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
@@ -99,6 +101,19 @@ export default function Conversation() {
|
||||
task: pageProps?.bot_task || null,
|
||||
});
|
||||
|
||||
const older = useOlderMessages({
|
||||
session_id,
|
||||
has_more: Boolean(pageProps?.bot_messages_more),
|
||||
first_message_id: Number(messages[0]?.id) || null,
|
||||
onLoaded: ({ messages: older_messages }) =>
|
||||
setMessages((prev) => [
|
||||
...older_messages.filter(
|
||||
(message) => !prev.some((m) => m.id == message.id),
|
||||
),
|
||||
...prev,
|
||||
]),
|
||||
});
|
||||
|
||||
const thread_messages = useMemo(
|
||||
() => [...messages, ...pending_messages],
|
||||
[messages, pending_messages],
|
||||
@@ -239,6 +254,7 @@ export default function Conversation() {
|
||||
reply_progress={reply_progress}
|
||||
receiving={pending_messages.length > 0}
|
||||
has_task={Boolean(progress.task_id)}
|
||||
older={older}
|
||||
onQuote={({ text }) =>
|
||||
composer.current?.insertQuote({ text })
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import crewRole from "@/src/utils/rbac/crew-role";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
/**
|
||||
* Opens a conversation. The super admin lands on the coordinator, which
|
||||
@@ -44,5 +46,5 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
return { redirect: { destination: `/admin/workspaces/${workspaces[0].id}` } };
|
||||
}
|
||||
|
||||
return { props: { bots, workspaces } };
|
||||
return { props: { bots: railBots({ bots }), workspaces: railWorkspaces({ workspaces }) } };
|
||||
};
|
||||
|
||||
@@ -10,6 +10,8 @@ import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness
|
||||
import grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
/**
|
||||
* One bot's settings: its details, models, workspaces, computers, tools
|
||||
@@ -63,8 +65,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
props: {
|
||||
...settings.props,
|
||||
bot,
|
||||
bots,
|
||||
workspaces,
|
||||
bots: railBots({ bots }),
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
bot_workspaces,
|
||||
bot_computers,
|
||||
computers: all_computers,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
@@ -11,7 +12,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
bots: await grabBots({ user_id: settings.props.person.id }),
|
||||
bots: railBots({ bots: await grabBots({ user_id: settings.props.person.id }) }),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-com
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
/**
|
||||
* One computer's settings: its state and resources, direct bot access,
|
||||
@@ -52,8 +54,8 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
props: {
|
||||
...settings.props,
|
||||
computer,
|
||||
bots,
|
||||
workspaces,
|
||||
bots: railBots({ bots }),
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
computer_bots: access.bots,
|
||||
computer_workspaces: access.workspaces,
|
||||
},
|
||||
|
||||
@@ -8,6 +8,8 @@ import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
|
||||
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
/**
|
||||
* One workspace's settings: its picture, details, bots, computers, tools
|
||||
@@ -52,14 +54,16 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
grabWorkspaceExtensionsOverview({ user_id, workspace_id }),
|
||||
]);
|
||||
|
||||
const crew = railBots({ bots: workspace_bots });
|
||||
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
workspaces,
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
workspace,
|
||||
bots: workspace_bots,
|
||||
workspace_bots,
|
||||
all_bots,
|
||||
bots: crew,
|
||||
workspace_bots: crew,
|
||||
all_bots: railBots({ bots: all_bots }),
|
||||
workspace_computers,
|
||||
computers,
|
||||
workspace_harness_overview,
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabCrewSettingsServerProps from "../(functions)/grab-crew-settings-server-props";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const settings = await grabCrewSettingsServerProps(params);
|
||||
@@ -11,7 +12,9 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return {
|
||||
props: {
|
||||
...settings.props,
|
||||
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
|
||||
workspaces: railWorkspaces({
|
||||
workspaces: await grabUserWorkspaces({ user_id: settings.props.person.id }),
|
||||
}),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -6,6 +6,8 @@ import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-w
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
|
||||
/**
|
||||
* A workspace's dashboard: its picture, context, latest activity, bots and
|
||||
@@ -38,12 +40,14 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
grabWorkspaceActivity({ user_id: user.id, workspace_id }),
|
||||
]);
|
||||
|
||||
const crew = railBots({ bots: workspace_bots });
|
||||
|
||||
return {
|
||||
props: {
|
||||
workspaces,
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
workspace,
|
||||
bots: workspace_bots,
|
||||
workspace_bots,
|
||||
bots: crew,
|
||||
workspace_bots: crew,
|
||||
workspace_computers,
|
||||
workspace_activity,
|
||||
},
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
|
||||
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 bot's core instructions, for its edit form in the conversation, which
|
||||
* loads them when it opens (page props leave them out). For super admins
|
||||
* and admins; only the super admin reaches the coordinator.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<{ system_prompt: string }>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
if (!canManageCrew({ user_types })) {
|
||||
throw new AppError(`Only an admin can edit a bot.`);
|
||||
}
|
||||
|
||||
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
|
||||
const bot = await grabUserBot({ bot_id, user_id: user.id });
|
||||
|
||||
if (!bot?.id) {
|
||||
throw new AppError(`Bot not found.`);
|
||||
}
|
||||
|
||||
return { success: true, singleRes: { system_prompt: bot.system_prompt || "" } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserBotSession from "@/src/functions/backend/db/bot-sessions/grab-user-bot-session";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import { OlderBotMessagesRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { OlderBotMessagesData } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* The messages before `before_id` in one of the session user's own
|
||||
* sessions, `AppData.ConversationOlderMessages` at a time, for the thread
|
||||
* scrolled to its top.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<OlderBotMessagesData>
|
||||
> = async ({ req, body }) => {
|
||||
try {
|
||||
if (req.method != "POST") {
|
||||
throw new AppError(`Method not allowed`);
|
||||
}
|
||||
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.logged_in_status || !user.id) {
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { session_id, before_id } = parseWithSchema({
|
||||
schema: OlderBotMessagesRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const session = await grabUserBotSession({ session_id, user_id: user.id });
|
||||
|
||||
if (!session?.id) {
|
||||
throw new AppError(`Session not found.`);
|
||||
}
|
||||
|
||||
const messages = await grabBotMessages({
|
||||
session_id: session.id,
|
||||
before_id,
|
||||
limit: AppData.ConversationOlderMessages + 1,
|
||||
user_id: user.id,
|
||||
});
|
||||
const has_more = messages.length > AppData.ConversationOlderMessages;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: { messages: has_more ? messages.slice(1) : messages, has_more },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -12,6 +12,7 @@ import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
|
||||
/**
|
||||
* The crew's workers that aren't in a workspace yet, for the workspace
|
||||
@@ -48,9 +49,11 @@ export const handler: BunextAPIRouteHandler<APIResponseObject<BotWithLastMessage
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: bots.filter(
|
||||
(bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id),
|
||||
),
|
||||
payload: railBots({
|
||||
bots: bots.filter(
|
||||
(bot) => bot.kind != "coordinator" && !members.some((member) => member.id == bot.id),
|
||||
),
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
|
||||
@@ -77,6 +77,12 @@ export const BotSessionIdRequestSchema = z.object({
|
||||
|
||||
export type BotSessionIdRequest = z.infer<typeof BotSessionIdRequestSchema>;
|
||||
|
||||
export const OlderBotMessagesRequestSchema = BotSessionIdRequestSchema.extend({
|
||||
before_id: z.coerce.number().int().positive("Pick a message"),
|
||||
});
|
||||
|
||||
export type OlderBotMessagesRequest = z.infer<typeof OlderBotMessagesRequestSchema>;
|
||||
|
||||
export const BotConversationRequestSchema = z.object({
|
||||
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||
workspace_id: WorkspaceIdField,
|
||||
|
||||
@@ -666,6 +666,15 @@ export type BotMessageWithAttachments = BUN_SQLITE_COBBLE_BOT_MESSAGES & {
|
||||
/**
|
||||
* `server:bot-reaction` data: a message's reactions changed.
|
||||
*/
|
||||
/**
|
||||
* A page of a session's older messages, oldest first, and whether there
|
||||
* are older still.
|
||||
*/
|
||||
export type OlderBotMessagesData = {
|
||||
messages: BotMessageWithAttachments[];
|
||||
has_more: boolean;
|
||||
};
|
||||
|
||||
export type BotMessageReactionData = {
|
||||
message_id: number;
|
||||
reactions: BotMessageReaction[];
|
||||
@@ -725,6 +734,10 @@ export type PagePropsType = {
|
||||
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.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import railBots from "./rail-bots";
|
||||
import railWorkspaces from "./rail-workspaces";
|
||||
|
||||
describe("railBots", () => {
|
||||
test("drops the core instructions and memory", () => {
|
||||
const bots: BotWithLastMessage[] = [
|
||||
{ id: 1, name: "Ada", system_prompt: "Long rules", memory: "Notes" },
|
||||
];
|
||||
const [bot] = railBots({ bots });
|
||||
|
||||
expect(bot).toEqual({ id: 1, name: "Ada", last_message_text: undefined });
|
||||
});
|
||||
|
||||
test("cuts the last message to a plain one-line preview", () => {
|
||||
const bots: BotWithLastMessage[] = [
|
||||
{ id: 1, last_message_text: `**Done.**\n\n${"word ".repeat(100)}` },
|
||||
];
|
||||
const [bot] = railBots({ bots });
|
||||
|
||||
expect(bot?.last_message_text?.startsWith("Done. word")).toBe(true);
|
||||
expect(bot?.last_message_text?.length).toBe(AppData.RailPreviewMaxChars);
|
||||
});
|
||||
});
|
||||
|
||||
describe("railWorkspaces", () => {
|
||||
test("drops the shared context", () => {
|
||||
expect(
|
||||
railWorkspaces({
|
||||
workspaces: [{ id: 2, name: "Site", system_prompt: "Context", unread: 0 }],
|
||||
}),
|
||||
).toEqual([{ id: 2, name: "Site", unread: 0 }]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import messagePreview from "./message-preview";
|
||||
|
||||
/**
|
||||
* Bots as page props carry them for the rail and the crew lists: without
|
||||
* their core instructions and memory (the largest part of a row, and only
|
||||
* the bot's own edit form reads them, loading them when it opens), and
|
||||
* with the last message cut to a one-line preview.
|
||||
*/
|
||||
export default function railBots<T extends BotWithLastMessage>({ bots }: { bots: T[] }): T[] {
|
||||
return bots.map(({ system_prompt, memory, ...bot }) => ({
|
||||
...bot,
|
||||
last_message_text: bot.last_message_text
|
||||
? messagePreview({ text: bot.last_message_text }).slice(0, AppData.RailPreviewMaxChars)
|
||||
: bot.last_message_text,
|
||||
})) as T[];
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { WorkspaceWithActivity } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Workspaces as page props carry them for the rail: without their shared
|
||||
* context (`system_prompt`), which only the open workspace's own pages
|
||||
* read, from their `workspace` prop.
|
||||
*/
|
||||
export default function railWorkspaces({
|
||||
workspaces,
|
||||
}: {
|
||||
workspaces: WorkspaceWithActivity[];
|
||||
}): WorkspaceWithActivity[] {
|
||||
return workspaces.map(({ system_prompt, ...workspace }) => workspace);
|
||||
}
|
||||
Reference in New Issue
Block a user