Add workspaces, conversation sessions, app-managed computers and pictures
- Rail: Add icon with stacked form modals (bot, workspace, computer), coordinator, recent bots with "See all bots", Workspaces section, and a workspace mode listing only its crew - Workspaces: page for context, bots (per-workspace instructions and memory), computers (default, link, unlink), models and delete; conversations scoped per bot per workspace - Sessions: conversations split into searchable, reopenable sessions with carry-over summaries; sessions and new-session header icons - Computers: created and provisioned over the docker socket, home dir kept at DATA_DIR/computers/<id> after delete; super admins only - Models: workspace settings win over a bot's own, then the defaults - Pictures for bots and workspaces; bots editable from the header - Coordinator tools: update_workspace, set_bot_workspace_instructions Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
|
||||
type Params = {
|
||||
/**
|
||||
* Under `/api/admin/bots/`.
|
||||
*/
|
||||
path: "list-sessions" | "new-session" | "reopen-session";
|
||||
body: Record<string, unknown>;
|
||||
};
|
||||
|
||||
/**
|
||||
* Posts to a conversation-session API route.
|
||||
*/
|
||||
export default async function postSessionApi<T extends { [k: string]: any }>({ path, body }: Params) {
|
||||
return await fetchApi<Record<string, unknown>, APIResponseObject<T>>(`/api/admin/bots/${path}`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type { BotMessageWithAttachments } from "@/src/types";
|
||||
|
||||
export default async function sendBotMessage({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids,
|
||||
}: SendBotMessageRequest) {
|
||||
@@ -13,6 +14,6 @@ export default async function sendBotMessage({
|
||||
APIResponseObject<BotMessageWithAttachments>
|
||||
>(`/api/admin/bots/send-message`, {
|
||||
method: "POST",
|
||||
body: { bot_id, text, media_ids },
|
||||
body: { bot_id, workspace_id, text, media_ids },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,15 +4,15 @@ import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import type { SetBotComputerRequest } from "@/src/schemas/bot-schema";
|
||||
|
||||
/**
|
||||
* Switches the computer a bot works on. Returns its computers, the new
|
||||
* one first.
|
||||
* Switches the computer a bot works on (in a workspace, the workspace's
|
||||
* default). Returns its computers, the new one first.
|
||||
*/
|
||||
export default async function setBotComputer({ bot_id, computer_id }: SetBotComputerRequest) {
|
||||
export default async function setBotComputer({ bot_id, computer_id, workspace_id }: SetBotComputerRequest) {
|
||||
return await fetchApi<
|
||||
SetBotComputerRequest,
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
|
||||
>(`/api/admin/bots/set-computer`, {
|
||||
method: "POST",
|
||||
body: { bot_id, computer_id },
|
||||
body: { bot_id, computer_id, workspace_id },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,6 +33,10 @@ export type BotComputers = {
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
/**
|
||||
* In a workspace, switching changes the workspace's default computer.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
computers: BUN_SQLITE_COBBLE_COMPUTERS[];
|
||||
};
|
||||
|
||||
@@ -42,7 +46,7 @@ type Params = {
|
||||
* arrives as `server:bot-updated` with the bot's `computer_id`. Also holds
|
||||
* the computer open in the viewer (`ComputerViewerModal`).
|
||||
*/
|
||||
export default function useBotComputers({ bot_id, computers: initial }: Params): BotComputers {
|
||||
export default function useBotComputers({ bot_id, workspace_id, computers: initial }: Params): BotComputers {
|
||||
const [computers, setComputers] = useState(initial);
|
||||
const [switching_id, setSwitchingId] = useState<number>();
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -71,7 +75,7 @@ export default function useBotComputers({ bot_id, computers: initial }: Params):
|
||||
setSwitchingId(computer_id);
|
||||
setError(undefined);
|
||||
|
||||
const res = await setBotComputer({ bot_id, computer_id }).catch(() => null);
|
||||
const res = await setBotComputer({ bot_id, computer_id, workspace_id }).catch(() => null);
|
||||
|
||||
if (res?.success && res.payload?.length) {
|
||||
setComputers(res.payload);
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import postSessionApi from "../(functions)/post-session-api";
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* Loads only while the modal is open.
|
||||
*/
|
||||
open: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's sessions, latest first, searched as the user types
|
||||
* (debounced). Starting or reopening a session reloads the page onto it.
|
||||
*/
|
||||
export default function useBotSessions({ bot_id, workspace_id, open }: Params) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [sessions, setSessions] = useState<BotSessionListItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busy_id, setBusyId] = useState<number | "new">();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
let stale = false;
|
||||
const timer = setTimeout(async () => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await postSessionApi<BotSessionListItem>({
|
||||
path: "list-sessions",
|
||||
body: { bot_id, workspace_id, query: query.trim() || undefined },
|
||||
}).catch(() => null);
|
||||
|
||||
if (stale) return;
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (res?.success) {
|
||||
setSessions(res.payload || []);
|
||||
setError(undefined);
|
||||
} else {
|
||||
setError(res?.msg || "Couldn't load the sessions.");
|
||||
}
|
||||
}, query ? 250 : 0);
|
||||
|
||||
return () => {
|
||||
stale = true;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [open, query, bot_id, workspace_id]);
|
||||
|
||||
const act = async ({ id, path, body }: { id: number | "new"; path: "new-session" | "reopen-session"; body: Record<string, unknown> }) => {
|
||||
if (busy_id) return;
|
||||
|
||||
setBusyId(id);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postSessionApi({ path, body }).catch(() => null);
|
||||
|
||||
if (res?.success) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(undefined);
|
||||
setError(res?.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return {
|
||||
query,
|
||||
setQuery,
|
||||
sessions,
|
||||
loading,
|
||||
busy_id,
|
||||
error,
|
||||
startSession: () => act({ id: "new", path: "new-session", body: { bot_id, workspace_id } }),
|
||||
reopenSession: ({ session_id }: { session_id: number }) =>
|
||||
act({ id: session_id, path: "reopen-session", body: { session_id } }),
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,10 @@ import type { BotJobUpdateData, BotTaskWithSteps, BotUpdatedData } from "@/src/t
|
||||
|
||||
type Params = {
|
||||
bot_id: number;
|
||||
/**
|
||||
* The open session: steps of jobs from other sessions are left out.
|
||||
*/
|
||||
session_id?: number | null;
|
||||
/**
|
||||
* The bot's active task when the page loaded.
|
||||
*/
|
||||
@@ -27,13 +31,14 @@ function fromTask({ task }: { task: BotTaskWithSteps | null }): Progress {
|
||||
* row's text), kept current from `server:bot-job-update`. Starts over when
|
||||
* `server:bot-updated` brings a different task, or none.
|
||||
*/
|
||||
export default function useTaskProgress({ bot_id, task }: Params) {
|
||||
export default function useTaskProgress({ bot_id, session_id, task }: Params) {
|
||||
const [progress, setProgress] = useState<Progress>(() => fromTask({ task }));
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-job-update",
|
||||
onData: (update: BotJobUpdateData) => {
|
||||
if (update.bot_id != bot_id || !update.task_id || !update.text) return;
|
||||
if (session_id && update.session_id && update.session_id != session_id) return;
|
||||
|
||||
setProgress({ task_id: update.task_id, text: update.text });
|
||||
},
|
||||
|
||||
@@ -11,6 +11,11 @@ import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* In a workspace the bot uses the workspace's computers, and a switch
|
||||
* changes the workspace's default for every bot there.
|
||||
*/
|
||||
workspace_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,7 +23,7 @@ type Props = {
|
||||
* computers are, the one it's on checked, and with more than one,
|
||||
* switches it. Each can be opened in the viewer.
|
||||
*/
|
||||
export default function BotComputersModal({ bot }: Props) {
|
||||
export default function BotComputersModal({ bot, workspace_name }: Props) {
|
||||
const { computers, switching_id, error, switchComputer, openComputer } =
|
||||
useContext(BotComputersContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -52,9 +57,13 @@ export default function BotComputersModal({ bot }: Props) {
|
||||
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one. Open one to use it yourself.`}
|
||||
</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{can_switch
|
||||
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
|
||||
: `This is the only computer ${name} can use right now.`}
|
||||
{workspace_name
|
||||
? can_switch
|
||||
? `These are ${workspace_name}'s computers. Picking another makes it the default for every bot working here.`
|
||||
: `This is ${workspace_name}'s only computer. Add more on the workspace's page.`
|
||||
: can_switch
|
||||
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
|
||||
: `This is the only computer ${name} can use right now.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack
|
||||
|
||||
@@ -3,9 +3,15 @@ import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
|
||||
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
|
||||
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* In a workspace the form saves the workspace's models, for every bot
|
||||
* working there.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* A worker's `grabBotHarnessOverview`; for the coordinator, the
|
||||
* defaults (`grabHarnessOverview`).
|
||||
@@ -23,7 +29,7 @@ type Props = {
|
||||
* runs on the defaults, so it saves the defaults themselves, which only a
|
||||
* super admin can.
|
||||
*/
|
||||
export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Props) {
|
||||
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
/**
|
||||
* Remounts the form when the data changes under it.
|
||||
@@ -44,6 +50,20 @@ export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Pro
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
if (workspace_id) {
|
||||
const res = await saveWorkspaceHarnessSettings({
|
||||
workspace_id,
|
||||
settings: settings.map((setting) => ({
|
||||
...setting,
|
||||
inherit: Boolean(setting.inherit),
|
||||
})),
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
if (bot.kind == "coordinator") {
|
||||
const res = await postHarnessApi({
|
||||
path: "save-settings",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
@@ -18,7 +18,8 @@ type Props = {
|
||||
export default function BotMessageGroup({ bot, group }: Props) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<BotStone
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={28}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { CpuAi } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotHarnessForm from "./bot-harness-form";
|
||||
@@ -12,6 +13,10 @@ import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in: the modal edits its models.
|
||||
*/
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
harnesses?: HarnessOverview[] | null;
|
||||
onSaved: (harnesses: HarnessOverview[]) => void;
|
||||
};
|
||||
@@ -22,13 +27,14 @@ type Props = {
|
||||
* the coordinator that form is the defaults, for super admins only;
|
||||
* everyone else gets the icon disabled, its title saying why.
|
||||
*/
|
||||
export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!harnesses?.length || !bot.id) return null;
|
||||
|
||||
const is_coordinator = bot.kind == "coordinator";
|
||||
const in_workspace = Boolean(workspace?.id);
|
||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
||||
const can_edit =
|
||||
!is_coordinator ||
|
||||
isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
@@ -39,7 +45,9 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
icon={CpuAi}
|
||||
title={
|
||||
can_edit
|
||||
? is_coordinator
|
||||
? in_workspace
|
||||
? `Change ${workspace?.name || "the workspace"}'s models`
|
||||
: is_coordinator
|
||||
? "Change the default models"
|
||||
: `Change ${name}'s models`
|
||||
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
|
||||
@@ -60,15 +68,26 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch pb-2">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{is_coordinator ? "Default models" : `${name}'s models`}
|
||||
{in_workspace
|
||||
? `Models in ${workspace?.name || "this workspace"}`
|
||||
: is_coordinator
|
||||
? "Default models"
|
||||
: `${name}'s models`}
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{is_coordinator
|
||||
{in_workspace
|
||||
? `Every bot working in ${workspace?.name || "this workspace"} uses these, the coordinator too. Left on "Same as the default", ${name} uses its own models, else the defaults.`
|
||||
: is_coordinator
|
||||
? `${name} and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it. Providers are in Settings, under Models.`
|
||||
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<BotHarnessForm bot={bot} harnesses={harnesses} onSaved={onSaved} />
|
||||
<BotHarnessForm
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
harnesses={harnesses}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's avatar: opens the bot's profile (picture, name, job,
|
||||
* colour, instructions) to edit.
|
||||
*/
|
||||
export default function BotProfileModal({ bot }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
title={`Edit ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
aria-label={`Edit ${name}`}
|
||||
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{`Edit ${name}`}</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{bot.kind == "coordinator"
|
||||
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
|
||||
: `${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}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
session: BotSessionListItem;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One session in the sessions modal: its title, the summary or last
|
||||
* message, how long and how recent. The active one says so; any other
|
||||
* reopens on click.
|
||||
*/
|
||||
export default function BotSessionRow({ session, loading, disabled, onOpen }: Props) {
|
||||
const active = session.status == "active";
|
||||
const preview = session.summary || session.last_message_text || "";
|
||||
const at = session.last_activity_at || session.created_at;
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={active ? "This is the current session" : `Reopen "${session.title || "Untitled"}"`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={loading}
|
||||
disabled={active || disabled}
|
||||
onClick={onOpen}
|
||||
className={twMerge(
|
||||
"w-full justify-start! items-start! gap-0! px-3! py-2.5! rounded-default text-left text-ink dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
|
||||
active && "bg-ink/5 opacity-100!",
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full min-w-0 gap-0.5! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="grow min-w-0 truncate text-sm font-bold leading-5 text-ink">
|
||||
{session.title || "Untitled session"}
|
||||
</Span>
|
||||
{session.unread && !active ? (
|
||||
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span>
|
||||
) : null}
|
||||
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
|
||||
{active ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
|
||||
</Span>
|
||||
</Row>
|
||||
<Span className="w-full truncate text-xs leading-4 text-ink-secondary">
|
||||
{`${session.message_count} message${session.message_count == 1 ? "" : "s"}`}
|
||||
{preview ? ` · ${messagePreview({ text: preview })}` : ""}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { History, Plus } from "lucide-react";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useBotSessions from "../(hooks)/use-bot-sessions";
|
||||
import BotSessionRow from "./bot-session-row";
|
||||
import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* A bot message arrived in another session of this conversation.
|
||||
*/
|
||||
has_other_unread?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's sessions icon. A conversation is split into sessions so a
|
||||
* bot's context never grows without bound; this lists them, searches
|
||||
* them, starts a fresh one, or reopens an old one to carry on.
|
||||
*/
|
||||
export default function BotSessionsModal({ bot, workspace_id, has_other_unread }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sessions = useBotSessions({ bot_id: Number(bot.id), workspace_id, open });
|
||||
const name = bot.name || "the bot";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<HeaderIconButton
|
||||
icon={History}
|
||||
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
|
||||
/>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
|
||||
<Stack className="min-w-0 gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Sessions</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`Each session keeps ${name}'s context short. A new one starts with a summary of the last; reopen any to pick up where you left off.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Button
|
||||
title="Start a new session"
|
||||
type="button"
|
||||
size="small"
|
||||
loading={sessions.busy_id == "new"}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onClick={sessions.startSession}
|
||||
className="shrink-0 gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New
|
||||
</Button>
|
||||
</Row>
|
||||
<Input
|
||||
id="bot-sessions-search"
|
||||
title="Search sessions"
|
||||
placeholder="Search titles, summaries and messages"
|
||||
autoComplete="off"
|
||||
defaultValue={sessions.query}
|
||||
onChange={(e) => sessions.setQuery(e.target.value)}
|
||||
wrapperWrapperProps={{ className: "mt-4" }}
|
||||
/>
|
||||
<SettingsStatus status={sessions.error ? { error: true, msg: sessions.error } : undefined} />
|
||||
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
|
||||
{sessions.sessions.map((session) => (
|
||||
<BotSessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
loading={sessions.busy_id == session.id}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onOpen={() => sessions.reopenSession({ session_id: Number(session.id) })}
|
||||
/>
|
||||
))}
|
||||
{sessions.sessions.length || sessions.loading ? null : (
|
||||
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{sessions.query.trim() ? `No session matches "${sessions.query.trim()}".` : "No sessions yet."}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
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 BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
@@ -36,7 +36,8 @@ export default function BotThinkingRow({ bot, progress }: Props) {
|
||||
|
||||
return (
|
||||
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
|
||||
<BotStone
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={waiting ? "asleep" : "working"}
|
||||
size={28}
|
||||
|
||||
@@ -11,6 +11,10 @@ import ComposerSendButton from "./composer-send-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in, if any.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
};
|
||||
|
||||
@@ -19,7 +23,7 @@ type Props = {
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. Files can also be pasted or dropped in.
|
||||
*/
|
||||
export default function Composer({ bot, onSent }: Props) {
|
||||
export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -34,7 +38,12 @@ export default function Composer({ bot, onSent }: Props) {
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await sendBotMessage({ bot_id: bot.id, text, media_ids: files.media_ids });
|
||||
const res = await sendBotMessage({
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids: files.media_ids,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
* bot in chat so it carries on.
|
||||
*/
|
||||
export default function ComputerViewerModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
|
||||
const [handing_back, setHandingBack] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -38,6 +40,7 @@ export default function ComputerViewerModal({ bot }: Props) {
|
||||
|
||||
const res = await sendBotMessage({
|
||||
bot_id: bot.id || 0,
|
||||
workspace_id: pageProps?.workspace?.id || null,
|
||||
text: `I'm done on ${name}. You can carry on.`,
|
||||
media_ids: [],
|
||||
}).catch(() => null);
|
||||
|
||||
@@ -2,17 +2,25 @@ import { useContext, useState } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import { CpuAi, Monitor } from "lucide-react";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { CpuAi, Layers, Monitor } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import BotComputersModal from "./bot-computers-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import BotProfileModal from "./bot-profile-modal";
|
||||
import BotSessionsModal from "./bot-sessions-modal";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
import HeaderMetaItem from "./header-meta-item";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in: its name shows under the
|
||||
* bot's, and the models modal edits its models.
|
||||
*/
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
has_other_unread?: boolean;
|
||||
/**
|
||||
* The bot's models (the defaults for the coordinator).
|
||||
*/
|
||||
@@ -25,7 +33,7 @@ type Props = {
|
||||
* that open the computers and models modals. Kept here so a save or a
|
||||
* switch shows under the name at once.
|
||||
*/
|
||||
export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
const { computers } = useContext(BotComputersContext);
|
||||
|
||||
@@ -34,16 +42,15 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
|
||||
return (
|
||||
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
||||
<BotStone
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
<BotProfileModal bot={bot} />
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
|
||||
) : null}
|
||||
<HeaderMetaItem
|
||||
icon={CpuAi}
|
||||
label="Model"
|
||||
@@ -63,10 +70,17 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
</Row>
|
||||
</Stack>
|
||||
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
|
||||
<BotComputersModal bot={bot} />
|
||||
<BotSessionsModal
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
|
||||
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
|
||||
<BotModelsModal
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
harnesses={harnesses}
|
||||
onSaved={setHarnesses}
|
||||
/>
|
||||
|
||||
@@ -9,13 +9,17 @@ type Props = {
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* For a button that acts on its own; a modal's target leaves it out.
|
||||
*/
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A ghost icon button on the right of the conversation header. Used as a
|
||||
* modal's target, so it takes no click handler of its own.
|
||||
* A ghost icon button on the right of the conversation header, usually a
|
||||
* modal's target.
|
||||
*/
|
||||
export default function HeaderIconButton({ icon: Icon, title, disabled, loading }: Props) {
|
||||
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
@@ -25,6 +29,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading
|
||||
color="text"
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={onClick}
|
||||
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Icon size={18} aria-hidden />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import postSessionApi from "../(functions)/post-session-api";
|
||||
import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Beside the sessions icon: closes this session and starts a fresh one
|
||||
* that carries a summary of it, then shows it.
|
||||
*/
|
||||
export default function NewSessionButton({ bot_id, workspace_id }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<HeaderIconButton
|
||||
icon={MessageSquarePlus}
|
||||
title="New session"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await postSessionApi({
|
||||
path: "new-session",
|
||||
body: { bot_id, workspace_id },
|
||||
}).catch(() => null);
|
||||
|
||||
if (res?.success) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
window.alert(res?.msg || "Couldn't start a new session.");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,8 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
|
||||
/**
|
||||
* The conversation column: header, bottom-anchored thread, composer, the
|
||||
* thread and composer held to a readable width.
|
||||
* thread and composer held to a readable width. Shows one session of the
|
||||
* bot's conversation, on its own or in a workspace (`pageProps.workspace`).
|
||||
* The bot's computers are shared with the header's modal and the computer
|
||||
* buttons in the thread, so a switch from either shows in both; either
|
||||
* can open one in the viewer.
|
||||
@@ -23,32 +24,58 @@ import ComputerViewerModal from "../(partials)/computer-viewer-modal";
|
||||
export default function Conversation() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const bot = pageProps?.bot;
|
||||
const workspace = pageProps?.workspace?.id ? pageProps.workspace : null;
|
||||
const workspace_id = workspace?.id || null;
|
||||
|
||||
const [messages, setMessages] = useState<BotMessageWithAttachments[]>(
|
||||
pageProps?.bot_messages || [],
|
||||
);
|
||||
const [session_id, setSessionId] = useState<number | null>(pageProps?.bot_session?.id || null);
|
||||
const [has_other_unread, setHasOtherUnread] = useState(false);
|
||||
|
||||
const bots = useLiveBots({
|
||||
bots: pageProps?.bots || [],
|
||||
open_bot_id: bot?.id || undefined,
|
||||
workspace_id,
|
||||
});
|
||||
const live_bot = bots.find((b) => b.id == bot?.id) || bot;
|
||||
const bot_computers = useBotComputers({
|
||||
bot_id: bot?.id || 0,
|
||||
workspace_id,
|
||||
computers: pageProps?.bot_computers || [],
|
||||
});
|
||||
|
||||
const progress = useTaskProgress({
|
||||
bot_id: bot?.id || 0,
|
||||
session_id,
|
||||
task: pageProps?.bot_task || null,
|
||||
});
|
||||
|
||||
console.log(`Refreshing ...`);
|
||||
/**
|
||||
* A message that starts a newer session (sent here or in another tab)
|
||||
* moves the thread onto it.
|
||||
*/
|
||||
const startsNewSession = (message: BotMessageWithAttachments) =>
|
||||
Boolean(message.session_id && (!session_id || Number(message.session_id) > session_id));
|
||||
|
||||
useServerEvent({
|
||||
event: "server:bot-message",
|
||||
onData: (message: BotMessageWithAttachments) => {
|
||||
if (message.bot_id != bot?.id) return;
|
||||
if ((message.workspace_id || null) != workspace_id) return;
|
||||
|
||||
if (message.session_id && message.session_id != session_id) {
|
||||
if (message.author == "user" && startsNewSession(message)) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
} else if (message.author == "user") {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setHasOtherUnread(true);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id)
|
||||
@@ -56,10 +83,10 @@ export default function Conversation() {
|
||||
: [...prev, message],
|
||||
);
|
||||
|
||||
if (message.author == "bot") {
|
||||
if (message.author == "bot" && message.session_id) {
|
||||
fetchApi(`/api/admin/bots/mark-read`, {
|
||||
method: "POST",
|
||||
body: { bot_id: message.bot_id },
|
||||
body: { session_id: message.session_id },
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
@@ -72,6 +99,8 @@ export default function Conversation() {
|
||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||
<ConversationHeader
|
||||
bot={live_bot}
|
||||
workspace={workspace}
|
||||
has_other_unread={has_other_unread}
|
||||
harnesses={pageProps?.bot_harness_overview?.harnesses}
|
||||
/>
|
||||
<MessageThread
|
||||
@@ -81,13 +110,20 @@ export default function Conversation() {
|
||||
/>
|
||||
<Composer
|
||||
bot={bot}
|
||||
onSent={({ message }) =>
|
||||
workspace_id={workspace_id}
|
||||
onSent={({ message }) => {
|
||||
if (startsNewSession(message) && message.session_id != session_id) {
|
||||
setSessionId(Number(message.session_id));
|
||||
setMessages([message]);
|
||||
return;
|
||||
}
|
||||
|
||||
setMessages((prev) =>
|
||||
prev.some((m) => m.id == message.id)
|
||||
? prev
|
||||
: [...prev, message],
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</Stack>
|
||||
<ComputerViewerModal bot={live_bot} />
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabActiveBotTask from "@/src/functions/backend/db/bots/grab-active-bot-task";
|
||||
import grabBotMessages from "@/src/functions/backend/db/bots/grab-bot-messages";
|
||||
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
|
||||
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabHarnessOverview from "@/src/functions/backend/db/harness/grab-harness-overview";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
@@ -21,10 +15,12 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
};
|
||||
}
|
||||
|
||||
const bots = await grabBots({ user_id: user.id });
|
||||
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
|
||||
const props = await grabConversationProps({
|
||||
user_id: Number(user.id),
|
||||
bot_id: String(query.bot_id),
|
||||
});
|
||||
|
||||
if (!bot?.id) {
|
||||
if (!props) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: "/admin/bots",
|
||||
@@ -32,33 +28,5 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
};
|
||||
}
|
||||
|
||||
if (bot.unread) {
|
||||
await markBotRead({ bot_id: bot.id, user_id: user.id });
|
||||
bot.unread = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* The header's models modal: a worker's own settings, or for the
|
||||
* coordinator the defaults it runs on (without the providers).
|
||||
*/
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
|
||||
grabBotMessages({ bot_id: bot.id }),
|
||||
(bot.kind == "coordinator"
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: grabBotHarnessOverview({ bot_id: bot.id })
|
||||
).catch(() => null),
|
||||
grabBotComputers({ bot_id: bot.id, user_id: user.id }),
|
||||
grabActiveBotTask({ bot_id: bot.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
bots,
|
||||
bot,
|
||||
bot_messages,
|
||||
bot_harness_overview,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
},
|
||||
};
|
||||
return { props };
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
@@ -31,6 +32,7 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
props: {
|
||||
bots,
|
||||
bot,
|
||||
workspaces: await grabUserWorkspaces({ user_id: user.id }),
|
||||
bot_harness_overview: await grabBotHarnessOverview({ bot_id: bot.id }),
|
||||
},
|
||||
};
|
||||
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { SaveWorkspaceHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Saves a workspace's model settings and returns the fresh page data.
|
||||
*/
|
||||
export default async function saveWorkspaceHarnessSettings(body: SaveWorkspaceHarnessSettingsRequest) {
|
||||
return await fetchApi<
|
||||
SaveWorkspaceHarnessSettingsRequest,
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
>(`/api/admin/workspaces/save-harness-settings`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import useServerEvent from "@/src/hooks/use-server-event";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computers: ComputerWithStatus[];
|
||||
};
|
||||
|
||||
/**
|
||||
* A list of computers kept current from `server:computer-updated` (setup
|
||||
* finishing or failing, a container stopping).
|
||||
*/
|
||||
export default function useLiveComputers({ computers: initial }: Params) {
|
||||
const [computers, setComputers] = useState(initial);
|
||||
|
||||
useServerEvent({
|
||||
event: "server:computer-updated",
|
||||
onData: (computer: ComputerWithStatus) =>
|
||||
setComputers((prev) => prev.map((c) => (c.id == computer.id ? { ...c, ...computer } : c))),
|
||||
});
|
||||
|
||||
return { computers, setComputers };
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useState } from "react";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
/**
|
||||
* What's being picked, e.g. "bot".
|
||||
*/
|
||||
noun: string;
|
||||
options: { value: string; title: string }[];
|
||||
loading?: boolean;
|
||||
onAdd: (params: { value: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A select of things to add, and an Add button beside it.
|
||||
*/
|
||||
export default function InlineAddSelect({ id, noun, options, loading, onAdd }: Props) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
if (!options.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-2! flex-nowrap items-end">
|
||||
<Select
|
||||
id={id}
|
||||
title={`Pick a ${noun}`}
|
||||
aria-label={`Pick a ${noun}`}
|
||||
options={[
|
||||
{ value: "", title: `Pick a ${noun}…`, default: !value },
|
||||
...options.map((option) => ({ ...option, default: option.value == value })),
|
||||
]}
|
||||
changeHandler={(next) => setValue(String(next))}
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
/>
|
||||
<Button
|
||||
title={`Add the ${noun}`}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
loading={loading}
|
||||
disabled={!value || loading}
|
||||
onClick={() => value && onAdd({ value })}
|
||||
className="shrink-0"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
workspace_name: string;
|
||||
bot: WorkspaceBot;
|
||||
onClose: () => void;
|
||||
onSaved: (bots: WorkspaceBot[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* What one bot has for this workspace alone: instructions on top of its
|
||||
* own, and its memory here. The bot rewrites both as it learns; the
|
||||
* coordinator can set the instructions too.
|
||||
*/
|
||||
export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, onClose, onSaved }: Props) {
|
||||
const [system_prompt, setSystemPrompt] = useState(bot.workspace_system_prompt || "");
|
||||
const [memory, setMemory] = useState(bot.workspace_memory || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: "workspaces/update-bot",
|
||||
body: { workspace_id, bot_id: bot.id, system_prompt, memory },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.msg || "Couldn't save.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.payload || []);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`${name} in ${workspace_name}`}
|
||||
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Textarea
|
||||
id="workspace-bot-instructions"
|
||||
title="Instructions here"
|
||||
label="Instructions here"
|
||||
showLabel
|
||||
rows={7}
|
||||
defaultValue={system_prompt}
|
||||
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-bot-memory"
|
||||
title="Memory here"
|
||||
label="Memory here"
|
||||
showLabel
|
||||
rows={5}
|
||||
defaultValue={memory}
|
||||
info="What it must remember in this workspace."
|
||||
onChange={(e) => setMemory(e.target.value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { NotebookPen, X } from "lucide-react";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
bot: WorkspaceBot;
|
||||
href: string;
|
||||
removing?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot in the workspace: opens its conversation here, edits what it has
|
||||
* for this workspace alone, or takes it out.
|
||||
*/
|
||||
export default function WorkspaceBotRow({ bot, href, removing, onEdit, onRemove }: Props) {
|
||||
const name = bot.name || "This bot";
|
||||
const has_own = Boolean(bot.workspace_system_prompt || bot.workspace_memory);
|
||||
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={(bot.hue || null) as BotHue | null} face={(bot.face || "idle") as BotFace} />
|
||||
<Link
|
||||
href={href}
|
||||
title={`Talk to ${name} here`}
|
||||
className="grow min-w-0 flex flex-col border-0 leading-normal hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink rounded-default-sm"
|
||||
>
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{name}</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{has_own ? "Has instructions for this workspace" : bot.role || "Uses its own instructions here"}
|
||||
</Span>
|
||||
</Link>
|
||||
<Stack className="shrink-0 flex-row! gap-1!">
|
||||
<Button
|
||||
title={`${name}'s instructions and memory here`}
|
||||
aria-label={`${name}'s instructions and memory here`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onEdit}
|
||||
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
|
||||
>
|
||||
<NotebookPen size={16} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
title={`Take ${name} out of this workspace`}
|
||||
aria-label={`Take ${name} out of this workspace`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={removing}
|
||||
disabled={removing}
|
||||
onClick={onRemove}
|
||||
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { BotWithLastMessage, WorkspaceBot } from "@/src/types";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceBotModal from "./workspace-bot-modal";
|
||||
import WorkspaceBotRow from "./workspace-bot-row";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
workspace_name: string;
|
||||
/**
|
||||
* The workspace's bots (the coordinator included, always a member).
|
||||
*/
|
||||
bots: WorkspaceBot[];
|
||||
/**
|
||||
* Every bot the user has.
|
||||
*/
|
||||
all_bots: BotWithLastMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which bots work here. A bot can be in any number of workspaces; in each
|
||||
* it can have instructions and memory of its own. Changes reload the
|
||||
* page, so the rail shows the new crew.
|
||||
*/
|
||||
export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots: initial, all_bots }: Props) {
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const [bots, setBots] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [editing, setEditing] = useState<WorkspaceBot>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const workers = bots.filter((bot) => bot.kind != "coordinator");
|
||||
const addable = all_bots.filter((bot) => bot.kind != "coordinator" && !bots.some((b) => b.id == bot.id));
|
||||
|
||||
const change = async ({ path, bot_id }: { path: "add-bot" | "remove-bot"; bot_id: number }) => {
|
||||
setBusyId(path == "add-bot" ? "add" : bot_id);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: `workspaces/${path}`,
|
||||
body: { workspace_id, bot_id },
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(undefined);
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Bots"
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{workers.map((bot) => (
|
||||
<WorkspaceBotRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
removing={busy_id == bot.id}
|
||||
onEdit={() => setEditing(bot)}
|
||||
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
||||
/>
|
||||
))}
|
||||
{workers.length ? null : (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No bots here yet. Only the coordinator works in this workspace.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-bot"
|
||||
noun="bot"
|
||||
options={addable.map((bot) => ({ value: String(bot.id), title: bot.name || `Bot ${bot.id}` }))}
|
||||
loading={busy_id == "add"}
|
||||
onAdd={({ value }) => change({ path: "add-bot", bot_id: Number(value) })}
|
||||
/>
|
||||
<Button
|
||||
title="Add a new bot to this workspace"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "bot" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New bot
|
||||
</Button>
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
{editing ? (
|
||||
<WorkspaceBotModal
|
||||
workspace_id={workspace_id}
|
||||
workspace_name={workspace_name}
|
||||
bot={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={setBots}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One small text action on a workspace computer row.
|
||||
*/
|
||||
export default function WorkspaceComputerAction({ title, label, disabled, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={disabled}
|
||||
onClick={onClick}
|
||||
className="px-2! text-[13px]"
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Monitor } from "lucide-react";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import computerStatusLabel from "../(utils)/computer-status-label";
|
||||
import WorkspaceComputerAction from "./workspace-computer-action";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
is_default: boolean;
|
||||
is_super_admin: boolean;
|
||||
busy?: boolean;
|
||||
onMakeDefault: () => void;
|
||||
onUnlink: () => void;
|
||||
onRetry: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer linked to the workspace: its name, state and whether it's
|
||||
* the default, then what can be done with it.
|
||||
*/
|
||||
export default function WorkspaceComputerRow({
|
||||
computer,
|
||||
is_default,
|
||||
is_super_admin,
|
||||
busy,
|
||||
onMakeDefault,
|
||||
onUnlink,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<Span className="size-8 shrink-0 flex items-center justify-center rounded-default-sm border border-solid border-hairline bg-paper">
|
||||
<Monitor size={16} aria-hidden />
|
||||
</Span>
|
||||
<Stack className="grow min-w-0 gap-0!">
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||
{name}
|
||||
{is_default ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">Default</Span> : null}
|
||||
</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{computerStatusLabel({ computer })}
|
||||
{computer.description ? ` · ${computer.description}` : ""}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Row className="shrink-0 gap-1! flex-nowrap">
|
||||
{is_default ? null : (
|
||||
<WorkspaceComputerAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<WorkspaceComputerAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<WorkspaceComputerAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<WorkspaceComputerAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</Row>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useLiveComputers from "../(hooks)/use-live-computers";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceComputerRow from "./workspace-computer-row";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
/**
|
||||
* Linked to the workspace, its default first.
|
||||
*/
|
||||
computers: ComputerWithStatus[];
|
||||
/**
|
||||
* Every computer the user has.
|
||||
*/
|
||||
all_computers: ComputerWithStatus[];
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The computers bots work on here. Tasks run on the default unless a bot
|
||||
* picks another; with none, tasks needing a computer can't run. Unlinking
|
||||
* keeps the computer; deleting it (super admins) keeps its files.
|
||||
*/
|
||||
export default function WorkspaceComputersGroup({ workspace_id, computers: initial, all_computers }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { computers, setComputers } = useLiveComputers({ computers: initial });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const default_id = computers[0]?.id;
|
||||
const addable = all_computers.filter((computer) => !computers.some((c) => c.id == computer.id));
|
||||
|
||||
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<ComputerWithStatus>({
|
||||
path,
|
||||
body: { workspace_id, computer_id },
|
||||
});
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.payload) {
|
||||
setComputers(res.payload);
|
||||
} else if (res.singleRes) {
|
||||
const fresh = res.singleRes;
|
||||
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
|
||||
} else {
|
||||
setComputers((prev) => prev.filter((c) => c.id != computer_id));
|
||||
}
|
||||
|
||||
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Computers"
|
||||
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{computers.map((computer) => (
|
||||
<WorkspaceComputerRow
|
||||
key={computer.id}
|
||||
computer={computer}
|
||||
is_default={computer.id == default_id}
|
||||
is_super_admin={is_super_admin}
|
||||
busy={busy}
|
||||
onMakeDefault={() => run({ path: "workspaces/set-computer", computer_id: Number(computer.id) })}
|
||||
onUnlink={() => run({ path: "workspaces/remove-computer", computer_id: Number(computer.id) })}
|
||||
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
|
||||
onDelete={() => {
|
||||
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
|
||||
|
||||
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
{computers.length ? null : (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No computers yet. Bots here can chat, but tasks that need a computer can't run.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-computer"
|
||||
noun="computer"
|
||||
options={addable.map((c) => ({ value: String(c.id), title: c.name || `Computer ${c.id}` }))}
|
||||
loading={busy}
|
||||
onAdd={({ value }) => run({ path: "workspaces/add-computer", computer_id: Number(value) })}
|
||||
/>
|
||||
{is_super_admin ? (
|
||||
<Button
|
||||
title="Create a new computer for this workspace"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "computer" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New computer
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the workspace and its conversations, after a confirm. Its bots
|
||||
* and computers stay.
|
||||
*/
|
||||
export default function WorkspaceDeleteGroup({ workspace }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const name = workspace.name || "this workspace";
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete ${name} and every conversation in it? Its bots and computers stay.`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
|
||||
|
||||
if (res.success) {
|
||||
window.location.href = AdminHomePath;
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setError(res.msg || "Couldn't delete the workspace.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Delete"
|
||||
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
|
||||
>
|
||||
<Button
|
||||
title={`Delete ${name}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={remove}
|
||||
className="self-start"
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
onSaved: (workspace: BUN_SQLITE_COBBLE_WORKSPACES) => void;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The workspace's name, what it's for, and the context every bot working
|
||||
* in it gets, saved together.
|
||||
*/
|
||||
export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
||||
const [name, setName] = useState(workspace.name || "");
|
||||
const [description, setDescription] = useState(workspace.description || "");
|
||||
const [system_prompt, setSystemPrompt] = useState(workspace.system_prompt || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const dirty =
|
||||
name != (workspace.name || "") ||
|
||||
description != (workspace.description || "") ||
|
||||
system_prompt != (workspace.system_prompt || "");
|
||||
|
||||
const submit = async () => {
|
||||
if (!dirty || loading) return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
path: "workspaces/update-workspace",
|
||||
body: { workspace_id: workspace.id, name, description, system_prompt },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't save the workspace." });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
onSaved(res.singleRes);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Workspace"
|
||||
description="Every bot working here gets this context on top of its own instructions."
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch" onSubmit={submit}>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="workspace-description"
|
||||
title="What it's for"
|
||||
label="What it's for"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-context"
|
||||
title="Context"
|
||||
label="Context"
|
||||
showLabel
|
||||
rows={8}
|
||||
defaultValue={system_prompt}
|
||||
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
|
||||
<SettingsStatus status={status} />
|
||||
<Button
|
||||
title="Save the workspace"
|
||||
type="submit"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!dirty || loading}
|
||||
className="ml-auto"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { useState } from "react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import ImagePickField from "@/src/components/general/image-pick-field";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import setProfileImage from "@/src/functions/frontend/set-profile-image";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The workspace's picture, shown in the rail. Saved as soon as it's
|
||||
* picked or removed.
|
||||
*/
|
||||
export default function WorkspaceImageGroup({ workspace }: Props) {
|
||||
const [image_media_id, setImageMediaId] = useState(workspace.image_media_id || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const save = async ({ data_url }: { data_url: string | null }) => {
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await setProfileImage({
|
||||
target: "workspace",
|
||||
id: Number(workspace.id),
|
||||
data_url,
|
||||
}).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res?.msg || "Couldn't save the picture." });
|
||||
return;
|
||||
}
|
||||
|
||||
setImageMediaId(res.singleRes.image_media_id);
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup title="Picture" description="Shown beside the workspace's name in the rail.">
|
||||
<ImagePickField
|
||||
preview={<WorkspaceAvatar size="lg" image_media_id={image_media_id} />}
|
||||
has_image={Boolean(image_media_id)}
|
||||
onPick={({ data_url }) => save({ data_url })}
|
||||
onRemove={() => save({ data_url: null })}
|
||||
loading={loading}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||
import saveWorkspaceHarnessSettings from "../(functions)/save-workspace-harness-settings";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which model each harness uses in the workspace. These win over a bot's
|
||||
* own models; "Same as the default" leaves each bot on its own, else the
|
||||
* defaults.
|
||||
*/
|
||||
export default function WorkspaceModels({ workspace_id, harnesses: initial }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
const [version, setVersion] = useState(0);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
return (
|
||||
<HarnessSettingsForm
|
||||
key={version}
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
const res = await saveWorkspaceHarnessSettings({
|
||||
workspace_id,
|
||||
settings: settings.map((setting) => ({ ...setting, inherit: Boolean(setting.inherit) })),
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setHarnesses(res.singleRes.harnesses);
|
||||
setNotice(res.msg || "Saved.");
|
||||
setVersion((prev) => prev + 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
|
||||
type Props = {
|
||||
title: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Heads a run of settings groups on the workspace page (its models).
|
||||
*/
|
||||
export default function WorkspaceSectionHeading({ title, description }: Props) {
|
||||
return (
|
||||
<Stack className="w-full gap-1! pt-10 pb-2 border-0 border-b border-solid border-hairline">
|
||||
<H2 className="text-base! leading-6! tracking-normal! font-bold text-ink mb-0!">{title}</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">{description}</Span>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
||||
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
|
||||
import WorkspaceDeleteGroup from "../(partials)/workspace-delete-group";
|
||||
import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
|
||||
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
||||
import WorkspaceModels from "../(partials)/workspace-models";
|
||||
import WorkspaceSectionHeading from "../(partials)/workspace-section-heading";
|
||||
|
||||
/**
|
||||
* A workspace's page beside its rail: its context, bots, computers, then
|
||||
* its models, and deleting it.
|
||||
*/
|
||||
export default function WorkspaceOverview() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [workspace, setWorkspace] = useState(pageProps?.workspace);
|
||||
|
||||
if (!workspace?.id) return null;
|
||||
|
||||
const workspace_id = Number(workspace.id);
|
||||
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={workspace.name || "Workspace"}
|
||||
description={workspace.description || "Context, bots, computers and models for this workspace."}
|
||||
>
|
||||
<WorkspaceImageGroup workspace={workspace} />
|
||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||
<WorkspaceBotsGroup
|
||||
workspace_id={workspace_id}
|
||||
workspace_name={workspace.name || "this workspace"}
|
||||
bots={pageProps?.workspace_bots || []}
|
||||
all_bots={pageProps?.all_bots || []}
|
||||
/>
|
||||
<WorkspaceComputersGroup
|
||||
workspace_id={workspace_id}
|
||||
computers={pageProps?.workspace_computers || []}
|
||||
all_computers={pageProps?.computers || []}
|
||||
/>
|
||||
<WorkspaceDeleteGroup workspace={workspace} />
|
||||
{harnesses?.length ? (
|
||||
<>
|
||||
<WorkspaceSectionHeading
|
||||
title="Models"
|
||||
description="Every bot working here uses these, the coordinator too. Left on “Same as the default”, each bot uses its own models, else the defaults."
|
||||
/>
|
||||
<WorkspaceModels workspace_id={workspace_id} harnesses={harnesses} />
|
||||
</>
|
||||
) : null}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import computerStatusLabel from "./computer-status-label";
|
||||
|
||||
describe("computerStatusLabel", () => {
|
||||
test("names the state", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "running" } })).toBe("Running");
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "creating" } })).toBe("Setting up…");
|
||||
expect(computerStatusLabel({ computer: { id: 1 } })).toBe("Unknown");
|
||||
});
|
||||
|
||||
test("says why setup failed", () => {
|
||||
expect(computerStatusLabel({ computer: { id: 1, status: "failed", error: "no docker" } })).toBe(
|
||||
"Setup failed: no docker",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import { ContainerStatuses } from "@/src/dict/computer-dict";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
computer: ComputerWithStatus;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer's state in a few words.
|
||||
*/
|
||||
export default function computerStatusLabel({ computer }: Params) {
|
||||
if (computer.status == "creating") return "Setting up…";
|
||||
if (computer.status == "failed") return computer.error ? `Setup failed: ${computer.error}` : "Setup failed";
|
||||
|
||||
return ContainerStatuses.find((s) => s.value == computer.status)?.title || "Unknown";
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A bot's conversation inside a workspace: the bot works with the
|
||||
* workspace's context, computers and models. Only the coordinator and the
|
||||
* workspace's bots have one; anyone else goes back to the workspace.
|
||||
*/
|
||||
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 props = await grabConversationProps({
|
||||
user_id: Number(user.id),
|
||||
bot_id: String(query.bot_id),
|
||||
workspace_id: String(query.workspace_id),
|
||||
});
|
||||
|
||||
if (!props) {
|
||||
return { redirect: { destination: `/admin/workspaces/${query.workspace_id}` } };
|
||||
}
|
||||
|
||||
return { props };
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
import Conversation from "@/src/pages/admin/bots/[bot_id]/(sections)/conversation";
|
||||
|
||||
/**
|
||||
* A bot's conversation in a workspace: the same conversation as
|
||||
* `/admin/bots/[bot_id]`, scoped by `pageProps.workspace`.
|
||||
*/
|
||||
export default function WorkspaceBotConversationPage() {
|
||||
return <Conversation />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const props = params.serverRes?.props as PagePropsType | undefined;
|
||||
const bot = props?.bot;
|
||||
|
||||
return {
|
||||
title: `${bot?.name || "Bots"} · ${props?.workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||
description: bot?.role || `Your crew of bots.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
|
||||
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 page: its details, crew, computers and models, with the
|
||||
* rail showing its crew.
|
||||
*/
|
||||
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 workspaces = await grabUserWorkspaces({ user_id: user.id });
|
||||
const workspace = workspaces.find((w) => String(w.id) == String(query.workspace_id));
|
||||
|
||||
if (!workspace?.id) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
}
|
||||
|
||||
const workspace_id = Number(workspace.id);
|
||||
|
||||
const [workspace_bots, all_bots, workspace_computers, computers, workspace_harness_overview] =
|
||||
await Promise.all([
|
||||
grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
grabBots({ user_id: user.id }),
|
||||
grabWorkspaceComputers({ workspace_id }),
|
||||
grabUserComputers({ user_id: user.id }),
|
||||
grabBotHarnessOverview({ workspace_id }).catch(() => null),
|
||||
]);
|
||||
|
||||
return {
|
||||
props: {
|
||||
workspaces,
|
||||
workspace,
|
||||
bots: workspace_bots,
|
||||
workspace_bots,
|
||||
all_bots,
|
||||
workspace_computers,
|
||||
computers,
|
||||
workspace_harness_overview,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -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 WorkspaceOverview from "./(sections)/workspace-overview";
|
||||
|
||||
export default function WorkspacePage() {
|
||||
return <WorkspaceOverview />;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const workspace = (params.serverRes?.props as PagePropsType | undefined)?.workspace;
|
||||
|
||||
return {
|
||||
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||
description: workspace?.description || `A workspace.`,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,24 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Opens the user's first workspace, or the bots when they have none.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: first?.id ? `/admin/workspaces/${first.id}` : AdminHomePath,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
import { SiteData } from "@/src/data/site-data";
|
||||
|
||||
/**
|
||||
* Never rendered: `index.server.ts` always redirects.
|
||||
*/
|
||||
export default function WorkspacesPage() {
|
||||
return null;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Workspaces | ${SiteData["SiteName"]}`,
|
||||
description: `Your workspaces.`,
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import createBot from "@/src/functions/backend/db/bots/create-bot";
|
||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { CreateBotRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Adds a worker bot to the session user's crew, and to one of their
|
||||
* workspaces when one is given. Returns the bot's id.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<{ bot_id: number }>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { name, role, hue, system_prompt, workspace_id } = parseWithSchema({ schema: CreateBotRequestSchema, data: body });
|
||||
|
||||
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
const bot_id = await createBot({ user_id: user.id, name, role, hue, system_prompt });
|
||||
|
||||
if (workspace_id) {
|
||||
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
|
||||
}
|
||||
|
||||
await logEvent({
|
||||
title: "Bot added",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} added the bot ${name}.`,
|
||||
metadata: { bot_id, workspace_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${name} is on the crew.`, singleRes: { bot_id } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotSessions from "@/src/functions/backend/db/bot-sessions/grab-bot-sessions";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import { ListBotSessionsRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import type { BotSessionListItem } 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 sessions of one of the session user's conversations, latest first,
|
||||
* searched by title, summary or message text.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BotSessionListItem>
|
||||
> = 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 { bot_id, workspace_id, query, page } = parseWithSchema({
|
||||
schema: ListBotSessionsRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabBotSessions({
|
||||
bot_id: conversation.bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
query,
|
||||
page,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import markBotRead from "@/src/functions/backend/db/bots/mark-bot-read";
|
||||
import { BotIdRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import markBotSessionRead from "@/src/functions/backend/db/bot-sessions/mark-bot-session-read";
|
||||
import { BotSessionIdRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
@@ -10,8 +10,8 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Clears a bot's unread flag when a message arrives in the conversation
|
||||
* the user has open. Only the session user's own bots.
|
||||
* Clears a session's unread flag when a message arrives in the
|
||||
* conversation the user has open. Only the session user's own bots.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
@@ -28,9 +28,9 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
return { success: false, msg: `Unauthorized`, logoutUser: true };
|
||||
}
|
||||
|
||||
const { bot_id } = parseWithSchema({ schema: BotIdRequestSchema, data: body });
|
||||
const { session_id } = parseWithSchema({ schema: BotSessionIdRequestSchema, data: body });
|
||||
|
||||
await markBotRead({ bot_id, user_id: user.id });
|
||||
await markBotSessionRead({ session_id, user_id: user.id });
|
||||
|
||||
return { success: true };
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import startBotSession from "@/src/functions/backend/db/bot-sessions/start-bot-session";
|
||||
import { BotConversationRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Closes the active session of one of the session user's conversations and
|
||||
* starts the next one, which carries on from it.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_BOT_SESSIONS>
|
||||
> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, workspace_id } = parseWithSchema({
|
||||
schema: BotConversationRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await startBotSession({
|
||||
bot_id: conversation.bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
}),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import reopenBotSession from "@/src/functions/backend/db/bot-sessions/reopen-bot-session";
|
||||
import { BotSessionIdRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Makes a closed session of one of the session user's conversations the
|
||||
* active one again, so the bot carries on from where it left off.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_BOT_SESSIONS>
|
||||
> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { session_id } = parseWithSchema({
|
||||
schema: BotSessionIdRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
singleRes: await reopenBotSession({ session_id, user_id: user.id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -1,5 +1,6 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import ensureBotSession from "@/src/functions/backend/db/bot-sessions/ensure-bot-session";
|
||||
import insertUserMessage from "@/src/functions/backend/db/bots/insert-user-message";
|
||||
import grabAttachableMedia from "@/src/functions/backend/db/bots/grab-attachable-media";
|
||||
import enqueueBotReply from "@/src/functions/backend/db/bots-queue/enqueue-bot-reply";
|
||||
@@ -16,10 +17,12 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Posts a message from the session user to one of their own bots and
|
||||
* queues the bot's reply, which arrives over the WebSocket. The message
|
||||
* also goes to the user's other open tabs. Files are uploaded first
|
||||
* (`upload-attachment`) and sent as `media_ids`.
|
||||
* Posts a message from the session user to one of their own bots, in its
|
||||
* own chat or in a workspace it's in, and queues the bot's reply, which
|
||||
* arrives over the WebSocket. The message goes in the conversation's
|
||||
* active session (the next one when it's full) and also to the user's
|
||||
* other open tabs. Files are uploaded first (`upload-attachment`) and
|
||||
* sent as `media_ids`.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
req,
|
||||
@@ -44,23 +47,33 @@ export const handler: BunextAPIRouteHandler<APIResponseObject> = async ({
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, text, media_ids } = parseWithSchema({
|
||||
const { bot_id, workspace_id, text, media_ids } = parseWithSchema({
|
||||
schema: SendBotMessageRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const bot = await grabUserBot({ bot_id, user_id: user.id });
|
||||
|
||||
if (!bot?.id) {
|
||||
throw new AppError(`Bot not found`);
|
||||
}
|
||||
const conversation = await grabUserConversation({ user_id: user.id, bot_id, workspace_id });
|
||||
const bot = conversation.bot;
|
||||
|
||||
const media = await grabAttachableMedia({ user_id: Number(user.id), media_ids });
|
||||
const message = await insertUserMessage({ bot_id: bot.id, text, media });
|
||||
const session = await ensureBotSession({
|
||||
bot_id: bot.id,
|
||||
workspace_id: conversation.workspace_id,
|
||||
rollover: true,
|
||||
});
|
||||
|
||||
const message = await insertUserMessage({
|
||||
bot_id: bot.id,
|
||||
session_id: Number(session.id),
|
||||
workspace_id: conversation.workspace_id,
|
||||
text,
|
||||
media,
|
||||
});
|
||||
|
||||
await enqueueBotReply({
|
||||
user_id: user.id,
|
||||
bot_id: bot.id,
|
||||
session_id: Number(session.id),
|
||||
message_id: message.id || 0,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ import type { BUN_SQLITE_COBBLE_COMPUTERS } from "@/db/types/db";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import pushBotNotice from "@/src/bots-queue/push-bot-notice";
|
||||
import setBotComputer from "@/src/functions/backend/db/computers/set-bot-computer";
|
||||
import grabUserConversation from "@/src/functions/backend/db/bot-sessions/grab-user-conversation";
|
||||
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SetBotComputerRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
@@ -14,9 +16,9 @@ import type {
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Switches the computer one of the session user's bots works on, and
|
||||
* tells the user's other tabs. Returns the bot's computers, the new one
|
||||
* first.
|
||||
* Switches the computer one of the session user's bots works on (in a
|
||||
* workspace, the workspace's default computer), and tells the user's
|
||||
* other tabs. Returns the bot's computers there, the new one first.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BUN_SQLITE_COBBLE_COMPUTERS>
|
||||
@@ -36,16 +38,21 @@ export const handler: BunextAPIRouteHandler<
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, computer_id } = parseWithSchema({
|
||||
const { bot_id, computer_id, workspace_id } = parseWithSchema({
|
||||
schema: SetBotComputerRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const { bot, computer, computers } = await setBotComputer({
|
||||
bot_id,
|
||||
computer_id,
|
||||
user_id: user.id,
|
||||
});
|
||||
const { bot, computer, computers } = workspace_id
|
||||
? {
|
||||
bot: (await grabUserConversation({ user_id: user.id, bot_id, workspace_id })).bot,
|
||||
...(await setWorkspaceComputer({ user_id: user.id, workspace_id, computer_id })),
|
||||
}
|
||||
: await setBotComputer({
|
||||
bot_id,
|
||||
computer_id,
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
await pushBotNotice({
|
||||
bot_id: bot.id || 0,
|
||||
@@ -61,14 +68,18 @@ export const handler: BunextAPIRouteHandler<
|
||||
title: "Bot computer switched",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} switched ${bot.name} to ${computer.name || `computer #${computer.id}`}.`,
|
||||
metadata: { bot_id: bot.id, computer_id: computer.id },
|
||||
plain_text: workspace_id
|
||||
? `User #${user.id} made ${computer.name || `computer #${computer.id}`} the default of workspace #${workspace_id}.`
|
||||
: `User #${user.id} switched ${bot.name} to ${computer.name || `computer #${computer.id}`}.`,
|
||||
metadata: { bot_id: bot.id, computer_id: computer.id, workspace_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `${bot.name} is on ${computer.name || "that computer"} now.`,
|
||||
msg: workspace_id
|
||||
? `${computer.name || "That computer"} is the workspace's default computer now.`
|
||||
: `${bot.name} is on ${computer.name || "that computer"} now.`,
|
||||
payload: computers,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import updateBot from "@/src/functions/backend/db/bots/update-bot";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { UpdateBotRequestSchema } from "@/src/schemas/bot-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves one of the session user's bots (name, job, hue, instructions).
|
||||
* Returns the bot.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_BOTS>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { bot_id, name, role, hue, system_prompt } = parseWithSchema({
|
||||
schema: UpdateBotRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const bot = await updateBot({ user_id: user.id, bot_id, name, role, hue, system_prompt });
|
||||
|
||||
await logEvent({
|
||||
title: "Bot changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} changed the bot ${bot.name}.`,
|
||||
metadata: { bot_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `Saved.`, singleRes: bot };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,74 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import createComputer from "@/src/functions/backend/db/computers/create-computer";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import addComputerToWorkspace from "@/src/functions/backend/db/workspaces/add-computer-to-workspace";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { CreateComputerRequestSchema } from "@/src/schemas/computer-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Creates a computer for the session user (super admins only: each one
|
||||
* is a container on this host), and links it to one of their workspaces
|
||||
* when one is given. The container is set up in the background; its
|
||||
* state arrives as `server:computer-updated`. Returns the computer.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
if (!isPassThroughUser({ user_types })) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { name, description, workspace_id } = parseWithSchema({ schema: CreateComputerRequestSchema, data: body });
|
||||
|
||||
if (workspace_id && !(await grabUserWorkspace({ workspace_id, user_id: user.id }))?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
const { computer_id } = await createComputer({ user_id: user.id, name, description });
|
||||
|
||||
if (workspace_id) {
|
||||
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
|
||||
}
|
||||
|
||||
await logEvent({
|
||||
title: "Computer created",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} created the computer ${name}.`,
|
||||
metadata: { computer_id, workspace_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `${name} is being set up. It can take a few minutes the first time.`,
|
||||
singleRes: (await grabUserComputers({ computer_id }))[0],
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,60 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteComputer from "@/src/functions/backend/db/computers/delete-computer";
|
||||
import grabComputerDataDir from "@/src/functions/backend/db/computers/grab-computer-data-dir";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { ComputerIdRequestSchema } from "@/src/schemas/computer-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of the session user's computers and its container (super
|
||||
* admins only). Its home directory stays on disk.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
if (!isPassThroughUser({ user_types })) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { computer_id } = parseWithSchema({ schema: ComputerIdRequestSchema, data: body });
|
||||
|
||||
const computer = await deleteComputer({ user_id: user.id, computer_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Computer deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the computer ${computer.name}.`,
|
||||
metadata: { computer_id, data_dir: grabComputerDataDir({ computer_id }) },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `${computer.name || "The computer"} is deleted. Its files are kept.`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabUserComputer from "@/src/functions/backend/db/computers/grab-user-computer";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import provisionComputer from "@/src/functions/backend/db/computers/provision-computer";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { ComputerIdRequestSchema } from "@/src/schemas/computer-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Sets up a computer's container again after it failed (super admins
|
||||
* only), in the background. Returns the computer, `creating`.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
if (!isPassThroughUser({ user_types })) {
|
||||
throw new AppError(`Only a super admin can do that.`);
|
||||
}
|
||||
|
||||
const { computer_id } = parseWithSchema({ schema: ComputerIdRequestSchema, data: body });
|
||||
|
||||
const computer = await grabUserComputer({ computer_id, user_id: user.id });
|
||||
|
||||
if (!computer?.id) {
|
||||
throw new AppError(`Computer not found.`);
|
||||
}
|
||||
|
||||
const current = (await grabUserComputers({ computer_id: computer.id }))[0];
|
||||
|
||||
if (current?.status == "creating") {
|
||||
throw new AppError(`${computer.name || "The computer"} is already being set up.`);
|
||||
}
|
||||
|
||||
provisionComputer({ computer_id: Number(computer.id) });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Setting ${computer.name || "the computer"} up again.`,
|
||||
singleRes: { ...current, status: "creating", error: null },
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,48 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import setProfileImage from "@/src/functions/backend/db/profile-images/set-profile-image";
|
||||
import { SetProfileImageRequestSchema } from "@/src/schemas/profile-image-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Sets or removes the picture of one of the session user's bots or
|
||||
* workspaces. Returns the new `image_media_id` (null once removed).
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<{ image_media_id: number | null }>
|
||||
> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { target, id, data_url } = parseWithSchema({
|
||||
schema: SetProfileImageRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: data_url ? `Picture saved.` : `Picture removed.`,
|
||||
singleRes: await setProfileImage({ user_id: user.id, target, id, data_url }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import addBotToWorkspace from "@/src/functions/backend/db/workspaces/add-bot-to-workspace";
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import { WorkspaceBotRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Puts one of the session user's bots in one of their workspaces. Returns
|
||||
* the workspace's crew.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, bot_id } = parseWithSchema({ schema: WorkspaceBotRequestSchema, data: body });
|
||||
|
||||
await addBotToWorkspace({ user_id: user.id, workspace_id, bot_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import addComputerToWorkspace from "@/src/functions/backend/db/workspaces/add-computer-to-workspace";
|
||||
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { WorkspaceComputerRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Links one of the session user's computers to one of their workspaces
|
||||
* (the first becomes its default). Returns the workspace's computers.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, computer_id } = parseWithSchema({ schema: WorkspaceComputerRequestSchema, data: body });
|
||||
|
||||
await addComputerToWorkspace({ user_id: user.id, workspace_id, computer_id });
|
||||
|
||||
return { success: true, payload: await grabWorkspaceComputers({ workspace_id }) };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import createWorkspace from "@/src/functions/backend/db/workspaces/create-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { CreateWorkspaceRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Creates a workspace for the session user. Returns its id.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<{ workspace_id: number }>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { name, description, system_prompt } = parseWithSchema({ schema: CreateWorkspaceRequestSchema, data: body });
|
||||
|
||||
const workspace_id = Number(
|
||||
await createWorkspace({ user_id: user.id, name, description, system_prompt }),
|
||||
);
|
||||
|
||||
await logEvent({
|
||||
title: "Workspace created",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} created the workspace ${name}.`,
|
||||
metadata: { workspace_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${name} is ready.`, singleRes: { workspace_id } };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,51 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import deleteWorkspace from "@/src/functions/backend/db/workspaces/delete-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { WorkspaceIdRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Deletes one of the session user's workspaces with its conversations.
|
||||
* Its bots and computers stay.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id } = parseWithSchema({ schema: WorkspaceIdRequestSchema, data: body });
|
||||
|
||||
const workspace = await deleteWorkspace({ user_id: user.id, workspace_id });
|
||||
|
||||
await logEvent({
|
||||
title: "Workspace deleted",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} deleted the workspace ${workspace.name}.`,
|
||||
metadata: { workspace_id },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return { success: true, msg: `${workspace.name || "The workspace"} is deleted.` };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import removeBotFromWorkspace from "@/src/functions/backend/db/workspaces/remove-bot-from-workspace";
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import { WorkspaceBotRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Takes a bot out of one of the session user's workspaces. Its
|
||||
* conversation there is kept, for if it's put back. Returns the
|
||||
* workspace's crew.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, bot_id } = parseWithSchema({ schema: WorkspaceBotRequestSchema, data: body });
|
||||
|
||||
await removeBotFromWorkspace({ user_id: user.id, workspace_id, bot_id });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import removeComputerFromWorkspace from "@/src/functions/backend/db/workspaces/remove-computer-from-workspace";
|
||||
import grabWorkspaceComputers from "@/src/functions/backend/db/workspaces/grab-workspace-computers";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { WorkspaceComputerRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Unlinks a computer from one of the session user's workspaces; the
|
||||
* computer stays. Returns the workspace's computers.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, computer_id } = parseWithSchema({ schema: WorkspaceComputerRequestSchema, data: body });
|
||||
|
||||
await removeComputerFromWorkspace({ user_id: user.id, workspace_id, computer_id });
|
||||
|
||||
return { success: true, payload: await grabWorkspaceComputers({ workspace_id }) };
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,91 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabHarnessSetting from "@/src/functions/backend/db/harness/grab-harness-setting";
|
||||
import saveWorkspaceHarnessSetting from "@/src/functions/backend/db/harness/save-workspace-harness-setting";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||
import { SaveWorkspaceHarnessSettingsRequestSchema } from "@/src/schemas/harness-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves which model each harness uses in one of the session user's
|
||||
* workspaces. A workspace's models win over its bots' own, for the
|
||||
* coordinator too.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, settings } = parseWithSchema({
|
||||
schema: SaveWorkspaceHarnessSettingsRequestSchema,
|
||||
data: body,
|
||||
});
|
||||
|
||||
const workspace = await grabUserWorkspace({ workspace_id, user_id: user.id });
|
||||
|
||||
if (!workspace?.id) {
|
||||
throw new AppError(`Workspace not found.`);
|
||||
}
|
||||
|
||||
for (const setting of settings) {
|
||||
const existing = await grabHarnessSetting({
|
||||
harness: setting.harness,
|
||||
workspace_id: workspace.id,
|
||||
});
|
||||
const { voice, ...options } = existing.source == "workspace" ? existing.options : {};
|
||||
|
||||
await saveWorkspaceHarnessSetting({
|
||||
workspace_id: Number(workspace.id),
|
||||
harness: setting.harness,
|
||||
inherit: setting.inherit,
|
||||
model_id: setting.model_id,
|
||||
provider_id: setting.provider_id,
|
||||
fallback: setting.fallback,
|
||||
enabled: setting.enabled,
|
||||
options: setting.voice ? { ...options, voice: setting.voice } : options,
|
||||
});
|
||||
}
|
||||
|
||||
await logEvent({
|
||||
title: "Workspace model settings changed",
|
||||
category: "system",
|
||||
user_id: user.id,
|
||||
plain_text: `User #${user.id} changed ${workspace.name}'s model settings for ${settings.map((s) => s.harness).join(", ")}.`,
|
||||
metadata: { workspace_id: workspace.id, settings },
|
||||
ip_address: grabRequestIP(req),
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved.`,
|
||||
singleRes: await grabBotHarnessOverview({ workspace_id: Number(workspace.id) }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return {
|
||||
success: false,
|
||||
msg: safeErrorMessage({ error }),
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import setWorkspaceComputer from "@/src/functions/backend/db/workspaces/set-workspace-computer";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import { WorkspaceComputerRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Makes one of a workspace's computers its default. Returns the
|
||||
* workspace's computers, the default first.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<ComputerWithStatus>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, computer_id } = parseWithSchema({ schema: WorkspaceComputerRequestSchema, data: body });
|
||||
|
||||
const { computer, computers } = await setWorkspaceComputer({
|
||||
user_id: user.id,
|
||||
workspace_id,
|
||||
computer_id,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `${computer.name || "That computer"} is the default now.`,
|
||||
payload: computers,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import updateWorkspaceBot from "@/src/functions/backend/db/workspaces/update-workspace-bot";
|
||||
import grabWorkspaceBots from "@/src/functions/backend/db/workspaces/grab-workspace-bots";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import { UpdateWorkspaceBotRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves a bot's instructions and memory for one of the session user's
|
||||
* workspaces. Returns the workspace's crew.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<WorkspaceBot>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, bot_id, system_prompt, memory } = parseWithSchema({ schema: UpdateWorkspaceBotRequestSchema, data: body });
|
||||
|
||||
await updateWorkspaceBot({ user_id: user.id, workspace_id, bot_id, system_prompt, memory });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved.`,
|
||||
payload: await grabWorkspaceBots({ user_id: user.id, workspace_id }),
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import updateWorkspace from "@/src/functions/backend/db/workspaces/update-workspace";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { UpdateWorkspaceRequestSchema } from "@/src/schemas/workspace-schema";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||
import isReadOnlyUser from "@/src/utils/rbac/is-read-only-user";
|
||||
import safeErrorMessage from "@/src/utils/safe-error-message";
|
||||
import type {
|
||||
APIResponseObject,
|
||||
BunextAPIRouteHandler,
|
||||
} from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* Saves one of the session user's workspaces: its name, description and
|
||||
* the context every bot working there gets. Returns the workspace.
|
||||
*/
|
||||
export const handler: BunextAPIRouteHandler<APIResponseObject<BUN_SQLITE_COBBLE_WORKSPACES>> = 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 (isReadOnlyUser({ user_types: user_types || [] })) {
|
||||
throw new AppError(`This user can only read records.`);
|
||||
}
|
||||
|
||||
const { workspace_id, name, description, system_prompt, archived } = parseWithSchema({ schema: UpdateWorkspaceRequestSchema, data: body });
|
||||
|
||||
await updateWorkspace({
|
||||
user_id: user.id,
|
||||
workspace_id,
|
||||
data: {
|
||||
name,
|
||||
description,
|
||||
system_prompt,
|
||||
archived: archived === undefined ? undefined : archived ? 1 : 0,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
msg: `Saved.`,
|
||||
singleRes: (await grabUserWorkspace({ workspace_id, user_id: user.id })) || undefined,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
return { success: false, msg: safeErrorMessage({ error }) };
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user