Let workers ask each other through the coordinator in chatrooms
A worker asks for another bot's help with ask_crew. The coordinator reads the request, asks the right bot (in a workspace, only its members), and sends the answer back into the asking bot's conversation, waking it. Each exchange is kept as a read-only chatroom: listed in the main rail after the workspaces, or in the workspace's rail for requests made there, with live status rows for each bot while it's open and a Stop button. Room sessions are hidden from every conversation list, other bots' words reach the model fenced as data, and turns, nesting and open rooms are capped. Closed rooms are pruned after a retention period and past a cap per place (Settings > General). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import { MessagesSquare } from "lucide-react";
|
||||
|
||||
type Props = {
|
||||
chatroom_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A chatroom named in a message (`chatroomRef`): a chip that opens the
|
||||
* room, where the request and everything said about it is kept (a
|
||||
* workspace's room is sent on to its page inside the workspace).
|
||||
*/
|
||||
export default function ChatroomRefChip({ chatroom_id }: Props) {
|
||||
return (
|
||||
<Link
|
||||
href={`/admin/chatrooms/${chatroom_id}`}
|
||||
title="Open the chatroom"
|
||||
className="inline-flex h-6 gap-1.5 items-center px-1.5 align-middle rounded-default-xs border-0 bg-sunken text-[13px] font-bold leading-4 text-ink whitespace-nowrap hover:opacity-80"
|
||||
>
|
||||
<MessagesSquare size={13} aria-hidden />
|
||||
{`Chatroom ${chatroom_id}`}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Paper from "@moduletrace/twui/elements/Paper";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import CrewMessageLine from "./crew-message-line";
|
||||
|
||||
type Props = {
|
||||
group: MessageGroup;
|
||||
};
|
||||
|
||||
/**
|
||||
* What the coordinator brought back from a chatroom, on its own paper so
|
||||
* it stands apart from the conversation: text under the hue-less
|
||||
* coordinator stone, never a user bubble, though the bot answers it like
|
||||
* one.
|
||||
*/
|
||||
export default function CrewMessageGroup({ group }: Props) {
|
||||
return (
|
||||
<Paper className="w-full p-4 gap-0! items-stretch rounded-default bg-surface dark:bg-surface border-hairline dark:border-hairline">
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<BotStone size={28} label="" className="-mt-1 shrink-0" />
|
||||
<Stack className="min-w-0 grow gap-1! items-start">
|
||||
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
|
||||
From the coordinator
|
||||
</Span>
|
||||
{group.messages.map((message) => (
|
||||
<CrewMessageLine key={message.id} message={message} />
|
||||
))}
|
||||
</Stack>
|
||||
</Row>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { memo } from "react";
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BotMessageWithAttachments } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { MessageMarkdownComponents } from "../(data)/message-markdown-components";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
type Props = {
|
||||
message: BotMessageWithAttachments;
|
||||
};
|
||||
|
||||
/**
|
||||
* One chatroom outcome in the asking bot's conversation, rendered like a
|
||||
* bot's message (its room ref a chip that opens the room).
|
||||
*/
|
||||
function CrewMessageLine({ message }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(CrewMessageLine);
|
||||
@@ -14,7 +14,9 @@ import {
|
||||
} from "@/src/utils/link-computer-refs";
|
||||
import { parseFolderRefHref } from "@/src/utils/folder-ref";
|
||||
import { graphicRefId } from "@/src/utils/graphic-ref";
|
||||
import { chatroomRefId } from "@/src/utils/chatroom-ref";
|
||||
import BotRefChip from "./bot-ref-chip";
|
||||
import ChatroomRefChip from "./chatroom-ref-chip";
|
||||
import ComputerRefChip from "./computer-ref-chip";
|
||||
import FileRefChip from "./file-ref-chip";
|
||||
import FolderRefChip from "./folder-ref-chip";
|
||||
@@ -34,7 +36,8 @@ import WorkspaceRefChip from "./workspace-ref-chip";
|
||||
* folder it named a chip that opens it in the files browser, an MCP server,
|
||||
* tool, skill or hook it proposed a chip that opens the confirm card, a
|
||||
* secret it showed a masked chip, a secret it asked for a chip that opens
|
||||
* the card it's typed into, a graphic it made the graphic itself;
|
||||
* the card it's typed into, a graphic it made the graphic itself, a
|
||||
* chatroom a chip that opens it;
|
||||
* anything else opens in a new tab.
|
||||
*/
|
||||
export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
@@ -101,6 +104,10 @@ export default function MarkdownLink({ href, children }: ComponentProps<"a">) {
|
||||
if (request_id) return <SecretRequestRefChip request_id={request_id} />;
|
||||
}
|
||||
|
||||
const chatroom_id = chatroomRefId({ href });
|
||||
|
||||
if (chatroom_id) return <ChatroomRefChip chatroom_id={chatroom_id} />;
|
||||
|
||||
const graphic_id = graphicRefId({ href });
|
||||
|
||||
if (graphic_id) return <GraphicRefCard graphic_id={graphic_id} />;
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { MessageGroup } from "../(utils)/group-messages";
|
||||
import UserMessageGroup from "./user-message-group";
|
||||
import BotMessageGroup from "./bot-message-group";
|
||||
import SessionDivider from "./session-divider";
|
||||
import CrewMessageGroup from "./crew-message-group";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
@@ -15,7 +16,9 @@ type Props = {
|
||||
*/
|
||||
export default function MessageGroupBlock({ bot, group }: Props) {
|
||||
const block =
|
||||
group.author === "user" ? (
|
||||
group.author === "crew" ? (
|
||||
<CrewMessageGroup group={group} />
|
||||
) : group.author === "user" ? (
|
||||
<UserMessageGroup bot={bot} group={group} />
|
||||
) : (
|
||||
<BotMessageGroup bot={bot} group={group} />
|
||||
|
||||
@@ -272,11 +272,11 @@ export default function Conversation() {
|
||||
* rollover whose summary push was missed): the thread
|
||||
* moves there, and loads it so its opening shows too.
|
||||
*/
|
||||
if (message.author == "user" && startsNewSession(message)) {
|
||||
if (message.author == "user" && !message.from_chatroom_id && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
history.retry();
|
||||
} else if (message.author == "user") {
|
||||
} else if (message.author == "user" && !message.from_chatroom_id) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setHasOtherUnread(true);
|
||||
|
||||
@@ -47,4 +47,14 @@ describe("groupMessages", () => {
|
||||
[2, true],
|
||||
]);
|
||||
});
|
||||
test("a chatroom's outcome is its own group, never the user's", () => {
|
||||
const groups = groupMessages({
|
||||
messages: [
|
||||
{ id: 1, author: "user", text: "Ask Ada", created_at: 1, session_id: 1 },
|
||||
{ id: 2, author: "user", text: "Answer", created_at: 2, session_id: 1, from_chatroom_id: 4 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(groups.map((group) => group.author)).toEqual(["user", "crew"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { BotMessageWithAttachments } from "@/src/types";
|
||||
|
||||
export type MessageGroup = {
|
||||
key: string;
|
||||
author: "bot" | "user";
|
||||
/**
|
||||
* `crew`: a chatroom's outcome brought back to the bot (`from_chatroom_id`),
|
||||
* stored on the user's side but shown as from the coordinator.
|
||||
*/
|
||||
author: "bot" | "user" | "crew";
|
||||
messages: BotMessageWithAttachments[];
|
||||
/**
|
||||
* Time of the group's last message, shown once per group.
|
||||
@@ -34,7 +38,7 @@ export default function groupMessages({
|
||||
const groups: MessageGroup[] = [];
|
||||
|
||||
for (const message of messages) {
|
||||
const author = message.author === "user" ? "user" : "bot";
|
||||
const author = message.from_chatroom_id ? "crew" : message.author === "user" ? "user" : "bot";
|
||||
const at = Number(message.created_at || 0);
|
||||
const last = groups[groups.length - 1];
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomInPlace from "@/src/utils/chatroom-in-place";
|
||||
import mergeChatroom from "@/src/utils/merge-chatroom";
|
||||
|
||||
type Params = {
|
||||
chatrooms: ChatroomSummary[];
|
||||
/**
|
||||
* Inside a workspace, only its rooms join the list. Left out, every room.
|
||||
*/
|
||||
workspace_id?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page's rooms kept current from `server:chatroom-updated`.
|
||||
*/
|
||||
export default function useLiveChatrooms({ chatrooms, workspace_id }: Params) {
|
||||
const [live, setLive] = useState(chatrooms);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:chatroom-updated",
|
||||
onData: (room: ChatroomSummary) => {
|
||||
if (workspace_id && !chatroomInPlace({ chatroom: room, workspace_id })) return;
|
||||
|
||||
setLive((prev) => mergeChatroom({ chatrooms: prev, chatroom: room }));
|
||||
},
|
||||
});
|
||||
|
||||
return live;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import ChatroomStones from "@/src/components/general/chatroom-stones";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import chatroomHref from "@/src/utils/chatroom-href";
|
||||
|
||||
type Props = {
|
||||
chatroom: ChatroomSummary;
|
||||
/**
|
||||
* Name the workspace it's in (left out inside that workspace).
|
||||
*/
|
||||
show_workspace?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A chatroom in the list: the bots in it, the request, where it stands,
|
||||
* the workspace it's in and when it last moved.
|
||||
*/
|
||||
export default function ChatroomListRow({ chatroom, show_workspace }: Props) {
|
||||
const at = Number(chatroom.last_activity_at || chatroom.created_at || 0);
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<ChatroomStones bots={chatroom.bots} size={18} className="w-20" />}
|
||||
title={chatroom.title || "A request"}
|
||||
href={chatroomHref({ chatroom })}
|
||||
badge={chatroom.unread ? "New" : undefined}
|
||||
subtitle={[chatroomStatusLine(chatroom), show_workspace && chatroom.workspace_name, at ? relativeTime({ at }) : ""]
|
||||
.filter(Boolean)
|
||||
.join(" · ")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import useLiveChatrooms from "../(hooks)/use-live-chatrooms";
|
||||
import ChatroomListRow from "../(partials)/chatroom-list-row";
|
||||
|
||||
/**
|
||||
* The user's chatrooms (every one, or a workspace's inside it), open ones
|
||||
* first. A chatroom is the record of one bot asking another for something
|
||||
* through the coordinator.
|
||||
*/
|
||||
export default function ChatroomsList() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const workspace = pageProps?.workspace;
|
||||
const workspace_id = workspace?.id ? Number(workspace.id) : undefined;
|
||||
const chatrooms = useLiveChatrooms({ chatrooms: pageProps?.chatrooms || [], workspace_id });
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title="Chatrooms"
|
||||
description={
|
||||
workspace
|
||||
? `When a bot in ${workspace.name} needs another bot here, it asks the coordinator. Each request is kept here, read-only.`
|
||||
: "When a bot needs another bot, it asks the coordinator. Each request is kept here, read-only."
|
||||
}
|
||||
no_trail={Boolean(workspace)}
|
||||
>
|
||||
<SettingsGroup
|
||||
title="Requests"
|
||||
description="Open ones first. The answer goes back to the bot that asked, which carries on in its own conversation."
|
||||
>
|
||||
<SettingsList is_empty={!chatrooms.length} empty="No chatrooms yet.">
|
||||
{chatrooms.map((chatroom) => (
|
||||
<ChatroomListRow key={chatroom.id} chatroom={chatroom} show_workspace={!workspace} />
|
||||
))}
|
||||
</SettingsList>
|
||||
</SettingsGroup>
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { ChatroomIdRequest } from "@/src/schemas/bot-schema";
|
||||
|
||||
/**
|
||||
* Stops a chatroom. Its new state arrives over the WebSocket.
|
||||
*/
|
||||
export default async function stopChatroomRequest({ chatroom_id }: ChatroomIdRequest) {
|
||||
return await fetchApi<ChatroomIdRequest, APIResponseObject>(`/api/admin/chatrooms/stop`, {
|
||||
method: "POST",
|
||||
body: { chatroom_id },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { BotJobUpdateData, ChatroomJob } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
jobs: ChatroomJob[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The latest step of each job still open in the room, by room session, from
|
||||
* `server:bot-job-update` (tool steps only: a closing note is the message
|
||||
* itself, about to land).
|
||||
*/
|
||||
export default function useChatroomSteps({ jobs }: Params) {
|
||||
const [steps, setSteps] = useState<Record<number, string>>({});
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-job-update",
|
||||
onData: (update: BotJobUpdateData) => {
|
||||
if (!update.text || update.kind == "note" || !update.session_id) return;
|
||||
if (!jobs.some((job) => job.session_id == update.session_id)) return;
|
||||
|
||||
setSteps((prev) => ({ ...prev, [Number(update.session_id)]: update.text! }));
|
||||
},
|
||||
});
|
||||
|
||||
return steps;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { ChatroomMessageData, ChatroomSummary } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
chatroom: ChatroomSummary;
|
||||
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The open room kept current from server pushes: its state and bots
|
||||
* (`server:chatroom-updated`) and every new message in it
|
||||
* (`server:chatroom-message`), deduped by id.
|
||||
*/
|
||||
export default function useLiveChatroom({ chatroom, messages }: Params) {
|
||||
const [room, setRoom] = useState(chatroom);
|
||||
const [thread, setThread] = useState(messages);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:chatroom-updated",
|
||||
onData: (data: ChatroomSummary) => {
|
||||
if (data.id == room.id) setRoom(data);
|
||||
},
|
||||
});
|
||||
|
||||
useServerEvent({
|
||||
event: "server:chatroom-message",
|
||||
onData: (data: ChatroomMessageData) => {
|
||||
if (data.chatroom_id != room.id) return;
|
||||
|
||||
setThread((prev) =>
|
||||
prev.some((message) => message.id == data.message.id) ? prev : [...prev, data.message],
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return { room, thread };
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import ChatroomStone from "@/src/components/general/chatroom-stone";
|
||||
import type { ChatroomBot } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
bot: ChatroomBot;
|
||||
/**
|
||||
* What it does in the room: asked, passes it on, was asked.
|
||||
*/
|
||||
part: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* One bot of the room with its part in it.
|
||||
*/
|
||||
export default function ChatroomBotsRow({ bot, part }: Props) {
|
||||
return (
|
||||
<Row className="gap-2! flex-nowrap items-center">
|
||||
<ChatroomStone bot={bot} size={18} />
|
||||
<Span className="text-[13px] font-bold leading-4 text-ink">{bot.name || "A bot"}</Span>
|
||||
<Span className="text-[13px] leading-4 text-ink-secondary">{part}</Span>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import ChatroomStone from "@/src/components/general/chatroom-stone";
|
||||
import type { ChatroomBot } from "@/src/types";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
|
||||
/**
|
||||
* Kept module-level: a new array on every render makes `react-markdown`
|
||||
* parse the message again.
|
||||
*/
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
type Props = {
|
||||
message: BUN_SQLITE_COBBLE_BOT_MESSAGES;
|
||||
/**
|
||||
* The bot that posted it, null once it was deleted.
|
||||
*/
|
||||
bot?: ChatroomBot | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One message in a chatroom, under the stone and name of the bot that
|
||||
* posted it, rendered from markdown like a conversation's (raw HTML
|
||||
* never). Rooms are records: nothing here replies or reacts.
|
||||
*/
|
||||
export default function ChatroomMessageRow({ message, bot }: Props) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<Span className="inline-flex shrink-0 -mt-0.5">
|
||||
{bot ? <ChatroomStone bot={bot} size={28} /> : null}
|
||||
</Span>
|
||||
<Stack className="min-w-0 grow gap-1! items-start">
|
||||
<Row className="gap-2! flex-nowrap items-baseline">
|
||||
<Span className="text-sm font-bold leading-5 text-ink">{bot?.name || "A removed bot"}</Span>
|
||||
<Span className="text-[11px] leading-4 text-ink-secondary">
|
||||
{relativeTime({ at: Number(message.created_at || 0) })}
|
||||
</Span>
|
||||
</Row>
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
||||
{linkComputerRefs({ text: message.text || "" })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import Markdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
|
||||
import linkComputerRefs from "@/src/utils/link-computer-refs";
|
||||
import { MessageMarkdownComponents } from "@/src/pages/admin/bots/[bot_id]/(data)/message-markdown-components";
|
||||
|
||||
const RemarkPlugins = [remarkGfm];
|
||||
|
||||
type Props = {
|
||||
chatroom: ChatroomSummary;
|
||||
};
|
||||
|
||||
/**
|
||||
* How a closed room ended and what went back to the bot that asked.
|
||||
*/
|
||||
export default function ChatroomOutcome({ chatroom }: Props) {
|
||||
if (chatroom.status == "open") return null;
|
||||
|
||||
return (
|
||||
<Stack className="w-full gap-2! items-start rounded-default border border-solid border-hairline bg-surface px-4 py-3">
|
||||
<Span className="text-[11px] font-bold uppercase tracking-wide leading-4 text-ink-secondary">
|
||||
{chatroomStatusLine(chatroom)}
|
||||
</Span>
|
||||
{chatroom.outcome?.trim() ? (
|
||||
<Stack className="w-full gap-2.5! items-start break-words">
|
||||
<Markdown remarkPlugins={RemarkPlugins} components={MessageMarkdownComponents}>
|
||||
{linkComputerRefs({ text: chatroom.outcome })}
|
||||
</Markdown>
|
||||
</Stack>
|
||||
) : null}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import ChatroomStone from "@/src/components/general/chatroom-stone";
|
||||
import type { ChatroomBotStatus } from "@/src/utils/chatroom-bot-statuses";
|
||||
|
||||
/**
|
||||
* Where one bot of an open room stands, with its latest step while it
|
||||
* works.
|
||||
*/
|
||||
export default function ChatroomStatusRow({ bot, label, detail, active }: ChatroomBotStatus) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-center">
|
||||
<ChatroomStone bot={bot} size={24} face={active ? "working" : "idle"} />
|
||||
<Stack className="min-w-0 gap-0! items-start">
|
||||
<Span
|
||||
className={twMerge(
|
||||
"text-sm leading-5 text-ink-secondary",
|
||||
active && "animate-pulse motion-reduce:animate-none",
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Span>
|
||||
{detail ? (
|
||||
<Span className="w-full text-xs leading-4 text-ink-secondary truncate">{detail}</Span>
|
||||
) : null}
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useState } from "react";
|
||||
import { Square } from "lucide-react";
|
||||
import HeaderIconButton from "@/src/components/general/header-icon-button";
|
||||
import stopChatroomRequest from "../(functions)/stop-chatroom-request";
|
||||
|
||||
type Props = {
|
||||
chatroom_id: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Stops an open room after a confirm: everything in it that could still
|
||||
* run is cancelled and the bot that asked is told.
|
||||
*/
|
||||
export default function ChatroomStopButton({ chatroom_id }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const stop = async () => {
|
||||
if (!window.confirm(`Stop this request? The bot that asked is told it was stopped.`)) return;
|
||||
|
||||
setLoading(true);
|
||||
const res = await stopChatroomRequest({ chatroom_id }).catch(() => null);
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success) window.alert(res?.msg || `Couldn't stop it.`);
|
||||
};
|
||||
|
||||
return <HeaderIconButton icon={Square} title="Stop this request" loading={loading} onClick={stop} />;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useContext } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import { CrewBotsContext } from "@/src/pages/admin/bots/[bot_id]/(data)/crew-bots-context";
|
||||
import type { BUN_SQLITE_COBBLE_BOT_MESSAGES } from "@/db/types/db";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomStatusLine from "@/src/utils/chatroom-status-line";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import useLiveChatroom from "../(hooks)/use-live-chatroom";
|
||||
import ChatroomBotsRow from "../(partials)/chatroom-bots-row";
|
||||
import ChatroomMessageRow from "../(partials)/chatroom-message-row";
|
||||
import ChatroomOutcome from "../(partials)/chatroom-outcome";
|
||||
import ChatroomStopButton from "../(partials)/chatroom-stop-button";
|
||||
import ChatroomStatus from "./chatroom-status";
|
||||
|
||||
type Props = {
|
||||
chatroom: ChatroomSummary;
|
||||
messages: BUN_SQLITE_COBBLE_BOT_MESSAGES[];
|
||||
};
|
||||
|
||||
/**
|
||||
* The open room, live: who is in it and their part, the thread and the
|
||||
* outcome, where each bot stands while it's open, with Stop for anyone who can do more than read.
|
||||
*/
|
||||
export default function ChatroomBody({ chatroom, messages }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { room, thread } = useLiveChatroom({ chatroom, messages });
|
||||
|
||||
const can_stop = room.status == "open" && !isReadOnlyUser({ user_types: pageProps?.user_types || [] });
|
||||
const bots = new Map(room.bots.map((bot) => [Number(bot.id), bot]));
|
||||
|
||||
return (
|
||||
<CrewBotsContext.Provider value={pageProps?.bots || []}>
|
||||
<SettingsShell
|
||||
title={room.title || "Chatroom"}
|
||||
description={`${chatroomStatusLine(room)}. A request between bots, through the coordinator. Read-only.`}
|
||||
action={can_stop ? <ChatroomStopButton chatroom_id={Number(room.id)} /> : undefined}
|
||||
no_trail={Boolean(room.workspace_id)}
|
||||
>
|
||||
<Row className="w-full gap-x-6! gap-y-2! py-5 items-center">
|
||||
{room.bots.map((bot) => (
|
||||
<ChatroomBotsRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
part={
|
||||
bot.id == room.requester_bot_id
|
||||
? "asked"
|
||||
: bot.kind == "coordinator"
|
||||
? "passes it on"
|
||||
: "was asked"
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Row>
|
||||
<Stack className="w-full gap-6! items-stretch py-2">
|
||||
{thread.map((message) => (
|
||||
<ChatroomMessageRow
|
||||
key={message.id}
|
||||
message={message}
|
||||
bot={bots.get(Number(message.bot_id)) || null}
|
||||
/>
|
||||
))}
|
||||
<ChatroomOutcome chatroom={room} />
|
||||
<ChatroomStatus room={room} />
|
||||
</Stack>
|
||||
</SettingsShell>
|
||||
</CrewBotsContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import type { ChatroomSummary } from "@/src/types";
|
||||
import chatroomBotStatuses from "@/src/utils/chatroom-bot-statuses";
|
||||
import useChatroomSteps from "../(hooks)/use-chatroom-steps";
|
||||
import ChatroomStatusRow from "../(partials)/chatroom-status-row";
|
||||
|
||||
type Props = {
|
||||
room: ChatroomSummary;
|
||||
};
|
||||
|
||||
/**
|
||||
* Under the thread while the room is open: one row per bot saying where it
|
||||
* stands (waiting for the answer, thinking, working with its latest step,
|
||||
* waiting on another, done).
|
||||
*/
|
||||
export default function ChatroomStatus({ room }: Props) {
|
||||
const steps = useChatroomSteps({ jobs: room.jobs || [] });
|
||||
const statuses = chatroomBotStatuses({ room, steps });
|
||||
|
||||
if (!statuses.length) return null;
|
||||
|
||||
return (
|
||||
<Stack role="status" className="w-full gap-3! items-stretch pt-2">
|
||||
{statuses.map((status) => (
|
||||
<ChatroomStatusRow key={status.bot.id} {...status} />
|
||||
))}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useContext } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import ChatroomBody from "./chatroom-body";
|
||||
|
||||
/**
|
||||
* A chatroom, read-only: who is in it, every bot's messages oldest first
|
||||
* and how it ended. Nobody writes here; to steer the coordinator, the
|
||||
* super admin talks to it directly. An open room can be stopped.
|
||||
*/
|
||||
export default function ChatroomView() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const chatroom = pageProps?.chatroom;
|
||||
|
||||
if (!chatroom?.id) return null;
|
||||
|
||||
return <ChatroomBody key={chatroom.id} chatroom={chatroom} messages={pageProps?.chatroom_messages || []} />;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabChatroomMessages from "@/src/functions/backend/db/chatrooms/grab-chatroom-messages";
|
||||
import grabUserChatroom from "@/src/functions/backend/db/chatrooms/grab-user-chatroom";
|
||||
import markChatroomRead from "@/src/functions/backend/db/chatrooms/mark-chatroom-read";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
import chatroomHref from "@/src/utils/chatroom-href";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* One of the user's chatrooms asked from a bot's own chat (a workspace's
|
||||
* opens inside the workspace), read-only: the request, every bot's
|
||||
* messages in it and its outcome. Opening it marks it read.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const chatroom = await grabUserChatroom({ chatroom_id: Number(query.chatroom_id) || 0, user_id: user.id });
|
||||
|
||||
if (!chatroom?.id) {
|
||||
return { redirect: { destination: "/admin/chatrooms" } };
|
||||
}
|
||||
|
||||
/**
|
||||
* A workspace's room opens inside the workspace, with its rail.
|
||||
*/
|
||||
if (chatroom.workspace_id) {
|
||||
return { redirect: { destination: chatroomHref({ chatroom }) } };
|
||||
}
|
||||
|
||||
await markChatroomRead({ chatroom_id: chatroom.id, user_id: user.id });
|
||||
|
||||
const [bots, workspaces, chatroom_messages] = await Promise.all([
|
||||
grabBots({ user_id: user.id }),
|
||||
grabUserWorkspaces({ user_id: user.id }),
|
||||
grabChatroomMessages({ chatroom_id: chatroom.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
bots: railBots({ bots }),
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
chatroom: { ...chatroom, unread: 0 },
|
||||
chatroom_messages,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import ChatroomView from "./(sections)/chatroom-view";
|
||||
|
||||
export default function ChatroomPage() {
|
||||
return <ChatroomView />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const chatroom = (params.serverRes?.props as PagePropsType | undefined)?.chatroom;
|
||||
|
||||
return {
|
||||
title: `${chatroom?.title || "Chatroom"} | ${SiteData["SiteName"]}`,
|
||||
description: `A request between bots, through the coordinator.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,33 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabUserChatrooms from "@/src/functions/backend/db/chatrooms/grab-user-chatrooms";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import railBots from "@/src/utils/rail-bots";
|
||||
import railWorkspaces from "@/src/utils/rail-workspaces";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Every chatroom of the user's, open ones first, beside the crew rail.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const [bots, workspaces, chatrooms] = await Promise.all([
|
||||
grabBots({ user_id: user.id }),
|
||||
grabUserWorkspaces({ user_id: user.id }),
|
||||
grabUserChatrooms({ user_id: user.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
bots: railBots({ bots }),
|
||||
workspaces: railWorkspaces({ workspaces }),
|
||||
chatrooms,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import ChatroomsList from "./(sections)/chatrooms-list";
|
||||
|
||||
export default function ChatroomsPage() {
|
||||
return <ChatroomsList />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async () => ({
|
||||
title: `Chatrooms | ${SiteData["SiteName"]}`,
|
||||
description: `Requests between bots, through the coordinator.`,
|
||||
});
|
||||
@@ -85,6 +85,17 @@ The computer keeps the session, like everything else in its browser (extensions,
|
||||
3. Click **Hand back to** the bot. It gets a message that you're done and carries on.
|
||||
|
||||
A task that hit the wall stops and reports instead of waiting, so after you hand back the bot picks the work up again from the chat.`,
|
||||
},
|
||||
{
|
||||
id: "chatrooms",
|
||||
title: "What is a chatroom?",
|
||||
summary: "A chatroom showed up in the rail, or a bot says it asked another bot.",
|
||||
body: `When a bot needs something another bot is better placed to give (what it knows, a file it has, work in its line), it doesn't ask that bot directly. It asks the coordinator, which reads the request, picks who should answer, asks them and sends the answer back. Each of those exchanges is kept in a **chatroom**.
|
||||
|
||||
- **Where:** with the conversation it came from. A request made in a workspace is listed under Chatrooms inside that workspace, after its bots; one made in a bot's own chat is listed under Chatrooms after the workspaces. The chip in the bot's message opens it too.
|
||||
- **Who can be asked:** in a workspace only the workspace's bots, otherwise any bot you work with. The work runs as you, with your access.
|
||||
- **Read-only:** nobody writes in a chatroom. To steer the coordinator, talk to it in its own chat (super admin only). You can **Stop** an open request from the room's header.
|
||||
- **What happens after:** the answer appears in the asking bot's conversation as a message from the coordinator, and the bot carries on from there.`,
|
||||
},
|
||||
{
|
||||
id: "graphics",
|
||||
|
||||
@@ -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 grabChatroomRetentionOverview from "@/src/functions/backend/db/app-settings/grab-chatroom-retention";
|
||||
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";
|
||||
@@ -52,6 +53,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),
|
||||
chatroom_retention: await grabChatroomRetentionOverview().catch(() => null),
|
||||
session_length: await grabSessionLengthOverview().catch(() => null),
|
||||
running_version: runningVersion({ env: process.env }),
|
||||
},
|
||||
|
||||
@@ -17,6 +17,12 @@ type Props = {
|
||||
* Controls at the end of the pane header, e.g. a settings link.
|
||||
*/
|
||||
action?: ReactNode;
|
||||
/**
|
||||
* Leave out the breadcrumb trail: inside a workspace it would name the
|
||||
* workspace by its id, and the workspace's rail already says where the
|
||||
* page is.
|
||||
*/
|
||||
no_trail?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -30,6 +36,7 @@ export default function SettingsShell({
|
||||
children,
|
||||
wide,
|
||||
action,
|
||||
no_trail,
|
||||
}: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
@@ -52,10 +59,14 @@ export default function SettingsShell({
|
||||
* address would say the id of a record, or a slug that
|
||||
* differs from the name the page shows.
|
||||
*/}
|
||||
<AdminBreadcrumbs
|
||||
pathname={pageProps?.url?.pathname}
|
||||
title={title}
|
||||
/>
|
||||
{no_trail ? (
|
||||
<Stack className="w-full shrink-0 pt-5" />
|
||||
) : (
|
||||
<AdminBreadcrumbs
|
||||
pathname={pageProps?.url?.pathname}
|
||||
title={title}
|
||||
/>
|
||||
)}
|
||||
{children}
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
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 { ChatroomRetentionOverview } from "@/src/types";
|
||||
import SettingsSaveBar from "../../(partials)/settings-save-bar";
|
||||
|
||||
type Props = {
|
||||
retention?: ChatroomRetentionOverview | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* How many days closed chatrooms are kept and how many rooms one place
|
||||
* keeps, saved together without a reload. Zero puts a default back.
|
||||
*/
|
||||
export default function ChatroomRetentionForm({ retention }: Props) {
|
||||
const [overview, setOverview] = useState(retention || undefined);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [keep_days, setKeepDays] = useState(String(retention?.keep_days ?? ""));
|
||||
const [max_per_place, setMaxPerPlace] = useState(String(retention?.max_per_place ?? ""));
|
||||
/**
|
||||
* The inputs only read `defaultValue` when they mount, so discarding or
|
||||
* saving remounts them with the saved numbers.
|
||||
*/
|
||||
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 long chatrooms are kept? Closed rooms past either limit are deleted, the oldest first.",
|
||||
)
|
||||
)
|
||||
return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await fetchApi<{}, APIResponseObject<ChatroomRetentionOverview>>(
|
||||
"/api/admin/settings/chatroom-retention",
|
||||
{
|
||||
method: "POST",
|
||||
body: { keep_days: Number(keep_days) || 0, max_per_place: Number(max_per_place) || 0 },
|
||||
},
|
||||
).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res?.msg || `Couldn't save it.` });
|
||||
return;
|
||||
}
|
||||
|
||||
setOverview(res.singleRes);
|
||||
setKeepDays(String(res.singleRes.keep_days));
|
||||
setMaxPerPlace(String(res.singleRes.max_per_place));
|
||||
setFormKey((key) => key + 1);
|
||||
setDirty(false);
|
||||
setStatus({
|
||||
success: true,
|
||||
msg: `Saved: closed chatrooms are kept for ${res.singleRes.keep_days} days, ${res.singleRes.max_per_place} rooms per place.`,
|
||||
});
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
setKeepDays(String(overview?.keep_days ?? ""));
|
||||
setMaxPerPlace(String(overview?.max_per_place ?? ""));
|
||||
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={`keep-${form_key}`}
|
||||
label="Keep closed chatrooms for"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max_days}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={keep_days}
|
||||
onChange={(event) => {
|
||||
setKeepDays(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix="days"
|
||||
info={`Older closed rooms are deleted once a day. 0 uses the default (${overview?.default_days ?? ""}).`}
|
||||
/>
|
||||
<Input
|
||||
key={`max-${form_key}`}
|
||||
label="Most chatrooms per place"
|
||||
type="number"
|
||||
min={0}
|
||||
max={overview?.max_per_place_ceiling}
|
||||
step={1}
|
||||
numberText
|
||||
rawNumber
|
||||
defaultValue={max_per_place}
|
||||
onChange={(event) => {
|
||||
setMaxPerPlace(event.target.value);
|
||||
setDirty(true);
|
||||
setStatus(undefined);
|
||||
}}
|
||||
suffix="rooms"
|
||||
info={`Each workspace, and bots' own chats, keeps at most this many rooms; past it the oldest closed ones are deleted. 0 uses the default (${overview?.default_per_place ?? ""}).`}
|
||||
/>
|
||||
<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 ChatroomRetentionForm from "../(partials)/chatroom-retention-form";
|
||||
|
||||
/**
|
||||
* How long closed chatrooms are kept and how many one place keeps. Only the
|
||||
* super admin sees it, since it changes every user's rooms.
|
||||
*/
|
||||
export default function ChatroomRetentionSection() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
|
||||
if (!isPassThroughUser({ user_types: pageProps?.user_types })) return null;
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Chatrooms"
|
||||
description="Chatrooms are records of bots asking each other through the coordinator. Closed ones are deleted after a while, and each workspace (and bots' own chats) keeps only so many; open rooms are never deleted. What a room answered stays in the asking bot's conversation."
|
||||
id="chatrooms"
|
||||
>
|
||||
<ChatroomRetentionForm retention={pageProps?.chatroom_retention} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import AppearanceSection from "./(sections)/appearance-section";
|
||||
import ChatroomRetentionSection from "./(sections)/chatroom-retention-section";
|
||||
import HookCallRetentionSection from "./(sections)/hook-call-retention-section";
|
||||
import InstallIconSection from "./(sections)/install-icon-section";
|
||||
import InstallNameSection from "./(sections)/install-name-section";
|
||||
@@ -27,6 +28,7 @@ export default function AdminSettingsGeneralPage() {
|
||||
<SessionLengthSection />
|
||||
<SkillLimitsSection />
|
||||
<HookCallRetentionSection />
|
||||
<ChatroomRetentionSection />
|
||||
<StorageSection />
|
||||
<VersionSection />
|
||||
</SettingsShell>
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabChatroomMessages from "@/src/functions/backend/db/chatrooms/grab-chatroom-messages";
|
||||
import grabUserChatroom from "@/src/functions/backend/db/chatrooms/grab-user-chatroom";
|
||||
import markChatroomRead from "@/src/functions/backend/db/chatrooms/mark-chatroom-read";
|
||||
import grabWorkspaceRailProps from "@/src/functions/backend/db/workspaces/grab-workspace-rail-props";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import chatroomHref from "@/src/utils/chatroom-href";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* One of the user's chatrooms asked in this workspace, read-only, beside
|
||||
* the workspace's rail. A room from elsewhere opens where it belongs.
|
||||
* Opening it marks it read.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const chatroom = await grabUserChatroom({ chatroom_id: Number(query.chatroom_id) || 0, user_id: user.id });
|
||||
|
||||
if (!chatroom?.id) {
|
||||
return { redirect: { destination: `/admin/workspaces/${query.workspace_id}/chatrooms` } };
|
||||
}
|
||||
|
||||
if (String(chatroom.workspace_id || "") != String(query.workspace_id)) {
|
||||
return { redirect: { destination: chatroomHref({ chatroom }) } };
|
||||
}
|
||||
|
||||
const rail = await grabWorkspaceRailProps({ user_id: user.id, workspace_id: chatroom.workspace_id! });
|
||||
|
||||
if (!rail) {
|
||||
return { redirect: { destination: "/admin/chatrooms" } };
|
||||
}
|
||||
|
||||
await markChatroomRead({ chatroom_id: chatroom.id, user_id: user.id });
|
||||
|
||||
return {
|
||||
props: {
|
||||
...rail,
|
||||
chatroom: { ...chatroom, unread: 0 },
|
||||
chatroom_messages: await grabChatroomMessages({ chatroom_id: chatroom.id }),
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import ChatroomView from "@/src/pages/admin/chatrooms/[chatroom_id]/(sections)/chatroom-view";
|
||||
|
||||
export default function WorkspaceChatroomPage() {
|
||||
return <ChatroomView />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const props = params.serverRes?.props as PagePropsType | undefined;
|
||||
|
||||
return {
|
||||
title: `${props?.chatroom?.title || "Chatroom"} | ${props?.workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||
description: `A request between this workspace's bots, through the coordinator.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserChatrooms from "@/src/functions/backend/db/chatrooms/grab-user-chatrooms";
|
||||
import grabWorkspaceRailProps from "@/src/functions/backend/db/workspaces/grab-workspace-rail-props";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A workspace's chatrooms, open ones first, beside the workspace's rail.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const rail = await grabWorkspaceRailProps({ user_id: user.id, workspace_id: query.workspace_id || 0 });
|
||||
|
||||
if (!rail) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
}
|
||||
|
||||
const chatrooms = await grabUserChatrooms({ user_id: user.id, workspace_id: Number(rail.workspace.id) });
|
||||
|
||||
return { props: { ...rail, chatrooms } };
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import ChatroomsList from "@/src/pages/admin/chatrooms/(sections)/chatrooms-list";
|
||||
|
||||
export default function WorkspaceChatroomsPage() {
|
||||
return <ChatroomsList />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const workspace = (params.serverRes?.props as PagePropsType | undefined)?.workspace;
|
||||
|
||||
return {
|
||||
title: `Chatrooms | ${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||
description: `Requests between this workspace's bots, through the coordinator.`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user