Add dedicated bot, workspace and user settings pages
This commit is contained in:
@@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
Bot,
|
||||||
|
CircleAlert,
|
||||||
|
CpuAi,
|
||||||
|
Image,
|
||||||
|
Info,
|
||||||
|
Layers,
|
||||||
|
Monitor,
|
||||||
|
Pencil,
|
||||||
|
Trash2,
|
||||||
|
type LucideIcon,
|
||||||
|
} from "lucide-react";
|
||||||
|
import type { SettingsRecordKind } from "@/src/utils/parse-settings-record-path";
|
||||||
|
|
||||||
|
export type SettingsRecordSection = {
|
||||||
|
title: string;
|
||||||
|
url: string;
|
||||||
|
Icon: LucideIcon;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the record rail's back link goes, and the sections it lists.
|
||||||
|
*/
|
||||||
|
export const SettingsRecordBack: Record<
|
||||||
|
SettingsRecordKind,
|
||||||
|
{ title: string; href: string }
|
||||||
|
> = {
|
||||||
|
bots: { title: "All bots", href: "/admin/settings/bots" },
|
||||||
|
workspaces: { title: "All workspaces", href: "/admin/settings/workspaces" },
|
||||||
|
users: { title: "All users", href: "/admin/settings/users" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export const SettingsRecordSections: Record<
|
||||||
|
SettingsRecordKind,
|
||||||
|
SettingsRecordSection[]
|
||||||
|
> = {
|
||||||
|
bots: [
|
||||||
|
{ title: "Overview", url: "#overview", Icon: Info },
|
||||||
|
{ title: "Details", url: "#details", Icon: Pencil },
|
||||||
|
{ title: "Models", url: "#models", Icon: CpuAi },
|
||||||
|
{ title: "Workspaces", url: "#workspaces", Icon: Layers },
|
||||||
|
{ title: "Computers", url: "#computers", Icon: Monitor },
|
||||||
|
{ title: "Delete", url: "#delete", Icon: Trash2 },
|
||||||
|
],
|
||||||
|
workspaces: [
|
||||||
|
{ title: "Picture", url: "#picture", Icon: Image },
|
||||||
|
{ title: "Details", url: "#details", Icon: Pencil },
|
||||||
|
{ title: "Bots", url: "#bots", Icon: Bot },
|
||||||
|
{ title: "Computers", url: "#computers", Icon: Monitor },
|
||||||
|
{ title: "Models", url: "#models", Icon: CpuAi },
|
||||||
|
{ title: "Delete", url: "#delete", Icon: Trash2 },
|
||||||
|
],
|
||||||
|
users: [
|
||||||
|
{ title: "Overview", url: "#overview", Icon: Info },
|
||||||
|
{ title: "Edit", url: "#edit", Icon: Pencil },
|
||||||
|
{ title: "Danger zone", url: "#danger", Icon: CircleAlert },
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db";
|
||||||
|
import type { BotWorkspace } from "@/src/types";
|
||||||
|
import grabUserWorkspaces from "./grab-user-workspaces";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
user_id: number | string;
|
||||||
|
bot: Pick<BUN_SQLITE_COBBLE_BOTS, "id" | "kind">;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The workspaces a bot works in, for its settings page. The coordinator
|
||||||
|
* works in every workspace; a worker only in the ones it was added to.
|
||||||
|
* Each row carries what the bot has for that workspace alone.
|
||||||
|
*/
|
||||||
|
export default async function grabBotWorkspaces({
|
||||||
|
user_id,
|
||||||
|
bot,
|
||||||
|
}: Params): Promise<BotWorkspace[]> {
|
||||||
|
const workspaces = await grabUserWorkspaces({ user_id });
|
||||||
|
|
||||||
|
if (bot.kind == "coordinator") {
|
||||||
|
return workspaces.map((workspace) => ({
|
||||||
|
...workspace,
|
||||||
|
workspace_system_prompt: null,
|
||||||
|
workspace_memory: null,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows =
|
||||||
|
(
|
||||||
|
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
|
||||||
|
sql: `SELECT workspace_id, system_prompt, memory FROM workspace_bots WHERE bot_id = ?`,
|
||||||
|
values: [Number(bot.id)],
|
||||||
|
})
|
||||||
|
).payload || [];
|
||||||
|
|
||||||
|
const by_workspace = new Map(rows.map((row) => [Number(row.workspace_id), row]));
|
||||||
|
|
||||||
|
return workspaces
|
||||||
|
.filter((workspace) => by_workspace.has(Number(workspace.id)))
|
||||||
|
.map((workspace) => {
|
||||||
|
const row = by_workspace.get(Number(workspace.id));
|
||||||
|
|
||||||
|
return {
|
||||||
|
...workspace,
|
||||||
|
workspace_system_prompt: row?.system_prompt || null,
|
||||||
|
workspace_memory: row?.memory || null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -122,7 +122,7 @@ export default function AddFlow({ children }: Props) {
|
|||||||
const pathname = pageProps?.url?.pathname;
|
const pathname = pageProps?.url?.pathname;
|
||||||
|
|
||||||
if (
|
if (
|
||||||
(linked && pathname == `/admin/workspaces/${workspace?.id}`) ||
|
(linked && pathname == `/admin/settings/workspaces/${workspace?.id}`) ||
|
||||||
pathname == SettingsComputersPath
|
pathname == SettingsComputersPath
|
||||||
) {
|
) {
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
|
|||||||
@@ -7,7 +7,11 @@ type Props = {
|
|||||||
submit_label: string;
|
submit_label: string;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
error?: string;
|
error?: string;
|
||||||
onCancel: () => void;
|
/**
|
||||||
|
* Left out on a form embedded in a settings page: then there is
|
||||||
|
* nothing to cancel into.
|
||||||
|
*/
|
||||||
|
onCancel?: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,6 +23,7 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
|
|||||||
<Stack className="gap-3! items-stretch">
|
<Stack className="gap-3! items-stretch">
|
||||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||||
<Row className="gap-2! flex-nowrap justify-end">
|
<Row className="gap-2! flex-nowrap justify-end">
|
||||||
|
{onCancel ? (
|
||||||
<Button
|
<Button
|
||||||
title="Cancel"
|
title="Cancel"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -30,6 +35,7 @@ export default function AddFormActions({ submit_label, loading, error, onCancel
|
|||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
|
<Button title={submit_label} type="submit" size="small" loading={loading} disabled={loading}>
|
||||||
{submit_label}
|
{submit_label}
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export default function AddWorkspaceForm({ onCancel }: Props) {
|
|||||||
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
|
await setProfileImage({ target: "workspace", id: workspace_id, data_url: image.data_url }).catch(() => null);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.location.href = `/admin/workspaces/${workspace_id}`;
|
window.location.href = `/admin/settings/workspaces/${workspace_id}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ type Props = {
|
|||||||
* Edits this bot; adds a worker when not given.
|
* Edits this bot; adds a worker when not given.
|
||||||
*/
|
*/
|
||||||
bot?: BUN_SQLITE_COBBLE_BOTS;
|
bot?: BUN_SQLITE_COBBLE_BOTS;
|
||||||
onCancel: () => void;
|
/**
|
||||||
|
* Left out when the form is embedded in a settings page.
|
||||||
|
*/
|
||||||
|
onCancel?: () => void;
|
||||||
|
/**
|
||||||
|
* Called with the saved bot instead of reloading. For the settings
|
||||||
|
* page, which updates in place.
|
||||||
|
*/
|
||||||
|
onSaved?: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -30,7 +38,7 @@ type Props = {
|
|||||||
* The coordinator keeps its name and has no colour: only its picture and
|
* The coordinator keeps its name and has no colour: only its picture and
|
||||||
* instructions change.
|
* instructions change.
|
||||||
*/
|
*/
|
||||||
export default function BotForm({ bot, onCancel }: Props) {
|
export default function BotForm({ bot, onCancel, onSaved }: Props) {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const workspace = pageProps?.workspace;
|
const workspace = pageProps?.workspace;
|
||||||
const crew = pageProps?.all_bots || pageProps?.bots || [];
|
const crew = pageProps?.all_bots || pageProps?.bots || [];
|
||||||
@@ -72,6 +80,8 @@ export default function BotForm({ bot, onCancel }: Props) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let image_media_id: number | null | undefined;
|
||||||
|
|
||||||
if (image.changed) {
|
if (image.changed) {
|
||||||
const image_res = await setProfileImage({
|
const image_res = await setProfileImage({
|
||||||
target: "bot",
|
target: "bot",
|
||||||
@@ -84,9 +94,24 @@ export default function BotForm({ bot, onCancel }: Props) {
|
|||||||
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
|
setError(image_res?.msg || "The bot is saved, but its picture isn't. Try another image.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
image_media_id = image_res.singleRes?.image_media_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (bot?.id) {
|
if (bot?.id) {
|
||||||
|
const saved = res.singleRes as BUN_SQLITE_COBBLE_BOTS | undefined;
|
||||||
|
|
||||||
|
if (onSaved && saved?.id) {
|
||||||
|
setLoading(false);
|
||||||
|
onSaved({
|
||||||
|
...saved,
|
||||||
|
...(image_media_id === undefined
|
||||||
|
? {}
|
||||||
|
: { image_media_id: image_media_id ?? undefined }),
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import { useContext, type ReactNode } from "react";
|
||||||
|
import Divider from "@moduletrace/twui/layout/Divider";
|
||||||
|
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
|
import UserAvatar from "@/src/components/general/user-avatar";
|
||||||
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
|
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||||
|
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import {
|
||||||
|
SettingsRecordBack,
|
||||||
|
SettingsRecordSections,
|
||||||
|
} from "@/src/dict/settings-record-sections-dict";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import parseSettingsRecordPath from "@/src/utils/parse-settings-record-path";
|
||||||
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
|
import RailBackLink from "./rail-back-link";
|
||||||
|
import RailSettingsLink from "./rail-settings-link";
|
||||||
|
import RailSettingsList from "./rail-settings-list";
|
||||||
|
import RailSettingsRecordRow from "./rail-settings-record-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
pathname?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings mode of the rail. On a list page it shows the settings sub
|
||||||
|
* pages; on a bot, workspace or user record page it drills into that
|
||||||
|
* record: a back link to its list, the record, then its sections.
|
||||||
|
*/
|
||||||
|
export default function RailSettingsAside({ pathname }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const record = parseSettingsRecordPath({ pathname });
|
||||||
|
|
||||||
|
if (!record) {
|
||||||
|
return <RailSettingsList pathname={pathname} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const back = SettingsRecordBack[record.kind];
|
||||||
|
const sections = SettingsRecordSections[record.kind].filter((section) => {
|
||||||
|
if (
|
||||||
|
record.kind == "bots" &&
|
||||||
|
section.url == "#delete" &&
|
||||||
|
pageProps?.bot?.kind == "coordinator"
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (section.url == "#models") {
|
||||||
|
return record.kind == "bots"
|
||||||
|
? Boolean(pageProps?.bot_harness_overview?.harnesses?.length)
|
||||||
|
: record.kind == "workspaces"
|
||||||
|
? Boolean(
|
||||||
|
pageProps?.workspace_harness_overview?.harnesses?.length,
|
||||||
|
)
|
||||||
|
: true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
let avatar: ReactNode = null;
|
||||||
|
let title = "";
|
||||||
|
let subtitle = "";
|
||||||
|
|
||||||
|
if (record.kind == "bots") {
|
||||||
|
const bot = pageProps?.bot;
|
||||||
|
|
||||||
|
if (!bot?.id) return <RailSettingsList pathname={pathname} />;
|
||||||
|
|
||||||
|
title = bot.name || `Bot ${bot.id}`;
|
||||||
|
subtitle =
|
||||||
|
bot.role ||
|
||||||
|
(bot.kind == "coordinator"
|
||||||
|
? "Coordinates your crew"
|
||||||
|
: "Worker bot");
|
||||||
|
avatar = (
|
||||||
|
<BotAvatar
|
||||||
|
image_media_id={bot.image_media_id}
|
||||||
|
hue={(bot.hue || null) as BotHue | null}
|
||||||
|
face={(bot.face || "idle") as BotFace}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else if (record.kind == "workspaces") {
|
||||||
|
const workspace = pageProps?.workspace;
|
||||||
|
|
||||||
|
if (!workspace?.id) return <RailSettingsList pathname={pathname} />;
|
||||||
|
|
||||||
|
title = workspace.name || `Workspace ${workspace.id}`;
|
||||||
|
subtitle = workspace.description || "A workspace";
|
||||||
|
avatar = (
|
||||||
|
<WorkspaceAvatar image_media_id={workspace.image_media_id} />
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const person = pageProps?.target_person;
|
||||||
|
|
||||||
|
if (!person?.id) return <RailSettingsList pathname={pathname} />;
|
||||||
|
|
||||||
|
const types = UserTypes.filter((type) =>
|
||||||
|
parseUserTypes({ user_types: person.user_types }).includes(
|
||||||
|
type.value,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map((type) => type.title)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
title =
|
||||||
|
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||||
|
person.email ||
|
||||||
|
`User ${person.id}`;
|
||||||
|
subtitle = types || person.email || "User";
|
||||||
|
avatar = <UserAvatar person={person} size="md" />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<RailBackLink title={back.title} href={back.href} />
|
||||||
|
<Divider className="shrink-0 mx-1 lg:mx-3 my-2 w-auto border-hairline dark:border-hairline" />
|
||||||
|
<RailSettingsRecordRow
|
||||||
|
avatar={avatar}
|
||||||
|
title={title}
|
||||||
|
subtitle={subtitle}
|
||||||
|
/>
|
||||||
|
{sections.map((section) => (
|
||||||
|
<RailSettingsLink key={section.url} {...section} />
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
avatar: ReactNode;
|
||||||
|
title: string;
|
||||||
|
subtitle?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record whose settings are open, at the top of its rail. Same 48px
|
||||||
|
* row as a bot, selected.
|
||||||
|
*/
|
||||||
|
export default function RailSettingsRecordRow({
|
||||||
|
avatar,
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Row
|
||||||
|
aria-current="page"
|
||||||
|
className="w-full shrink-0 min-h-12 gap-3! flex-nowrap items-center rounded-default px-1 lg:px-3 bg-ink/5"
|
||||||
|
>
|
||||||
|
{avatar}
|
||||||
|
<Stack className="hidden lg:flex grow min-w-0 gap-0!">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||||
|
{title}
|
||||||
|
</Span>
|
||||||
|
{subtitle ? (
|
||||||
|
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||||
|
{subtitle}
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
</Row>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
import Link from "@moduletrace/twui/layout/Link";
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import type { WorkspaceWithActivity } from "@/src/types";
|
import type { WorkspaceWithActivity } from "@/src/types";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace: WorkspaceWithActivity;
|
workspace: WorkspaceWithActivity;
|
||||||
@@ -15,9 +18,14 @@ type Props = {
|
|||||||
* hue, so unread is said in words beside the description.
|
* hue, so unread is said in words beside the description.
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceRow({ workspace, selected }: Props) {
|
export default function WorkspaceRow({ workspace, selected }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const href = canManageCrew({ user_types: pageProps?.user_types })
|
||||||
|
? `/admin/settings/workspaces/${workspace.id}`
|
||||||
|
: `/admin/workspaces/${workspace.id}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
href={`/admin/workspaces/${workspace.id}`}
|
href={href}
|
||||||
aria-current={selected ? "page" : undefined}
|
aria-current={selected ? "page" : undefined}
|
||||||
title={workspace.name}
|
title={workspace.name}
|
||||||
className={twMerge(
|
className={twMerge(
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { RailPages } from "@/src/dict/rail-pages-dict";
|
|||||||
import RailHeader from "../(partials)/rail-header";
|
import RailHeader from "../(partials)/rail-header";
|
||||||
import RailPagesList from "../(partials)/rail-pages-list";
|
import RailPagesList from "../(partials)/rail-pages-list";
|
||||||
import RailCrewList from "../(partials)/rail-crew-list";
|
import RailCrewList from "../(partials)/rail-crew-list";
|
||||||
import RailSettingsList from "../(partials)/rail-settings-list";
|
import RailSettingsAside from "../(partials)/rail-settings-aside";
|
||||||
import RailUserRow from "../(partials)/rail-user-row";
|
import RailUserRow from "../(partials)/rail-user-row";
|
||||||
import RailWorkspaceList from "../(partials)/rail-workspace-list";
|
import RailWorkspaceList from "../(partials)/rail-workspace-list";
|
||||||
|
|
||||||
@@ -48,7 +48,7 @@ export default function BotsRail() {
|
|||||||
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
<RailHeader show_add={!is_settings_page && !is_rail_page} />
|
||||||
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
|
<Stack className="grow min-h-0 overflow-y-auto gap-0! items-stretch px-2 lg:px-3 pt-1 pb-3">
|
||||||
{is_settings_page ? (
|
{is_settings_page ? (
|
||||||
<RailSettingsList pathname={pathname} />
|
<RailSettingsAside pathname={pathname} />
|
||||||
) : is_rail_page ? (
|
) : is_rail_page ? (
|
||||||
<RailPagesList pathname={pathname} />
|
<RailPagesList pathname={pathname} />
|
||||||
) : in_workspace && live_workspace ? (
|
) : in_workspace && live_workspace ? (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
|||||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||||
import BotHarnessForm from "./bot-harness-form";
|
import BotHarnessForm from "@/src/pages/admin/settings/(partials)/bot-harness-form";
|
||||||
import HeaderIconButton from "./header-icon-button";
|
import HeaderIconButton from "./header-icon-button";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
|||||||
@@ -2,15 +2,19 @@ import { useContext, useState } from "react";
|
|||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import H1 from "@moduletrace/twui/layout/H1";
|
import H1 from "@moduletrace/twui/layout/H1";
|
||||||
import { CpuAi, Layers, Monitor } from "lucide-react";
|
import { CpuAi, Layers, Monitor, Settings } from "lucide-react";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||||
import BotComputersModal from "./bot-computers-modal";
|
import BotComputersModal from "./bot-computers-modal";
|
||||||
import BotModelsModal from "./bot-models-modal";
|
import BotModelsModal from "./bot-models-modal";
|
||||||
import BotProfileModal from "./bot-profile-modal";
|
import BotProfileModal from "./bot-profile-modal";
|
||||||
import BotSessionsModal from "./bot-sessions-modal";
|
import BotSessionsModal from "./bot-sessions-modal";
|
||||||
import NewSessionButton from "./new-session-button";
|
import NewSessionButton from "./new-session-button";
|
||||||
|
import HeaderIconButton from "./header-icon-button";
|
||||||
import HeaderMetaItem from "./header-meta-item";
|
import HeaderMetaItem from "./header-meta-item";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -36,9 +40,14 @@ type Props = {
|
|||||||
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
|
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
|
||||||
const [harnesses, setHarnesses] = useState(initial);
|
const [harnesses, setHarnesses] = useState(initial);
|
||||||
const { computers } = useContext(BotComputersContext);
|
const { computers } = useContext(BotComputersContext);
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
|
||||||
const model = harnesses?.find((h) => h.harness == "text")?.current;
|
const model = harnesses?.find((h) => h.harness == "text")?.current;
|
||||||
const computer = computers[0];
|
const computer = computers[0];
|
||||||
|
const can_open_settings =
|
||||||
|
bot.kind == "coordinator"
|
||||||
|
? isPassThroughUser({ user_types: pageProps?.user_types })
|
||||||
|
: canManageCrew({ user_types: pageProps?.user_types });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
||||||
@@ -84,6 +93,13 @@ export default function ConversationHeader({ bot, workspace, has_other_unread, h
|
|||||||
harnesses={harnesses}
|
harnesses={harnesses}
|
||||||
onSaved={setHarnesses}
|
onSaved={setHarnesses}
|
||||||
/>
|
/>
|
||||||
|
{can_open_settings ? (
|
||||||
|
<HeaderIconButton
|
||||||
|
icon={Settings}
|
||||||
|
title={`${bot.name || "This bot"}'s settings`}
|
||||||
|
href={`/admin/settings/bots/${bot.id}`}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
</Row>
|
</Row>
|
||||||
</Row>
|
</Row>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,13 +13,24 @@ type Props = {
|
|||||||
* For a button that acts on its own; a modal's target leaves it out.
|
* For a button that acts on its own; a modal's target leaves it out.
|
||||||
*/
|
*/
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
|
/**
|
||||||
|
* For a button that opens a page.
|
||||||
|
*/
|
||||||
|
href?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A ghost icon button on the right of the conversation header, usually a
|
* A ghost icon button on the right of the conversation header, usually a
|
||||||
* modal's target.
|
* modal's target.
|
||||||
*/
|
*/
|
||||||
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
|
export default function HeaderIconButton({
|
||||||
|
icon: Icon,
|
||||||
|
title,
|
||||||
|
disabled,
|
||||||
|
loading,
|
||||||
|
onClick,
|
||||||
|
href,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Button
|
<Button
|
||||||
title={title}
|
title={title}
|
||||||
@@ -30,6 +41,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading,
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
href={href}
|
||||||
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"
|
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 />
|
<Icon size={18} aria-hidden />
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { useContext } from "react";
|
|
||||||
import { AppContext } from "@/src/pages/__root";
|
|
||||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
|
||||||
import BotHarnessForm from "../../(partials)/bot-harness-form";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A worker bot's models: per harness, the default or its own pick. The
|
|
||||||
* same form as the chat header's models modal.
|
|
||||||
*/
|
|
||||||
export default function BotModelsSection() {
|
|
||||||
const { pageProps } = useContext(AppContext);
|
|
||||||
const bot = pageProps?.bot;
|
|
||||||
const overview = pageProps?.bot_harness_overview;
|
|
||||||
|
|
||||||
if (!bot?.id || !overview) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SettingsShell
|
|
||||||
title={`${bot.name}'s models`}
|
|
||||||
description="Uses the default models unless you pick its own."
|
|
||||||
>
|
|
||||||
<BotHarnessForm bot={bot} harnesses={overview.harnesses} />
|
|
||||||
</SettingsShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A worker bot's model settings, for super admins and admins. The
|
|
||||||
* coordinator always uses the defaults, so its page (and anyone else's)
|
|
||||||
* goes back to the conversation.
|
|
||||||
*/
|
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|
||||||
const query: PageQueryObject = params.query;
|
|
||||||
const { user, user_types } = await userAuth({ req: params.req });
|
|
||||||
|
|
||||||
if (!user?.id) {
|
|
||||||
return { redirect: { destination: "/auth/login" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
const bots = await grabBots({ user_id: user.id });
|
|
||||||
const bot = bots.find((b) => String(b.id) === String(query.bot_id));
|
|
||||||
|
|
||||||
if (!bot?.id) {
|
|
||||||
return { redirect: { destination: "/admin/bots" } };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (bot.kind == "coordinator" || !canManageCrew({ user_types })) {
|
|
||||||
return { redirect: { destination: `/admin/bots/${bot.id}` } };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
props: {
|
|
||||||
bots,
|
|
||||||
bot,
|
|
||||||
workspaces: await grabUserWorkspaces({ user_id: user.id }),
|
|
||||||
bot_harness_overview: await grabBotHarnessOverview({ bot_id: bot.id }),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import type { PagePropsType } from "@/src/types";
|
|
||||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
|
||||||
import { SiteData } from "@/src/data/site-data";
|
|
||||||
import BotModelsSection from "./(sections)/bot-models-section";
|
|
||||||
|
|
||||||
export default function BotModelsPage() {
|
|
||||||
return <BotModelsSection />;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
|
||||||
const bot = (params.serverRes?.props as PagePropsType | undefined)?.bot;
|
|
||||||
|
|
||||||
return {
|
|
||||||
title: `${bot?.name || "Bot"} models | ${SiteData["SiteName"]}`,
|
|
||||||
description: `Which models ${bot?.name || "this bot"} uses.`,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -26,6 +26,10 @@ type Props = {
|
|||||||
*/
|
*/
|
||||||
onSaved?: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
onSaved?: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/**
|
||||||
|
* On a settings page: no Cancel, and the save updates in place.
|
||||||
|
*/
|
||||||
|
embedded?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -33,7 +37,12 @@ type Props = {
|
|||||||
* (bots and workspaces) and password, saved in one go through
|
* (bots and workspaces) and password, saved in one go through
|
||||||
* `users/save-user`.
|
* `users/save-user`.
|
||||||
*/
|
*/
|
||||||
export default function UserForm({ person, onSaved, onCancel }: Props) {
|
export default function UserForm({
|
||||||
|
person,
|
||||||
|
onSaved,
|
||||||
|
onCancel,
|
||||||
|
embedded,
|
||||||
|
}: Props) {
|
||||||
const init = useFormInit<UserFormObject>({
|
const init = useFormInit<UserFormObject>({
|
||||||
existing: person
|
existing: person
|
||||||
? {
|
? {
|
||||||
@@ -63,7 +72,11 @@ export default function UserForm({ person, onSaved, onCancel }: Props) {
|
|||||||
<UserFormUserTypes {...init} person={person} />
|
<UserFormUserTypes {...init} person={person} />
|
||||||
<UserFormAccess {...init} person={person} />
|
<UserFormAccess {...init} person={person} />
|
||||||
<UserFormPassword {...init} />
|
<UserFormPassword {...init} />
|
||||||
<UserFormAction {...init} onCancel={onCancel} />
|
<UserFormAction
|
||||||
|
{...init}
|
||||||
|
onCancel={onCancel}
|
||||||
|
hide_cancel={embedded}
|
||||||
|
/>
|
||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,15 +4,29 @@ import type { UserFormObject } from "@/src/types";
|
|||||||
|
|
||||||
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
|
type Props = ReturnType<typeof useFormInit<UserFormObject>> & {
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
/**
|
||||||
|
* On a settings page, where there is nothing to cancel into.
|
||||||
|
*/
|
||||||
|
hide_cancel?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function UserFormAction({ existing_full, loading, status, onCancel }: Props) {
|
export default function UserFormAction({
|
||||||
|
existing_full,
|
||||||
|
loading,
|
||||||
|
status,
|
||||||
|
onCancel,
|
||||||
|
hide_cancel,
|
||||||
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<AddFormActions
|
<AddFormActions
|
||||||
submit_label={existing_full?.id ? "Save" : "Add user"}
|
submit_label={existing_full?.id ? "Save" : "Add user"}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
error={status?.error ? status.msg : undefined}
|
error={status?.error ? status.msg : undefined}
|
||||||
onCancel={onCancel || (() => window.history.back())}
|
onCancel={
|
||||||
|
hide_cancel
|
||||||
|
? undefined
|
||||||
|
: onCancel || (() => window.history.back())
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||||
|
import { SiteData } from "@/src/data/site-data";
|
||||||
|
import type { SettingsRecordKind } from "@/src/utils/parse-settings-record-path";
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
kind: SettingsRecordKind;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A nested settings record page's title and description: the bot,
|
||||||
|
* workspace or user it manages.
|
||||||
|
*/
|
||||||
|
export default function settingsRecordPageMeta({
|
||||||
|
kind,
|
||||||
|
}: Params): BunextPageModuleMetaFn {
|
||||||
|
return async (params) => {
|
||||||
|
const props = params.serverRes?.props as PagePropsType | undefined;
|
||||||
|
|
||||||
|
if (kind == "bots") {
|
||||||
|
const bot = props?.bot;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${bot?.name || "Bot"} settings | ${SiteData["SiteName"]}`,
|
||||||
|
description: bot?.role || `Manage this bot on ${SiteData["SiteName"]}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind == "workspaces") {
|
||||||
|
const workspace = props?.workspace;
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${workspace?.name || "Workspace"} settings | ${SiteData["SiteName"]}`,
|
||||||
|
description:
|
||||||
|
workspace?.description ||
|
||||||
|
`Manage this workspace on ${SiteData["SiteName"]}.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const person = props?.target_person;
|
||||||
|
const name =
|
||||||
|
[person?.first_name, person?.last_name].filter(Boolean).join(" ") ||
|
||||||
|
person?.email ||
|
||||||
|
"User";
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: `${name} settings | ${SiteData["SiteName"]}`,
|
||||||
|
description: `Manage ${name} on ${SiteData["SiteName"]}.`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -2,8 +2,8 @@ import { useState } from "react";
|
|||||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
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 postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
|
||||||
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
|
import saveBotHarnessSettings from "@/src/functions/frontend/save-bot-harness-settings";
|
||||||
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
|
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import { useContext } from "react";
|
||||||
|
import Breadcrumbs from "@moduletrace/twui/elements/Breadcrumbs";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import { ChevronRight } from "lucide-react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
/**
|
||||||
|
* The record's name, shown where the URL has its id.
|
||||||
|
*/
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The hierarchy above a nested settings page: Settings / Bots / Scout.
|
||||||
|
* Built from the path, with the record's name replacing its id.
|
||||||
|
*/
|
||||||
|
export default function SettingsBreadcrumbs({ title }: Props) {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const pathname = pageProps?.url?.pathname;
|
||||||
|
|
||||||
|
if (!pathname) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack className="w-full shrink-0 pt-5 pb-1">
|
||||||
|
<Breadcrumbs
|
||||||
|
pageUrl={pathname}
|
||||||
|
currentTitle={title}
|
||||||
|
skipHome
|
||||||
|
excludeRegexMatch={/^admin$/}
|
||||||
|
linkProps={{
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink-secondary no-underline hover:text-ink hover:opacity-100 dark:text-ink-secondary",
|
||||||
|
}}
|
||||||
|
currentLinkProps={{
|
||||||
|
title,
|
||||||
|
className:
|
||||||
|
"border-0 text-[13px] leading-[18px] text-ink no-underline pointer-events-none dark:text-ink",
|
||||||
|
}}
|
||||||
|
divider={
|
||||||
|
<ChevronRight
|
||||||
|
size={12}
|
||||||
|
aria-hidden
|
||||||
|
className="shrink-0 text-ink-secondary"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import Link from "@moduletrace/twui/layout/Link";
|
||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
@@ -6,6 +7,10 @@ import Stack from "@moduletrace/twui/layout/Stack";
|
|||||||
type Props = {
|
type Props = {
|
||||||
avatar: ReactNode;
|
avatar: ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
|
/**
|
||||||
|
* Where the title links, for rows that open a page.
|
||||||
|
*/
|
||||||
|
href?: string;
|
||||||
/**
|
/**
|
||||||
* A word beside the title, e.g. "Default" or "You".
|
* A word beside the title, e.g. "Default" or "You".
|
||||||
*/
|
*/
|
||||||
@@ -21,15 +26,29 @@ type Props = {
|
|||||||
* One row of a settings list: an avatar, a title and one line about it,
|
* One row of a settings list: an avatar, a title and one line about it,
|
||||||
* then what can be done with it.
|
* then what can be done with it.
|
||||||
*/
|
*/
|
||||||
export default function SettingsListRow({ avatar, title, badge, subtitle, children }: Props) {
|
export default function SettingsListRow({ avatar, title, href, badge, subtitle, children }: Props) {
|
||||||
return (
|
const title_content = (
|
||||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
|
||||||
{avatar}
|
|
||||||
<Stack className="grow min-w-0 gap-0!">
|
|
||||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||||
{title}
|
{title}
|
||||||
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
|
{badge ? <Span className="ml-2 text-[11px] font-bold leading-4 text-ink-secondary">{badge}</Span> : null}
|
||||||
</Span>
|
</Span>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||||
|
{avatar}
|
||||||
|
<Stack className="grow min-w-0 gap-0!">
|
||||||
|
{href ? (
|
||||||
|
<Link
|
||||||
|
href={href}
|
||||||
|
title={title}
|
||||||
|
className="block min-w-0 border-0 leading-normal hover:opacity-100 hover:underline focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-surface rounded-default-sm"
|
||||||
|
>
|
||||||
|
{title_content}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
title_content
|
||||||
|
)}
|
||||||
{subtitle ? (
|
{subtitle ? (
|
||||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
|
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">{subtitle}</Span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
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;
|
||||||
|
/**
|
||||||
|
* An anchor, so the rail can link to this section.
|
||||||
|
*/
|
||||||
|
id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Heads a run of settings groups, e.g. the models form on a bot or
|
||||||
|
* workspace page.
|
||||||
|
*/
|
||||||
|
export default function SettingsSectionHeading({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
id,
|
||||||
|
}: Props) {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
id={id}
|
||||||
|
className="w-full scroll-mt-4 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { twMerge } from "tailwind-merge";
|
import { twMerge } from "tailwind-merge";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import SettingsBreadcrumbs from "./settings-breadcrumbs";
|
||||||
import SettingsPaneHeader from "./settings-pane-header";
|
import SettingsPaneHeader from "./settings-pane-header";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -11,6 +12,10 @@ type Props = {
|
|||||||
* A wider column, for tables (activity).
|
* A wider column, for tables (activity).
|
||||||
*/
|
*/
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
|
/**
|
||||||
|
* A nested record page's name, for its breadcrumb trail.
|
||||||
|
*/
|
||||||
|
breadcrumb_title?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,6 +27,7 @@ export default function SettingsShell({
|
|||||||
description,
|
description,
|
||||||
children,
|
children,
|
||||||
wide,
|
wide,
|
||||||
|
breadcrumb_title,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
<Stack className="grow min-w-0 h-full gap-0! items-stretch">
|
||||||
@@ -33,6 +39,9 @@ export default function SettingsShell({
|
|||||||
wide && "max-w-[1200px]",
|
wide && "max-w-[1200px]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
{breadcrumb_title ? (
|
||||||
|
<SettingsBreadcrumbs title={breadcrumb_title} />
|
||||||
|
) : null}
|
||||||
{children}
|
{children}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
|
||||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
|
||||||
import type { BotWithLastMessage } from "@/src/types";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/**
|
|
||||||
* Open while set.
|
|
||||||
*/
|
|
||||||
bot?: BotWithLastMessage;
|
|
||||||
onClose: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Edits a bot in a modal, with the same form as adding one (and as the
|
|
||||||
* chat header's avatar).
|
|
||||||
*/
|
|
||||||
export default function BotEditModal({ bot, onClose }: Props) {
|
|
||||||
return (
|
|
||||||
<AddModalShell
|
|
||||||
open={Boolean(bot)}
|
|
||||||
setOpen={(open) => !open && onClose()}
|
|
||||||
title={`Edit ${bot?.name || "the bot"}`}
|
|
||||||
description={
|
|
||||||
bot?.kind == "coordinator"
|
|
||||||
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
|
|
||||||
: "Its core instructions apply everywhere. What it does in one workspace is set on that workspace's page."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{bot ? <BotForm key={bot.id} bot={bot} onCancel={onClose} /> : null}
|
|
||||||
</AddModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -7,15 +7,14 @@ import SettingsRowAction from "../../(partials)/settings-row-action";
|
|||||||
type Props = {
|
type Props = {
|
||||||
bot: BotWithLastMessage;
|
bot: BotWithLastMessage;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onEdit: () => void;
|
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One bot: its stone or picture, name and job, then Chat, Edit, and for a
|
* One bot: its stone or picture, name and job. The title opens its
|
||||||
* worker its Models and Delete.
|
* settings page; Chat and, for a worker, Delete sit at the end.
|
||||||
*/
|
*/
|
||||||
export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
|
export default function BotSettingsRow({ bot, busy, onDelete }: Props) {
|
||||||
const name = bot.name || `Bot ${bot.id}`;
|
const name = bot.name || `Bot ${bot.id}`;
|
||||||
const is_coordinator = bot.kind == "coordinator";
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
|
||||||
@@ -28,16 +27,13 @@ export default function BotSettingsRow({ bot, busy, onEdit, onDelete }: Props) {
|
|||||||
face={(bot.face || "idle") as BotFace}
|
face={(bot.face || "idle") as BotFace}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
href={`/admin/settings/bots/${bot.id}`}
|
||||||
title={name}
|
title={name}
|
||||||
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
|
subtitle={bot.role || (is_coordinator ? "Coordinates your crew and hands work to it" : undefined)}
|
||||||
>
|
>
|
||||||
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
|
<SettingsRowAction title={`Chat with ${name}`} label="Chat" href={`/admin/bots/${bot.id}`} />
|
||||||
<SettingsRowAction title={`Edit ${name}`} label="Edit" disabled={busy} onClick={onEdit} />
|
|
||||||
{is_coordinator ? null : (
|
{is_coordinator ? null : (
|
||||||
<>
|
|
||||||
<SettingsRowAction title={`${name}'s models`} label="Models" href={`/admin/bots/${bot.id}/models`} />
|
|
||||||
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,22 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext } from "react";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||||
import type { BotWithLastMessage } from "@/src/types";
|
|
||||||
import useSettingsList from "../../(hooks)/use-settings-list";
|
import useSettingsList from "../../(hooks)/use-settings-list";
|
||||||
import SettingsGroup from "../../(partials)/settings-group";
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
import SettingsList from "../../(partials)/settings-list";
|
import SettingsList from "../../(partials)/settings-list";
|
||||||
import SettingsStatus from "../../(partials)/settings-status";
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
import BotEditModal from "../(partials)/bot-edit-modal";
|
|
||||||
import BotSettingsRow from "../(partials)/bot-settings-row";
|
import BotSettingsRow from "../(partials)/bot-settings-row";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The user's crew, coordinator first: chat with a bot, edit it, set its
|
* The user's crew, coordinator first. A row opens that bot's settings
|
||||||
* models, add one or delete a worker.
|
* page; chat with it, or delete a worker.
|
||||||
*/
|
*/
|
||||||
export default function BotsSettingsSection() {
|
export default function BotsSettingsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const { openForm } = useContext(AddFlowContext);
|
const { openForm } = useContext(AddFlowContext);
|
||||||
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
|
const { items, busy, status, remove } = useSettingsList({ items: pageProps?.bots || [] });
|
||||||
const [editing, setEditing] = useState<BotWithLastMessage>();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
@@ -32,7 +29,6 @@ export default function BotsSettingsSection() {
|
|||||||
key={bot.id}
|
key={bot.id}
|
||||||
bot={bot}
|
bot={bot}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onEdit={() => setEditing(bot)}
|
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
remove({
|
remove({
|
||||||
id: bot.id,
|
id: bot.id,
|
||||||
@@ -57,7 +53,6 @@ export default function BotsSettingsSection() {
|
|||||||
New bot
|
New bot
|
||||||
</Button>
|
</Button>
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
<BotEditModal bot={editing} onClose={() => setEditing(undefined)} />
|
|
||||||
</SettingsGroup>
|
</SettingsGroup>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||||
|
import type { ComputerWithStatus } from "@/src/types";
|
||||||
|
import computerResourcesLabel from "@/src/utils/computer-resources-label";
|
||||||
|
import computerStatusLabel from "@/src/utils/computer-status-label";
|
||||||
|
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||||
|
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
computer: ComputerWithStatus;
|
||||||
|
busy?: boolean;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One computer the bot can use in its own chat.
|
||||||
|
*/
|
||||||
|
export default function BotComputerRow({
|
||||||
|
computer,
|
||||||
|
busy,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
const name = computer.name || `Computer ${computer.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsListRow
|
||||||
|
avatar={<ComputerIconTile />}
|
||||||
|
title={name}
|
||||||
|
subtitle={[
|
||||||
|
computerStatusLabel({ computer }),
|
||||||
|
computerResourcesLabel({ computer }),
|
||||||
|
computer.description,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
>
|
||||||
|
{onRemove ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Take ${name}'s access away`}
|
||||||
|
label="Remove"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRemove}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SettingsListRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import { Monitor } from "lucide-react";
|
||||||
|
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { BotWithLastMessage, ComputerWithStatus } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsList from "../../../(partials)/settings-list";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
import BotComputerRow from "./bot-computer-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
/**
|
||||||
|
* Computers it can use in its own chat.
|
||||||
|
*/
|
||||||
|
computers: ComputerWithStatus[];
|
||||||
|
/**
|
||||||
|
* Every computer the user has.
|
||||||
|
*/
|
||||||
|
all_computers: ComputerWithStatus[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bot's direct computer access. In a workspace it uses that
|
||||||
|
* workspace's computers instead; the coordinator can use every computer.
|
||||||
|
*/
|
||||||
|
export default function BotComputersGroup({
|
||||||
|
bot,
|
||||||
|
computers: initial,
|
||||||
|
all_computers,
|
||||||
|
}: Props) {
|
||||||
|
const [computers, setComputers] = useState(initial);
|
||||||
|
const [busy_id, setBusyId] = useState<number | "add">();
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
|
||||||
|
const addable = all_computers.filter(
|
||||||
|
(computer) => !computers.some((item) => item.id == computer.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const change = async ({
|
||||||
|
path,
|
||||||
|
computer_id,
|
||||||
|
}: {
|
||||||
|
path: "add-computer" | "remove-computer";
|
||||||
|
computer_id: number;
|
||||||
|
}) => {
|
||||||
|
setBusyId(path == "add-computer" ? "add" : computer_id);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi({
|
||||||
|
path: `bots/${path}`,
|
||||||
|
body: { bot_id: bot.id, computer_id },
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusyId(undefined);
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path == "add-computer") {
|
||||||
|
const computer = all_computers.find(
|
||||||
|
(item) => Number(item.id) == computer_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (computer) {
|
||||||
|
setComputers((prev) => [...prev, computer]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setComputers((prev) =>
|
||||||
|
prev.filter((computer) => Number(computer.id) != computer_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="computers"
|
||||||
|
title="Computers"
|
||||||
|
description={
|
||||||
|
is_coordinator
|
||||||
|
? "The coordinator can use every computer."
|
||||||
|
: "Direct access for its own chat. In a workspace it uses that workspace's computers instead."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsList
|
||||||
|
is_empty={!computers.length}
|
||||||
|
empty={
|
||||||
|
is_coordinator
|
||||||
|
? "No computers yet."
|
||||||
|
: "No direct computer access yet."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{computers.map((computer) => (
|
||||||
|
<BotComputerRow
|
||||||
|
key={computer.id}
|
||||||
|
computer={computer}
|
||||||
|
busy={busy_id == computer.id}
|
||||||
|
onRemove={
|
||||||
|
is_coordinator
|
||||||
|
? undefined
|
||||||
|
: () =>
|
||||||
|
change({
|
||||||
|
path: "remove-computer",
|
||||||
|
computer_id: Number(computer.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
{is_coordinator ? null : (
|
||||||
|
<InlineAddSelect
|
||||||
|
id="bot-add-computer"
|
||||||
|
noun="computer"
|
||||||
|
options={addable.map((computer) => ({
|
||||||
|
value: String(computer.id),
|
||||||
|
title:
|
||||||
|
computer.name || `Computer ${computer.id}`,
|
||||||
|
}))}
|
||||||
|
loading={busy_id == "add"}
|
||||||
|
onAdd={({ value }) =>
|
||||||
|
change({
|
||||||
|
path: "add-computer",
|
||||||
|
computer_id: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
title="Manage your computers"
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
href="/admin/settings/computers"
|
||||||
|
beforeIcon={<Monitor size={14} aria-hidden />}
|
||||||
|
className="self-start gap-1!"
|
||||||
|
>
|
||||||
|
Manage computers
|
||||||
|
</Button>
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deletes a worker with its conversations, tasks and models. The
|
||||||
|
* coordinator is never deleted.
|
||||||
|
*/
|
||||||
|
export default function BotDeleteGroup({ bot }: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{ error?: boolean; msg?: string }>();
|
||||||
|
const name = bot.name || "this bot";
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (
|
||||||
|
!window.confirm(
|
||||||
|
`Delete ${name}? Its conversations, tasks and models go with it.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi({
|
||||||
|
path: "bots/delete-bot",
|
||||||
|
body: { bot_id: bot.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
window.location.href = "/admin/settings/bots";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
setStatus({ error: true, msg: res.msg || "Couldn't delete the bot." });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="delete"
|
||||||
|
title="Delete"
|
||||||
|
description="Deletes the bot with its conversations, tasks and models."
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
title={`Delete ${name}`}
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
disabled={loading}
|
||||||
|
onClick={remove}
|
||||||
|
className="self-start"
|
||||||
|
>
|
||||||
|
Delete bot
|
||||||
|
</Button>
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
onSaved: (bot: BUN_SQLITE_COBBLE_BOTS) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bot's picture, name, job, colour and core instructions, edited in
|
||||||
|
* place. The same form as adding a bot, without a modal around it.
|
||||||
|
*/
|
||||||
|
export default function BotDetailsGroup({ bot, onSaved }: Props) {
|
||||||
|
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
|
||||||
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="details"
|
||||||
|
title="Details"
|
||||||
|
description={
|
||||||
|
is_coordinator
|
||||||
|
? "The coordinator keeps its name and has no colour. Its core instructions apply everywhere."
|
||||||
|
: "The bot's picture, name, job, colour and core instructions. These apply in its own chat and wherever it has no workspace instructions."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<BotForm
|
||||||
|
bot={bot}
|
||||||
|
onSaved={(saved) => {
|
||||||
|
onSaved(saved);
|
||||||
|
setStatus({ success: true, msg: "Saved." });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
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 { MessageSquare } from "lucide-react";
|
||||||
|
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
|
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The bot at a glance: its stone or picture, name, job and status, then a
|
||||||
|
* way into its chat.
|
||||||
|
*/
|
||||||
|
export default function BotOverviewGroup({ bot }: Props) {
|
||||||
|
const name = bot.name || `Bot ${bot.id}`;
|
||||||
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="overview"
|
||||||
|
title="Overview"
|
||||||
|
description="What this bot is here for."
|
||||||
|
>
|
||||||
|
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||||
|
<BotAvatar
|
||||||
|
image_media_id={bot.image_media_id}
|
||||||
|
hue={(bot.hue || null) as BotHue | null}
|
||||||
|
face={(bot.face || "idle") as BotFace}
|
||||||
|
size={40}
|
||||||
|
/>
|
||||||
|
<Stack className="grow min-w-0 gap-0!">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">
|
||||||
|
{name}
|
||||||
|
</Span>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
|
||||||
|
{[
|
||||||
|
is_coordinator ? "Coordinator" : "Worker",
|
||||||
|
bot.role,
|
||||||
|
bot.status_line,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" · ")}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
<Button
|
||||||
|
title={`Chat with ${name}`}
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
href={`/admin/bots/${bot.id}`}
|
||||||
|
beforeIcon={<MessageSquare size={14} aria-hidden />}
|
||||||
|
className="shrink-0"
|
||||||
|
>
|
||||||
|
Open chat
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||||
|
import type { BotWorkspace } from "@/src/types";
|
||||||
|
import SettingsListRow from "../../../(partials)/settings-list-row";
|
||||||
|
import SettingsRowAction from "../../../(partials)/settings-row-action";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
workspace: BotWorkspace;
|
||||||
|
busy?: boolean;
|
||||||
|
onRemove?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One workspace the bot works in. Its title opens the workspace's
|
||||||
|
* settings; a worker can be taken out from here.
|
||||||
|
*/
|
||||||
|
export default function BotWorkspaceRow({
|
||||||
|
workspace,
|
||||||
|
busy,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
|
const name = workspace.name || `Workspace ${workspace.id}`;
|
||||||
|
const has_own = Boolean(
|
||||||
|
workspace.workspace_system_prompt || workspace.workspace_memory,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsListRow
|
||||||
|
avatar={
|
||||||
|
<WorkspaceAvatar
|
||||||
|
image_media_id={workspace.image_media_id}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
href={`/admin/settings/workspaces/${workspace.id}`}
|
||||||
|
title={name}
|
||||||
|
subtitle={
|
||||||
|
has_own
|
||||||
|
? "Has instructions for this workspace"
|
||||||
|
: workspace.description || "Uses the workspace's context"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{onRemove ? (
|
||||||
|
<SettingsRowAction
|
||||||
|
title={`Take ${name} out of this workspace`}
|
||||||
|
label="Remove"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={onRemove}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</SettingsListRow>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { BotWorkspace, BotWithLastMessage, WorkspaceWithActivity } from "@/src/types";
|
||||||
|
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsList from "../../../(partials)/settings-list";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
import BotWorkspaceRow from "./bot-workspace-row";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
bot: BotWithLastMessage;
|
||||||
|
/**
|
||||||
|
* Every workspace the user has.
|
||||||
|
*/
|
||||||
|
workspaces: WorkspaceWithActivity[];
|
||||||
|
/**
|
||||||
|
* The workspaces the bot works in.
|
||||||
|
*/
|
||||||
|
bot_workspaces: BotWorkspace[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Where the bot works. A worker can be added to or taken out of a
|
||||||
|
* workspace; the coordinator works in every one of them.
|
||||||
|
*/
|
||||||
|
export default function BotWorkspacesGroup({
|
||||||
|
bot,
|
||||||
|
workspaces,
|
||||||
|
bot_workspaces: initial,
|
||||||
|
}: Props) {
|
||||||
|
const [bot_workspaces, setBotWorkspaces] = useState(initial);
|
||||||
|
const [busy_id, setBusyId] = useState<number | "add">();
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
|
||||||
|
const addable = workspaces.filter(
|
||||||
|
(workspace) =>
|
||||||
|
!bot_workspaces.some((member) => member.id == workspace.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
const change = async ({
|
||||||
|
path,
|
||||||
|
workspace_id,
|
||||||
|
}: {
|
||||||
|
path: "add-bot" | "remove-bot";
|
||||||
|
workspace_id: number;
|
||||||
|
}) => {
|
||||||
|
setBusyId(path == "add-bot" ? "add" : workspace_id);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi({
|
||||||
|
path: `workspaces/${path}`,
|
||||||
|
body: { workspace_id, bot_id: bot.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
setBusyId(undefined);
|
||||||
|
|
||||||
|
if (!res.success) {
|
||||||
|
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path == "add-bot") {
|
||||||
|
const workspace = workspaces.find(
|
||||||
|
(item) => Number(item.id) == workspace_id,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (workspace) {
|
||||||
|
setBotWorkspaces((prev) => [
|
||||||
|
...prev,
|
||||||
|
{
|
||||||
|
...workspace,
|
||||||
|
workspace_system_prompt: null,
|
||||||
|
workspace_memory: null,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setBotWorkspaces((prev) =>
|
||||||
|
prev.filter((workspace) => Number(workspace.id) != workspace_id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="workspaces"
|
||||||
|
title="Workspaces"
|
||||||
|
description={
|
||||||
|
is_coordinator
|
||||||
|
? "The coordinator works in every workspace."
|
||||||
|
: "Where this bot works. In each, the workspace's context, computers and models apply."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsList
|
||||||
|
is_empty={!bot_workspaces.length}
|
||||||
|
empty={
|
||||||
|
is_coordinator
|
||||||
|
? "No workspaces yet."
|
||||||
|
: "This bot is not in a workspace yet."
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{bot_workspaces.map((workspace) => (
|
||||||
|
<BotWorkspaceRow
|
||||||
|
key={workspace.id}
|
||||||
|
workspace={workspace}
|
||||||
|
busy={busy_id == workspace.id}
|
||||||
|
onRemove={
|
||||||
|
is_coordinator
|
||||||
|
? undefined
|
||||||
|
: () =>
|
||||||
|
change({
|
||||||
|
path: "remove-bot",
|
||||||
|
workspace_id: Number(workspace.id),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</SettingsList>
|
||||||
|
{is_coordinator ? null : (
|
||||||
|
<InlineAddSelect
|
||||||
|
id="bot-add-workspace"
|
||||||
|
noun="workspace"
|
||||||
|
options={addable.map((workspace) => ({
|
||||||
|
value: String(workspace.id),
|
||||||
|
title:
|
||||||
|
workspace.name ||
|
||||||
|
`Workspace ${workspace.id}`,
|
||||||
|
}))}
|
||||||
|
loading={busy_id == "add"}
|
||||||
|
onAdd={({ value }) =>
|
||||||
|
change({
|
||||||
|
path: "add-bot",
|
||||||
|
workspace_id: Number(value),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS } from "@/db/types/db";
|
||||||
|
import type { BotWithLastMessage } from "@/src/types";
|
||||||
|
import BotHarnessForm from "../../../(partials)/bot-harness-form";
|
||||||
|
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
||||||
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
|
import BotComputersGroup from "../(partials)/bot-computers-group";
|
||||||
|
import BotDeleteGroup from "../(partials)/bot-delete-group";
|
||||||
|
import BotDetailsGroup from "../(partials)/bot-details-group";
|
||||||
|
import BotOverviewGroup from "../(partials)/bot-overview-group";
|
||||||
|
import BotWorkspacesGroup from "../(partials)/bot-workspaces-group";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bot's settings: overview, details, models, workspaces, computers,
|
||||||
|
* and deleting a worker. The same page shows the bot's information and
|
||||||
|
* edits it.
|
||||||
|
*/
|
||||||
|
export default function BotSettingsSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [bot, setBot] = useState<BotWithLastMessage | undefined>(
|
||||||
|
pageProps?.bot || undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!bot?.id) return null;
|
||||||
|
|
||||||
|
const name = bot.name || `Bot ${bot.id}`;
|
||||||
|
const is_coordinator = bot.kind == "coordinator";
|
||||||
|
const harnesses = pageProps?.bot_harness_overview?.harnesses;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsShell
|
||||||
|
title={name}
|
||||||
|
description={
|
||||||
|
bot.role ||
|
||||||
|
(is_coordinator
|
||||||
|
? "Coordinates your crew and hands work to it"
|
||||||
|
: "A worker bot.")
|
||||||
|
}
|
||||||
|
breadcrumb_title={name}
|
||||||
|
>
|
||||||
|
<BotOverviewGroup bot={bot} />
|
||||||
|
<BotDetailsGroup
|
||||||
|
bot={bot}
|
||||||
|
onSaved={(saved: BUN_SQLITE_COBBLE_BOTS) =>
|
||||||
|
setBot((prev) => ({ ...(prev || bot), ...saved }))
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{harnesses?.length ? (
|
||||||
|
<>
|
||||||
|
<SettingsSectionHeading
|
||||||
|
id="models"
|
||||||
|
title="Models"
|
||||||
|
description={
|
||||||
|
is_coordinator
|
||||||
|
? "The coordinator 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."
|
||||||
|
: "Uses the default models unless you pick its own. In a workspace, that workspace's models win. The text model chats, plans and drives computers; the others read, transcribe, speak and make images."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<BotHarnessForm bot={bot} harnesses={harnesses} />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
<BotWorkspacesGroup
|
||||||
|
bot={bot}
|
||||||
|
workspaces={pageProps?.workspaces || []}
|
||||||
|
bot_workspaces={pageProps?.bot_workspaces || []}
|
||||||
|
/>
|
||||||
|
<BotComputersGroup
|
||||||
|
bot={bot}
|
||||||
|
computers={pageProps?.bot_computers || []}
|
||||||
|
all_computers={pageProps?.computers || []}
|
||||||
|
/>
|
||||||
|
{is_coordinator ? null : <BotDeleteGroup bot={bot} />}
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { PagePropsType, PageQueryObject, ComputerWithStatus } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||||
|
import grabUserBot from "@/src/functions/backend/db/bots/grab-user-bot";
|
||||||
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
|
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-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 grabBotWorkspaces from "@/src/functions/backend/db/workspaces/grab-bot-workspaces";
|
||||||
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
|
import grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One bot's settings: its details, models, workspaces and computers. For
|
||||||
|
* super admins and admins; the coordinator is the super admin's alone.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabCrewSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props?.person?.id) return settings;
|
||||||
|
|
||||||
|
const query: PageQueryObject = params.query;
|
||||||
|
const user_id = settings.props.person.id;
|
||||||
|
|
||||||
|
const bot = await grabUserBot({ bot_id: String(query.bot_id), user_id });
|
||||||
|
|
||||||
|
if (!bot?.id) {
|
||||||
|
return { redirect: { destination: "/admin/settings/bots", permanent: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
bots,
|
||||||
|
workspaces,
|
||||||
|
bot_workspaces,
|
||||||
|
all_computers,
|
||||||
|
bot_computer_rows,
|
||||||
|
bot_harness_overview,
|
||||||
|
] = await Promise.all([
|
||||||
|
grabBots({ user_id }),
|
||||||
|
grabUserWorkspaces({ user_id }),
|
||||||
|
grabBotWorkspaces({ user_id, bot }),
|
||||||
|
grabUserComputers({ user_id }),
|
||||||
|
grabBotComputers({ bot_id: bot.id }),
|
||||||
|
(bot.kind == "coordinator"
|
||||||
|
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||||
|
: grabBotHarnessOverview({ bot_id: bot.id })
|
||||||
|
).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const bot_computers = bot_computer_rows
|
||||||
|
.map((row) =>
|
||||||
|
all_computers.find((computer) => computer.id == row.id),
|
||||||
|
)
|
||||||
|
.filter((computer): computer is ComputerWithStatus =>
|
||||||
|
Boolean(computer?.id),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
bot,
|
||||||
|
bots,
|
||||||
|
workspaces,
|
||||||
|
bot_workspaces,
|
||||||
|
bot_computers,
|
||||||
|
computers: all_computers,
|
||||||
|
bot_harness_overview,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import BotSettingsSection from "./(sections)/bot-settings-section";
|
||||||
|
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsBotPage() {
|
||||||
|
return <BotSettingsSection />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsRecordPageMeta({ kind: "bots" });
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds a user in a modal. Editing one happens on its settings page.
|
||||||
|
*/
|
||||||
|
export default function NewUserModal({ open, onClose, onSaved }: Props) {
|
||||||
|
return (
|
||||||
|
<AddModalShell
|
||||||
|
open={open}
|
||||||
|
setOpen={(next) => !next && onClose()}
|
||||||
|
title="Add a user"
|
||||||
|
description="They get their own coordinator and main computer, and sign in with this password."
|
||||||
|
>
|
||||||
|
{open ? <UserForm onCancel={onClose} onSaved={onSaved} /> : null}
|
||||||
|
</AddModalShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
|
||||||
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
|
||||||
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
|
|
||||||
|
|
||||||
type Props = {
|
|
||||||
/**
|
|
||||||
* Edits this user; adds one when not given.
|
|
||||||
*/
|
|
||||||
person?: BUN_SQLITE_COBBLE_USERS_JOIN;
|
|
||||||
open: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
onSaved: (params: { person: BUN_SQLITE_COBBLE_USERS_JOIN; msg: string }) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds or edits a user in a modal.
|
|
||||||
*/
|
|
||||||
export default function UserEditModal({ person, open, onClose, onSaved }: Props) {
|
|
||||||
return (
|
|
||||||
<AddModalShell
|
|
||||||
open={open}
|
|
||||||
setOpen={(next) => !next && onClose()}
|
|
||||||
title={person ? `Edit ${person.first_name || "the user"}` : "Add a user"}
|
|
||||||
description={
|
|
||||||
person
|
|
||||||
? "Leave the password empty to keep theirs."
|
|
||||||
: "They get their own coordinator and main computer, and sign in with this password."
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{open ? <UserForm key={person?.id || "new"} person={person} onCancel={onClose} onSaved={onSaved} /> : null}
|
|
||||||
</AddModalShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,6 @@ import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
|||||||
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||||
import parseUserTypes from "@/src/utils/parse-user-types";
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||||
import canUpdateUser from "@/src/utils/rbac/can-update-user";
|
|
||||||
import SettingsListRow from "../../(partials)/settings-list-row";
|
import SettingsListRow from "../../(partials)/settings-list-row";
|
||||||
import SettingsRowAction from "../../(partials)/settings-row-action";
|
import SettingsRowAction from "../../(partials)/settings-row-action";
|
||||||
|
|
||||||
@@ -13,22 +12,20 @@ type Props = {
|
|||||||
actor_user_id?: number | string;
|
actor_user_id?: number | string;
|
||||||
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||||
busy?: boolean;
|
busy?: boolean;
|
||||||
onEdit: () => void;
|
|
||||||
onRevoke: () => void;
|
onRevoke: () => void;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One user: their photo, name, user types and email, then Edit, Revoke
|
* One user: their photo, name, user types and email. The title opens
|
||||||
* (not once revoked) and Delete where the actor may (`canUpdateUser`,
|
* their settings page; Revoke (not once revoked) and Delete sit at the
|
||||||
* `canRevokeUser`; never revoking or deleting yourself).
|
* end where the actor may (`canRevokeUser`; never yourself).
|
||||||
*/
|
*/
|
||||||
export default function UserSettingsRow({
|
export default function UserSettingsRow({
|
||||||
person,
|
person,
|
||||||
actor_user_id,
|
actor_user_id,
|
||||||
actor_user_types,
|
actor_user_types,
|
||||||
busy,
|
busy,
|
||||||
onEdit,
|
|
||||||
onRevoke,
|
onRevoke,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
@@ -42,25 +39,17 @@ export default function UserSettingsRow({
|
|||||||
.join(", ");
|
.join(", ");
|
||||||
const is_self = person.id == actor_user_id;
|
const is_self = person.id == actor_user_id;
|
||||||
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
||||||
const can_edit = canUpdateUser({ actor_user_types, actor_user_id, target_user_id: person.id, target_user_types });
|
|
||||||
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
|
const can_delete = !is_self && canRevokeUser({ user_types: actor_user_types, target_user_types });
|
||||||
const can_revoke = can_delete && !user_types.includes("revoked");
|
const can_revoke = can_delete && !user_types.includes("revoked");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsListRow
|
<SettingsListRow
|
||||||
avatar={<UserAvatar person={person} size="md" />}
|
avatar={<UserAvatar person={person} size="md" />}
|
||||||
|
href={`/admin/settings/users/${person.id}`}
|
||||||
title={name}
|
title={name}
|
||||||
badge={is_self ? "You" : undefined}
|
badge={is_self ? "You" : undefined}
|
||||||
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
|
subtitle={[types_title, person.email].filter(Boolean).join(" · ")}
|
||||||
>
|
>
|
||||||
{can_edit ? (
|
|
||||||
<SettingsRowAction
|
|
||||||
title={`Edit ${name}`}
|
|
||||||
label="Edit"
|
|
||||||
disabled={busy}
|
|
||||||
onClick={onEdit}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{can_revoke ? (
|
{can_revoke ? (
|
||||||
<SettingsRowAction
|
<SettingsRowAction
|
||||||
title={`Revoke ${name}`}
|
title={`Revoke ${name}`}
|
||||||
|
|||||||
@@ -8,26 +8,21 @@ import useSettingsList from "../../(hooks)/use-settings-list";
|
|||||||
import SettingsGroup from "../../(partials)/settings-group";
|
import SettingsGroup from "../../(partials)/settings-group";
|
||||||
import SettingsList from "../../(partials)/settings-list";
|
import SettingsList from "../../(partials)/settings-list";
|
||||||
import SettingsStatus from "../../(partials)/settings-status";
|
import SettingsStatus from "../../(partials)/settings-status";
|
||||||
import UserEditModal from "../(partials)/user-edit-modal";
|
import NewUserModal from "../(partials)/new-user-modal";
|
||||||
import UserSettingsRow from "../(partials)/user-settings-row";
|
import UserSettingsRow from "../(partials)/user-settings-row";
|
||||||
import revokeUser from "../(functions)/revoke-user";
|
import revokeUser from "../(functions)/revoke-user";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Everyone who can sign in (for super admins and admins): add a user
|
* Everyone who can sign in (for super admins and admins). A row opens
|
||||||
* with a password, edit one (photo, name, email, username, a new
|
* that user's settings page; add one with a password, revoke one, or
|
||||||
* password, user types), revoke one, or delete one with everything
|
* delete one with everything that's theirs.
|
||||||
* that's theirs. What each person
|
|
||||||
* may do here is the RBAC helpers' call.
|
|
||||||
*/
|
*/
|
||||||
export default function UsersSettingsSection() {
|
export default function UsersSettingsSection() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
|
const { items, setItems, busy, setBusy, status, setStatus, remove } = useSettingsList({
|
||||||
items: pageProps?.persons || [],
|
items: pageProps?.persons || [],
|
||||||
});
|
});
|
||||||
/**
|
const [creating, setCreating] = useState(false);
|
||||||
* The user being edited, or `true` while adding one.
|
|
||||||
*/
|
|
||||||
const [editing, setEditing] = useState<BUN_SQLITE_COBBLE_USERS_JOIN | true>();
|
|
||||||
|
|
||||||
const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) =>
|
const savePerson = (person: BUN_SQLITE_COBBLE_USERS_JOIN) =>
|
||||||
setItems((prev) =>
|
setItems((prev) =>
|
||||||
@@ -66,7 +61,6 @@ export default function UsersSettingsSection() {
|
|||||||
actor_user_id={pageProps?.user?.id}
|
actor_user_id={pageProps?.user?.id}
|
||||||
actor_user_types={pageProps?.user_types}
|
actor_user_types={pageProps?.user_types}
|
||||||
busy={busy}
|
busy={busy}
|
||||||
onEdit={() => setEditing(person)}
|
|
||||||
onRevoke={() => revoke(person)}
|
onRevoke={() => revoke(person)}
|
||||||
onDelete={() =>
|
onDelete={() =>
|
||||||
remove({
|
remove({
|
||||||
@@ -86,7 +80,7 @@ export default function UsersSettingsSection() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
color="gray"
|
color="gray"
|
||||||
size="small"
|
size="small"
|
||||||
onClick={() => setEditing(true)}
|
onClick={() => setCreating(true)}
|
||||||
className="self-start gap-1!"
|
className="self-start gap-1!"
|
||||||
>
|
>
|
||||||
<Plus size={14} aria-hidden />
|
<Plus size={14} aria-hidden />
|
||||||
@@ -94,13 +88,12 @@ export default function UsersSettingsSection() {
|
|||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<SettingsStatus status={status} />
|
<SettingsStatus status={status} />
|
||||||
<UserEditModal
|
<NewUserModal
|
||||||
person={editing === true ? undefined : editing}
|
open={creating}
|
||||||
open={Boolean(editing)}
|
onClose={() => setCreating(false)}
|
||||||
onClose={() => setEditing(undefined)}
|
|
||||||
onSaved={({ person, msg }) => {
|
onSaved={({ person, msg }) => {
|
||||||
savePerson(person);
|
savePerson(person);
|
||||||
setEditing(undefined);
|
setCreating(false);
|
||||||
setStatus({ success: true, msg });
|
setStatus({ success: true, msg });
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
|
import canRevokeUser from "@/src/utils/rbac/can-revoke-user";
|
||||||
|
import revokeUser from "../../(functions)/revoke-user";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||||
|
actor_user_id?: number | string;
|
||||||
|
actor_user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||||
|
onUpdated: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoking or deleting a user, where the actor may. Never yourself, and
|
||||||
|
* never the super admin.
|
||||||
|
*/
|
||||||
|
export default function UserDangerGroup({
|
||||||
|
person,
|
||||||
|
actor_user_id,
|
||||||
|
actor_user_types,
|
||||||
|
onUpdated,
|
||||||
|
}: Props) {
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [status, setStatus] = useState<Status>();
|
||||||
|
const name = person.first_name || "this user";
|
||||||
|
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||||
|
const is_self = person.id == actor_user_id;
|
||||||
|
const target_user_types = user_types.map((user_type) => ({ user_type }));
|
||||||
|
const can_delete =
|
||||||
|
!is_self &&
|
||||||
|
canRevokeUser({ user_types: actor_user_types, target_user_types });
|
||||||
|
const can_revoke = can_delete && !user_types.includes("revoked");
|
||||||
|
|
||||||
|
const revoke = async () => {
|
||||||
|
if (
|
||||||
|
busy ||
|
||||||
|
!window.confirm(
|
||||||
|
`Revoke ${name}? They can't sign in until Revoked is removed from their user types. Their crew and files are kept.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await revokeUser({ person });
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
|
||||||
|
if (!res.success || !res.singleRes?.id) {
|
||||||
|
setStatus({ error: true, msg: res.msg || "Couldn't revoke them." });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
onUpdated(res.singleRes);
|
||||||
|
setStatus({ success: true, msg: `${name} can't sign in now.` });
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async () => {
|
||||||
|
if (
|
||||||
|
busy ||
|
||||||
|
!window.confirm(
|
||||||
|
`Delete ${name} with their bots, workspaces and computers? Their computers' files stay on disk.`,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
|
setBusy(true);
|
||||||
|
setStatus(undefined);
|
||||||
|
|
||||||
|
const res = await postAdminApi({
|
||||||
|
path: "users/delete-user",
|
||||||
|
body: { user_id: person.id },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.success) {
|
||||||
|
window.location.href = "/admin/settings/users";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBusy(false);
|
||||||
|
setStatus({ error: true, msg: res.msg || "Couldn't delete them." });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="danger"
|
||||||
|
title="Danger zone"
|
||||||
|
description="Revoking keeps everything they had; deleting takes it with them."
|
||||||
|
>
|
||||||
|
{is_self ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
This is you. You can't revoke or delete yourself here.
|
||||||
|
</Span>
|
||||||
|
) : !can_delete ? (
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
Nothing can be done here.
|
||||||
|
</Span>
|
||||||
|
) : (
|
||||||
|
<Row className="w-full gap-2! flex-wrap items-center">
|
||||||
|
{can_revoke ? (
|
||||||
|
<Button
|
||||||
|
title={`Revoke ${name}`}
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={revoke}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
title={`Delete ${name}`}
|
||||||
|
type="button"
|
||||||
|
variant="outlined"
|
||||||
|
color="gray"
|
||||||
|
size="small"
|
||||||
|
loading={busy}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={remove}
|
||||||
|
>
|
||||||
|
Delete user
|
||||||
|
</Button>
|
||||||
|
</Row>
|
||||||
|
)}
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import UserForm from "@/src/pages/admin/people/create/(partials)/user-form";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
import SettingsStatus from "../../../(partials)/settings-status";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||||
|
onSaved: (person: BUN_SQLITE_COBBLE_USERS_JOIN) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user's profile, photo, user types, access and password, saved
|
||||||
|
* together. The same form as adding one, embedded on the page.
|
||||||
|
*/
|
||||||
|
export default function UserEditGroup({ person, onSaved }: Props) {
|
||||||
|
const [status, setStatus] = useState<{ success?: boolean; msg?: string }>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="edit"
|
||||||
|
title="Edit"
|
||||||
|
description="Their photo, name, contact details, user types, what they reach and a new password."
|
||||||
|
>
|
||||||
|
<UserForm
|
||||||
|
key={`${person.id}-${person.updated_at || ""}`}
|
||||||
|
person={person}
|
||||||
|
embedded
|
||||||
|
onSaved={({ person: saved, msg }) => {
|
||||||
|
onSaved(saved);
|
||||||
|
setStatus({ success: true, msg });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<SettingsStatus status={status} />
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
|
import UserAvatar from "@/src/components/general/user-avatar";
|
||||||
|
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||||
|
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import parseIdList from "@/src/utils/parse-id-list";
|
||||||
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
|
import SettingsGroup from "../../../(partials)/settings-group";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
person: BUN_SQLITE_COBBLE_USERS_JOIN;
|
||||||
|
/**
|
||||||
|
* The session user, to say "You".
|
||||||
|
*/
|
||||||
|
actor_user_id?: number | string;
|
||||||
|
/**
|
||||||
|
* Workers and workspaces a member reaches.
|
||||||
|
*/
|
||||||
|
crew_bots: BUN_SQLITE_COBBLE_BOTS[];
|
||||||
|
crew_workspaces: BUN_SQLITE_COBBLE_WORKSPACES[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A user at a glance: their photo, name, contact details, user types and
|
||||||
|
* what they reach.
|
||||||
|
*/
|
||||||
|
export default function UserOverviewGroup({
|
||||||
|
person,
|
||||||
|
actor_user_id,
|
||||||
|
crew_bots,
|
||||||
|
crew_workspaces,
|
||||||
|
}: Props) {
|
||||||
|
const name =
|
||||||
|
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||||
|
person.email ||
|
||||||
|
`User ${person.id}`;
|
||||||
|
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||||
|
const is_self = person.id == actor_user_id;
|
||||||
|
const reaches_everything =
|
||||||
|
user_types.includes("super_admin") || user_types.includes("admin");
|
||||||
|
const bot_ids = parseIdList({ ids: person.bot_ids });
|
||||||
|
const workspace_ids = parseIdList({ ids: person.workspace_ids });
|
||||||
|
const bot_names = crew_bots
|
||||||
|
.filter((bot) => bot_ids.includes(Number(bot.id)))
|
||||||
|
.map((bot) => bot.name || `Bot ${bot.id}`);
|
||||||
|
const workspace_names = crew_workspaces
|
||||||
|
.filter((workspace) => workspace_ids.includes(Number(workspace.id)))
|
||||||
|
.map((workspace) => workspace.name || `Workspace ${workspace.id}`);
|
||||||
|
const access = reaches_everything
|
||||||
|
? "Every worker and workspace."
|
||||||
|
: [
|
||||||
|
bot_names.length
|
||||||
|
? `Bots: ${bot_names.join(", ")}`
|
||||||
|
: "No bots.",
|
||||||
|
workspace_names.length
|
||||||
|
? `Workspaces: ${workspace_names.join(", ")}`
|
||||||
|
: "No workspaces.",
|
||||||
|
].join(" ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsGroup
|
||||||
|
id="overview"
|
||||||
|
title="Overview"
|
||||||
|
description="Who this is, and what they can reach."
|
||||||
|
>
|
||||||
|
<Row className="w-full gap-4! flex-nowrap items-start">
|
||||||
|
<UserAvatar person={person} size="lg" />
|
||||||
|
<Stack className="grow min-w-0 gap-1!">
|
||||||
|
<Row className="gap-2! flex-nowrap items-center">
|
||||||
|
<Span className="text-sm font-bold leading-5 text-ink truncate">
|
||||||
|
{name}
|
||||||
|
</Span>
|
||||||
|
{is_self ? (
|
||||||
|
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink-secondary">
|
||||||
|
You
|
||||||
|
</Span>
|
||||||
|
) : null}
|
||||||
|
</Row>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary truncate w-full">
|
||||||
|
{[person.email, person.username].filter(Boolean).join(" · ")}
|
||||||
|
</Span>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{UserTypes.filter((type) =>
|
||||||
|
user_types.includes(type.value),
|
||||||
|
)
|
||||||
|
.map((type) => type.title)
|
||||||
|
.join(", ") || "No user types"}
|
||||||
|
</Span>
|
||||||
|
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||||
|
{access}
|
||||||
|
</Span>
|
||||||
|
</Stack>
|
||||||
|
</Row>
|
||||||
|
</SettingsGroup>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useContext, useState } from "react";
|
||||||
|
import { AppContext } from "@/src/pages/__root";
|
||||||
|
import { UserTypes } from "@/src/dict/user-types-dict";
|
||||||
|
import type { BUN_SQLITE_COBBLE_USERS_JOIN } from "@/src/types/sql-joins";
|
||||||
|
import parseUserTypes from "@/src/utils/parse-user-types";
|
||||||
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
|
import UserDangerGroup from "../(partials)/user-danger-group";
|
||||||
|
import UserEditGroup from "../(partials)/user-edit-group";
|
||||||
|
import UserOverviewGroup from "../(partials)/user-overview-group";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user's settings: overview, the form that edits them, and revoking
|
||||||
|
* or deleting them where the actor may.
|
||||||
|
*/
|
||||||
|
export default function UserSettingsSection() {
|
||||||
|
const { pageProps } = useContext(AppContext);
|
||||||
|
const [person, setPerson] = useState<
|
||||||
|
BUN_SQLITE_COBBLE_USERS_JOIN | undefined
|
||||||
|
>(pageProps?.target_person || undefined);
|
||||||
|
|
||||||
|
if (!person?.id) return null;
|
||||||
|
|
||||||
|
const name =
|
||||||
|
[person.first_name, person.last_name].filter(Boolean).join(" ") ||
|
||||||
|
person.email ||
|
||||||
|
`User ${person.id}`;
|
||||||
|
const user_types = parseUserTypes({ user_types: person.user_types });
|
||||||
|
const types_title = UserTypes.filter((type) =>
|
||||||
|
user_types.includes(type.value),
|
||||||
|
)
|
||||||
|
.map((type) => type.title)
|
||||||
|
.join(", ");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingsShell
|
||||||
|
title={name}
|
||||||
|
description={
|
||||||
|
types_title || person.email || "A user on this install."
|
||||||
|
}
|
||||||
|
breadcrumb_title={name}
|
||||||
|
>
|
||||||
|
<UserOverviewGroup
|
||||||
|
person={person}
|
||||||
|
actor_user_id={pageProps?.user?.id}
|
||||||
|
crew_bots={pageProps?.crew_bots || []}
|
||||||
|
crew_workspaces={pageProps?.crew_workspaces || []}
|
||||||
|
/>
|
||||||
|
<UserEditGroup person={person} onSaved={setPerson} />
|
||||||
|
<UserDangerGroup
|
||||||
|
person={person}
|
||||||
|
actor_user_id={pageProps?.user?.id}
|
||||||
|
actor_user_types={pageProps?.user_types}
|
||||||
|
onUpdated={setPerson}
|
||||||
|
/>
|
||||||
|
</SettingsShell>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
import { SettingsDefaultPath } from "@/src/dict/settings-pages-dict";
|
||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabCrewOptions from "@/src/functions/backend/db/access/grab-crew-options";
|
||||||
|
import grabUsers from "@/src/functions/backend/db/users/grab-users";
|
||||||
|
import sanitizeUserBio from "@/src/functions/backend/db/users/sanitize-user-bio";
|
||||||
|
import canListUsers from "@/src/utils/rbac/can-list-users";
|
||||||
|
import grabSettingsServerProps from "../../(functions)/grab-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One user's settings, for those who may list users (`canListUsers`):
|
||||||
|
* their profile, user types, access and danger zone.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props) return settings;
|
||||||
|
|
||||||
|
const { user_types } = await userAuth({ req: params.req });
|
||||||
|
|
||||||
|
if (!canListUsers({ user_types })) {
|
||||||
|
return { redirect: { destination: SettingsDefaultPath, permanent: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const query: PageQueryObject = params.query;
|
||||||
|
const target_person = (
|
||||||
|
await grabUsers({ user_id: String(query.user_id) })
|
||||||
|
).singleRes;
|
||||||
|
|
||||||
|
if (!target_person?.id) {
|
||||||
|
return { redirect: { destination: "/admin/settings/users", permanent: false } };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
target_person: sanitizeUserBio({ user: target_person }),
|
||||||
|
...(await grabCrewOptions()),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import UserSettingsSection from "./(sections)/user-settings-section";
|
||||||
|
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsUserPage() {
|
||||||
|
return <UserSettingsSection />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsRecordPageMeta({ kind: "users" });
|
||||||
@@ -19,10 +19,10 @@ export default function WorkspaceSettingsRow({ workspace, busy, onDelete }: Prop
|
|||||||
return (
|
return (
|
||||||
<SettingsListRow
|
<SettingsListRow
|
||||||
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
|
avatar={<WorkspaceAvatar image_media_id={workspace.image_media_id} size="sm" />}
|
||||||
|
href={`/admin/settings/workspaces/${workspace.id}`}
|
||||||
title={name}
|
title={name}
|
||||||
subtitle={workspace.description || undefined}
|
subtitle={workspace.description || undefined}
|
||||||
>
|
>
|
||||||
<SettingsRowAction title={`Open ${name}`} label="Open" href={`/admin/workspaces/${workspace.id}`} />
|
|
||||||
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
<SettingsRowAction title={`Delete ${name}`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||||
</SettingsListRow>
|
</SettingsListRow>
|
||||||
);
|
);
|
||||||
|
|||||||
+55
-11
@@ -3,41 +3,85 @@ import Link from "@moduletrace/twui/layout/Link";
|
|||||||
import Row from "@moduletrace/twui/layout/Row";
|
import Row from "@moduletrace/twui/layout/Row";
|
||||||
import Span from "@moduletrace/twui/layout/Span";
|
import Span from "@moduletrace/twui/layout/Span";
|
||||||
import Stack from "@moduletrace/twui/layout/Stack";
|
import Stack from "@moduletrace/twui/layout/Stack";
|
||||||
import { NotebookPen, X } from "lucide-react";
|
import { MessageSquare, NotebookPen, X } from "lucide-react";
|
||||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||||
import type { WorkspaceBot } from "@/src/types";
|
import type { WorkspaceBot } from "@/src/types";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
bot: WorkspaceBot;
|
bot: WorkspaceBot;
|
||||||
href: string;
|
/**
|
||||||
|
* The bot's settings page; left out for the coordinator, which only
|
||||||
|
* super admins manage.
|
||||||
|
*/
|
||||||
|
settings_href?: string;
|
||||||
|
/**
|
||||||
|
* Its conversation here.
|
||||||
|
*/
|
||||||
|
chat_href: string;
|
||||||
removing?: boolean;
|
removing?: boolean;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
onRemove: () => void;
|
onRemove: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bot in the workspace: opens its conversation here, edits what it has
|
* A bot in the workspace: opens its settings (or, for the coordinator,
|
||||||
* for this workspace alone, or takes it out.
|
* its conversation here), chats with it here, edits what it has for this
|
||||||
|
* workspace alone, or takes it out.
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceBotRow({ bot, href, removing, onEdit, onRemove }: Props) {
|
export default function WorkspaceBotRow({
|
||||||
|
bot,
|
||||||
|
settings_href,
|
||||||
|
chat_href,
|
||||||
|
removing,
|
||||||
|
onEdit,
|
||||||
|
onRemove,
|
||||||
|
}: Props) {
|
||||||
const name = bot.name || "This bot";
|
const name = bot.name || "This bot";
|
||||||
const has_own = Boolean(bot.workspace_system_prompt || bot.workspace_memory);
|
const has_own = Boolean(
|
||||||
|
bot.workspace_system_prompt || bot.workspace_memory,
|
||||||
|
);
|
||||||
|
const title_href = settings_href || chat_href;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
<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} />
|
<BotAvatar
|
||||||
|
image_media_id={bot.image_media_id}
|
||||||
|
hue={(bot.hue || null) as BotHue | null}
|
||||||
|
face={(bot.face || "idle") as BotFace}
|
||||||
|
/>
|
||||||
<Link
|
<Link
|
||||||
href={href}
|
href={title_href}
|
||||||
title={`Talk to ${name} here`}
|
title={
|
||||||
|
settings_href
|
||||||
|
? `${name}'s settings`
|
||||||
|
: `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"
|
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-sm font-bold leading-5 text-ink truncate w-full">
|
||||||
|
{name}
|
||||||
|
</Span>
|
||||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
<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"}
|
{has_own
|
||||||
|
? "Has instructions for this workspace"
|
||||||
|
: bot.role || "Uses its own instructions here"}
|
||||||
</Span>
|
</Span>
|
||||||
</Link>
|
</Link>
|
||||||
<Stack className="shrink-0 flex-row! gap-1!">
|
<Stack className="shrink-0 flex-row! gap-1!">
|
||||||
|
{settings_href ? (
|
||||||
|
<Button
|
||||||
|
title={`Talk to ${name} here`}
|
||||||
|
aria-label={`Talk to ${name} here`}
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
color="text"
|
||||||
|
href={chat_href}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<MessageSquare size={16} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
title={`${name}'s instructions and memory here`}
|
title={`${name}'s instructions and memory here`}
|
||||||
aria-label={`${name}'s instructions and memory here`}
|
aria-label={`${name}'s instructions and memory here`}
|
||||||
+8
-2
@@ -8,7 +8,7 @@ import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
|||||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
import InlineAddSelect from "./inline-add-select";
|
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||||
import WorkspaceBotModal from "./workspace-bot-modal";
|
import WorkspaceBotModal from "./workspace-bot-modal";
|
||||||
import WorkspaceBotRow from "./workspace-bot-row";
|
import WorkspaceBotRow from "./workspace-bot-row";
|
||||||
|
|
||||||
@@ -60,6 +60,7 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
|
id="bots"
|
||||||
title="Bots"
|
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."
|
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
|
||||||
>
|
>
|
||||||
@@ -68,7 +69,12 @@ export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots:
|
|||||||
<WorkspaceBotRow
|
<WorkspaceBotRow
|
||||||
key={bot.id}
|
key={bot.id}
|
||||||
bot={bot}
|
bot={bot}
|
||||||
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
settings_href={
|
||||||
|
bot.kind == "coordinator"
|
||||||
|
? undefined
|
||||||
|
: `/admin/settings/bots/${bot.id}`
|
||||||
|
}
|
||||||
|
chat_href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||||
removing={busy_id == bot.id}
|
removing={busy_id == bot.id}
|
||||||
onEdit={() => setEditing(bot)}
|
onEdit={() => setEditing(bot)}
|
||||||
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
||||||
+2
-1
@@ -10,7 +10,7 @@ import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group"
|
|||||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||||
import InlineAddSelect from "./inline-add-select";
|
import InlineAddSelect from "@/src/components/general/inline-add-select";
|
||||||
import WorkspaceComputerRow from "./workspace-computer-row";
|
import WorkspaceComputerRow from "./workspace-computer-row";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -73,6 +73,7 @@ export default function WorkspaceComputersGroup({ workspace_id, computers: initi
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
|
id="computers"
|
||||||
title="Computers"
|
title="Computers"
|
||||||
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
||||||
>
|
>
|
||||||
+2
-2
@@ -1,7 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import Button from "@moduletrace/twui/layout/Button";
|
import Button from "@moduletrace/twui/layout/Button";
|
||||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
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 postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||||
@@ -28,7 +27,7 @@ export default function WorkspaceDeleteGroup({ workspace }: Props) {
|
|||||||
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
|
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
|
||||||
|
|
||||||
if (res.success) {
|
if (res.success) {
|
||||||
window.location.href = AdminHomePath;
|
window.location.href = "/admin/settings/workspaces";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,6 +37,7 @@ export default function WorkspaceDeleteGroup({ workspace }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
|
id="delete"
|
||||||
title="Delete"
|
title="Delete"
|
||||||
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
|
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
|
||||||
>
|
>
|
||||||
+1
@@ -57,6 +57,7 @@ export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup
|
<SettingsGroup
|
||||||
|
id="details"
|
||||||
title="Workspace"
|
title="Workspace"
|
||||||
description="Every bot working here gets this context on top of its own instructions."
|
description="Every bot working here gets this context on top of its own instructions."
|
||||||
>
|
>
|
||||||
+1
-1
@@ -43,7 +43,7 @@ export default function WorkspaceImageGroup({ workspace }: Props) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsGroup title="Picture" description="Shown beside the workspace's name in the rail.">
|
<SettingsGroup id="picture" title="Picture" description="Shown beside the workspace's name in the rail.">
|
||||||
<ImagePickField
|
<ImagePickField
|
||||||
preview={<WorkspaceAvatar size="lg" image_media_id={image_media_id} />}
|
preview={<WorkspaceAvatar size="lg" image_media_id={image_media_id} />}
|
||||||
has_image={Boolean(image_media_id)}
|
has_image={Boolean(image_media_id)}
|
||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { HarnessOverview } from "@/src/types";
|
import type { HarnessOverview } from "@/src/types";
|
||||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||||
import saveWorkspaceHarnessSettings from "../(functions)/save-workspace-harness-settings";
|
import saveWorkspaceHarnessSettings from "@/src/functions/frontend/save-workspace-harness-settings";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
workspace_id: number;
|
workspace_id: number;
|
||||||
+15
-20
@@ -1,20 +1,17 @@
|
|||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
|
||||||
import { AppContext } from "@/src/pages/__root";
|
import { AppContext } from "@/src/pages/__root";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
import SettingsSectionHeading from "../../../(partials)/settings-section-heading";
|
||||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
import SettingsShell from "../../../(partials)/settings-shell";
|
||||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
||||||
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
|
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
|
||||||
import WorkspaceDeleteGroup from "../(partials)/workspace-delete-group";
|
import WorkspaceDeleteGroup from "../(partials)/workspace-delete-group";
|
||||||
import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
|
import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
|
||||||
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
||||||
import WorkspaceModels from "../(partials)/workspace-models";
|
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
|
* A workspace's settings: its picture, context, bots, computers and
|
||||||
* its models, and deleting it. A member only lands here while it has no
|
* models, then deleting it. Super admins and admins only.
|
||||||
* bots yet.
|
|
||||||
*/
|
*/
|
||||||
export default function WorkspaceOverview() {
|
export default function WorkspaceOverview() {
|
||||||
const { pageProps } = useContext(AppContext);
|
const { pageProps } = useContext(AppContext);
|
||||||
@@ -22,27 +19,24 @@ export default function WorkspaceOverview() {
|
|||||||
|
|
||||||
if (!workspace?.id) return null;
|
if (!workspace?.id) return null;
|
||||||
|
|
||||||
if (!canManageCrew({ user_types: pageProps?.user_types })) {
|
|
||||||
return (
|
|
||||||
<SettingsShell title={workspace.name || "Workspace"} description={workspace.description || "A workspace."}>
|
|
||||||
<EmptyContent title="No bots here yet. An admin adds them." />
|
|
||||||
</SettingsShell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const workspace_id = Number(workspace.id);
|
const workspace_id = Number(workspace.id);
|
||||||
|
const name = workspace.name || "Workspace";
|
||||||
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
const harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsShell
|
<SettingsShell
|
||||||
title={workspace.name || "Workspace"}
|
title={name}
|
||||||
description={workspace.description || "Context, bots, computers and models for this workspace."}
|
description={
|
||||||
|
workspace.description ||
|
||||||
|
"Context, bots, computers and models for this workspace."
|
||||||
|
}
|
||||||
|
breadcrumb_title={name}
|
||||||
>
|
>
|
||||||
<WorkspaceImageGroup workspace={workspace} />
|
<WorkspaceImageGroup workspace={workspace} />
|
||||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||||
<WorkspaceBotsGroup
|
<WorkspaceBotsGroup
|
||||||
workspace_id={workspace_id}
|
workspace_id={workspace_id}
|
||||||
workspace_name={workspace.name || "this workspace"}
|
workspace_name={name}
|
||||||
bots={pageProps?.workspace_bots || []}
|
bots={pageProps?.workspace_bots || []}
|
||||||
all_bots={pageProps?.all_bots || []}
|
all_bots={pageProps?.all_bots || []}
|
||||||
/>
|
/>
|
||||||
@@ -51,16 +45,17 @@ export default function WorkspaceOverview() {
|
|||||||
computers={pageProps?.workspace_computers || []}
|
computers={pageProps?.workspace_computers || []}
|
||||||
all_computers={pageProps?.computers || []}
|
all_computers={pageProps?.computers || []}
|
||||||
/>
|
/>
|
||||||
<WorkspaceDeleteGroup workspace={workspace} />
|
|
||||||
{harnesses?.length ? (
|
{harnesses?.length ? (
|
||||||
<>
|
<>
|
||||||
<WorkspaceSectionHeading
|
<SettingsSectionHeading
|
||||||
|
id="models"
|
||||||
title="Models"
|
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."
|
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} />
|
<WorkspaceModels workspace_id={workspace_id} harnesses={harnesses} />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
<WorkspaceDeleteGroup workspace={workspace} />
|
||||||
</SettingsShell>
|
</SettingsShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||||
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
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 grabCrewSettingsServerProps from "../../(functions)/grab-crew-settings-server-props";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One workspace's settings: its picture, details, bots, computers and
|
||||||
|
* models. For super admins and admins.
|
||||||
|
*/
|
||||||
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
|
const settings = await grabCrewSettingsServerProps(params);
|
||||||
|
|
||||||
|
if (!settings.props?.person?.id) return settings;
|
||||||
|
|
||||||
|
const query: PageQueryObject = params.query;
|
||||||
|
const user_id = settings.props.person.id;
|
||||||
|
|
||||||
|
const workspaces = await grabUserWorkspaces({ user_id });
|
||||||
|
const workspace = workspaces.find(
|
||||||
|
(item) => String(item.id) == String(query.workspace_id),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!workspace?.id) {
|
||||||
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: "/admin/settings/workspaces",
|
||||||
|
permanent: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const workspace_id = Number(workspace.id);
|
||||||
|
const [
|
||||||
|
workspace_bots,
|
||||||
|
all_bots,
|
||||||
|
workspace_computers,
|
||||||
|
computers,
|
||||||
|
workspace_harness_overview,
|
||||||
|
] = await Promise.all([
|
||||||
|
grabWorkspaceBots({ user_id, workspace_id }),
|
||||||
|
grabBots({ user_id }),
|
||||||
|
grabWorkspaceComputers({ workspace_id }),
|
||||||
|
grabUserComputers({ user_id }),
|
||||||
|
grabBotHarnessOverview({ workspace_id }).catch(() => null),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
props: {
|
||||||
|
...settings.props,
|
||||||
|
workspaces,
|
||||||
|
workspace,
|
||||||
|
bots: workspace_bots,
|
||||||
|
workspace_bots,
|
||||||
|
all_bots,
|
||||||
|
workspace_computers,
|
||||||
|
computers,
|
||||||
|
workspace_harness_overview,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import WorkspaceOverview from "./(sections)/workspace-overview";
|
||||||
|
import settingsRecordPageMeta from "../../(functions)/settings-record-page-meta";
|
||||||
|
|
||||||
|
export default function AdminSettingsWorkspacePage() {
|
||||||
|
return <WorkspaceOverview />;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const meta = settingsRecordPageMeta({ kind: "workspaces" });
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A bot's conversation inside a workspace: the bot works with the
|
* 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 context, computers and models. Only the coordinator and the
|
||||||
* workspace's bots have one; anyone else goes back to the workspace.
|
* workspace's bots have one; anyone else goes back to the workspace
|
||||||
|
* settings (managers) or their bots.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
const query: PageQueryObject = params.query;
|
const query: PageQueryObject = params.query;
|
||||||
const { user } = await userAuth({ req: params.req });
|
const { user, user_types } = await userAuth({ req: params.req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return { redirect: { destination: "/auth/login" } };
|
return { redirect: { destination: "/auth/login" } };
|
||||||
@@ -23,7 +25,13 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (!props) {
|
if (!props) {
|
||||||
return { redirect: { destination: `/admin/workspaces/${query.workspace_id}` } };
|
return {
|
||||||
|
redirect: {
|
||||||
|
destination: canManageCrew({ user_types })
|
||||||
|
? `/admin/settings/workspaces/${query.workspace_id}`
|
||||||
|
: "/admin/bots",
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props };
|
return { props };
|
||||||
|
|||||||
@@ -1,19 +1,15 @@
|
|||||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||||
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 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 { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
|
||||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A workspace's page: its details, crew, computers and models, with the
|
* The workspace's old landing page. Management moved to
|
||||||
* rail showing its crew. Only super admins and admins manage it; a member
|
* `/admin/settings/workspaces/<id>`: managers go there, members go to the
|
||||||
* opens its first bot instead.
|
* workspace's first bot. No page renders.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||||
const query: PageQueryObject = params.query;
|
const query: PageQueryObject = params.query;
|
||||||
@@ -23,47 +19,37 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
|||||||
return { redirect: { destination: "/auth/login" } };
|
return { redirect: { destination: "/auth/login" } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspaces = await grabUserWorkspaces({ user_id: user.id });
|
const workspace = await grabUserWorkspace({
|
||||||
const workspace = workspaces.find((w) => String(w.id) == String(query.workspace_id));
|
workspace_id: String(query.workspace_id),
|
||||||
|
user_id: user.id,
|
||||||
|
});
|
||||||
|
|
||||||
if (!workspace?.id) {
|
if (!workspace?.id) {
|
||||||
return { redirect: { destination: AdminHomePath } };
|
return { redirect: { destination: AdminHomePath, permanent: false } };
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace_id = Number(workspace.id);
|
const workspace_id = Number(workspace.id);
|
||||||
|
|
||||||
/**
|
if (canManageCrew({ user_types })) {
|
||||||
* Members don't manage it: they go straight to its first bot.
|
return {
|
||||||
*/
|
redirect: {
|
||||||
if (!canManageCrew({ user_types })) {
|
destination: `/admin/settings/workspaces/${workspace_id}`,
|
||||||
const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id });
|
permanent: false,
|
||||||
|
},
|
||||||
if (workspace_bots[0]?.id) {
|
};
|
||||||
return { redirect: { destination: `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}` } };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return { props: { workspaces, workspace, bots: workspace_bots, workspace_bots } };
|
const workspace_bots = await grabWorkspaceBots({
|
||||||
}
|
user_id: user.id,
|
||||||
|
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 {
|
return {
|
||||||
props: {
|
redirect: {
|
||||||
workspaces,
|
destination: workspace_bots[0]?.id
|
||||||
workspace,
|
? `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`
|
||||||
bots: workspace_bots,
|
: AdminHomePath,
|
||||||
workspace_bots,
|
permanent: false,
|
||||||
all_bots,
|
|
||||||
workspace_computers,
|
|
||||||
computers,
|
|
||||||
workspace_harness_overview,
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,17 +1,15 @@
|
|||||||
import type { PagePropsType } from "@/src/types";
|
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
|
||||||
import { SiteData } from "@/src/data/site-data";
|
import { SiteData } from "@/src/data/site-data";
|
||||||
import WorkspaceOverview from "./(sections)/workspace-overview";
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Never rendered: `index.server.ts` always redirects. Workspace settings
|
||||||
|
* live at `/admin/settings/workspaces/<id>`.
|
||||||
|
*/
|
||||||
export default function WorkspacePage() {
|
export default function WorkspacePage() {
|
||||||
return <WorkspaceOverview />;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
export const meta: BunextPageModuleMeta = {
|
||||||
const workspace = (params.serverRes?.props as PagePropsType | undefined)?.workspace;
|
title: `Workspaces | ${SiteData["SiteName"]}`,
|
||||||
|
description: `Your workspaces.`,
|
||||||
return {
|
|
||||||
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
|
||||||
description: workspace?.description || `A workspace.`,
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,13 +2,15 @@ import userAuth from "@/src/functions/backend/auth/user-auth";
|
|||||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||||
import type { PagePropsType } from "@/src/types";
|
import type { PagePropsType } from "@/src/types";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the user's first workspace, or the bots when they have none.
|
* Opens the user's first workspace: its settings page for managers, its
|
||||||
|
* first bot for members. The bots when they have no workspaces.
|
||||||
*/
|
*/
|
||||||
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
||||||
const { user } = await userAuth({ req });
|
const { user, user_types } = await userAuth({ req });
|
||||||
|
|
||||||
if (!user?.id) {
|
if (!user?.id) {
|
||||||
return { redirect: { destination: "/auth/login" } };
|
return { redirect: { destination: "/auth/login" } };
|
||||||
@@ -16,9 +18,15 @@ export const server: BunextPageServerFn<PagePropsType> = async ({ req }) => {
|
|||||||
|
|
||||||
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
|
const first = (await grabUserWorkspaces({ user_id: user.id }))[0];
|
||||||
|
|
||||||
|
if (!first?.id) {
|
||||||
|
return { redirect: { destination: AdminHomePath } };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
redirect: {
|
redirect: {
|
||||||
destination: first?.id ? `/admin/workspaces/${first.id}` : AdminHomePath,
|
destination: canManageCrew({ user_types })
|
||||||
|
? `/admin/settings/workspaces/${first.id}`
|
||||||
|
: `/admin/workspaces/${first.id}`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import addBotToComputer from "@/src/functions/backend/db/computers/add-bot-to-computer";
|
||||||
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { BotComputerRequestSchema } from "@/src/schemas/bot-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import 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";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gives one of the session user's bots direct access to one of their
|
||||||
|
* computers. Returns the bot's computers.
|
||||||
|
*/
|
||||||
|
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 (!canManageCrew({ user_types })) {
|
||||||
|
throw new AppError(`Only an admin can do that.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bot_id, computer_id } = parseWithSchema({
|
||||||
|
schema: BotComputerRequestSchema,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await addBotToComputer({ user_id: user.id, bot_id, computer_id });
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot computer access changed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} gave bot #${bot_id} access to computer #${computer_id}.`,
|
||||||
|
metadata: { bot_id, computer_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved.`,
|
||||||
|
payload: await grabBotComputers({ bot_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||||
|
import grabBotComputers from "@/src/functions/backend/db/computers/grab-bot-computers";
|
||||||
|
import removeBotFromComputer from "@/src/functions/backend/db/computers/remove-bot-from-computer";
|
||||||
|
import logEvent, { grabRequestIP } from "@/src/functions/backend/logger";
|
||||||
|
import { BotComputerRequestSchema } from "@/src/schemas/bot-schema";
|
||||||
|
import AppError from "@/src/utils/app-error";
|
||||||
|
import parseWithSchema from "@/src/utils/parse-with-schema";
|
||||||
|
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||||
|
import 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 away one of the session user's bots' direct access to one of
|
||||||
|
* their computers. Returns the bot's computers.
|
||||||
|
*/
|
||||||
|
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 (!canManageCrew({ user_types })) {
|
||||||
|
throw new AppError(`Only an admin can do that.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { bot_id, computer_id } = parseWithSchema({
|
||||||
|
schema: BotComputerRequestSchema,
|
||||||
|
data: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
await removeBotFromComputer({
|
||||||
|
user_id: user.id,
|
||||||
|
bot_id,
|
||||||
|
computer_id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await logEvent({
|
||||||
|
title: "Bot computer access changed",
|
||||||
|
category: "system",
|
||||||
|
user_id: user.id,
|
||||||
|
plain_text: `User #${user.id} took bot #${bot_id}'s access to computer #${computer_id} away.`,
|
||||||
|
metadata: { bot_id, computer_id },
|
||||||
|
ip_address: grabRequestIP(req),
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
msg: `Saved.`,
|
||||||
|
payload: await grabBotComputers({ bot_id }),
|
||||||
|
};
|
||||||
|
} catch (error: unknown) {
|
||||||
|
return { success: false, msg: safeErrorMessage({ error }) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -37,6 +37,16 @@ export const SetBotComputerRequestSchema = z.object({
|
|||||||
|
|
||||||
export type SetBotComputerRequest = z.infer<typeof SetBotComputerRequestSchema>;
|
export type SetBotComputerRequest = z.infer<typeof SetBotComputerRequestSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giving a bot direct access to a computer, or taking it away.
|
||||||
|
*/
|
||||||
|
export const BotComputerRequestSchema = z.object({
|
||||||
|
bot_id: z.coerce.number().int().positive("Pick a bot"),
|
||||||
|
computer_id: z.coerce.number().int().positive("Pick a computer"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type BotComputerRequest = z.infer<typeof BotComputerRequestSchema>;
|
||||||
|
|
||||||
export const BotSessionIdRequestSchema = z.object({
|
export const BotSessionIdRequestSchema = z.object({
|
||||||
session_id: z.coerce.number().int().positive("Pick a session"),
|
session_id: z.coerce.number().int().positive("Pick a session"),
|
||||||
});
|
});
|
||||||
|
|||||||
+19
-1
@@ -101,6 +101,15 @@ export type WorkspaceBot = BotWithLastMessage & {
|
|||||||
workspace_memory?: string | null;
|
workspace_memory?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A workspace a bot works in, with what the bot has for that workspace
|
||||||
|
* alone.
|
||||||
|
*/
|
||||||
|
export type BotWorkspace = WorkspaceWithActivity & {
|
||||||
|
workspace_system_prompt?: string | null;
|
||||||
|
workspace_memory?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The task shown in the working-on panel, with its ordered steps.
|
* The task shown in the working-on panel, with its ordered steps.
|
||||||
*/
|
*/
|
||||||
@@ -346,6 +355,11 @@ export type PagePropsType = {
|
|||||||
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
user_types?: BUN_SQLITE_COBBLE_USER_TYPES[] | null;
|
||||||
person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
|
person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
|
||||||
persons?: BUN_SQLITE_COBBLE_USERS_JOIN[] | null;
|
persons?: BUN_SQLITE_COBBLE_USERS_JOIN[] | null;
|
||||||
|
/**
|
||||||
|
* The user a settings page manages, when it is not the session user
|
||||||
|
* (`/admin/settings/users/<id>`).
|
||||||
|
*/
|
||||||
|
target_person?: BUN_SQLITE_COBBLE_USERS_JOIN | null;
|
||||||
/**
|
/**
|
||||||
* Every worker and workspace, to pick what a user reaches
|
* Every worker and workspace, to pick what a user reaches
|
||||||
* (`grabCrewOptions`).
|
* (`grabCrewOptions`).
|
||||||
@@ -379,10 +393,14 @@ export type PagePropsType = {
|
|||||||
workspace_computers?: ComputerWithStatus[] | null;
|
workspace_computers?: ComputerWithStatus[] | null;
|
||||||
computers?: ComputerWithStatus[] | null;
|
computers?: ComputerWithStatus[] | null;
|
||||||
workspace_harness_overview?: BotHarnessOverviewPayload | null;
|
workspace_harness_overview?: BotHarnessOverviewPayload | null;
|
||||||
|
/**
|
||||||
|
* The workspaces a bot works in, with what it has for each alone.
|
||||||
|
*/
|
||||||
|
bot_workspaces?: BotWorkspace[] | null;
|
||||||
/**
|
/**
|
||||||
* The open bot's computers, the one it's on first.
|
* The open bot's computers, the one it's on first.
|
||||||
*/
|
*/
|
||||||
bot_computers?: BUN_SQLITE_COBBLE_COMPUTERS[] | null;
|
bot_computers?: ComputerWithStatus[] | null;
|
||||||
/**
|
/**
|
||||||
* The open bot's active task and what its job has done so far.
|
* The open bot's active task and what its job has done so far.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import parseSettingsRecordPath from "./parse-settings-record-path";
|
||||||
|
|
||||||
|
describe("parseSettingsRecordPath", () => {
|
||||||
|
test("reads a nested settings record", () => {
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({ pathname: "/admin/settings/bots/3" }),
|
||||||
|
).toEqual({ kind: "bots", id: "3" });
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({
|
||||||
|
pathname: "/admin/settings/workspaces/12/",
|
||||||
|
}),
|
||||||
|
).toEqual({ kind: "workspaces", id: "12" });
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({ pathname: "/admin/settings/users/8" }),
|
||||||
|
).toEqual({ kind: "users", id: "8" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("ignores settings list pages and other paths", () => {
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({ pathname: "/admin/settings/bots" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({ pathname: "/admin/settings/models" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({
|
||||||
|
pathname: "/admin/settings/bots/3/models",
|
||||||
|
}),
|
||||||
|
).toBeNull();
|
||||||
|
expect(
|
||||||
|
parseSettingsRecordPath({ pathname: "/admin/bots/3" }),
|
||||||
|
).toBeNull();
|
||||||
|
expect(parseSettingsRecordPath({ pathname: undefined })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export type SettingsRecordKind = "bots" | "workspaces" | "users";
|
||||||
|
|
||||||
|
export type SettingsRecordPath = {
|
||||||
|
kind: SettingsRecordKind;
|
||||||
|
id: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Params = {
|
||||||
|
pathname?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The record a nested settings page manages, e.g.
|
||||||
|
* `/admin/settings/bots/3` -> `{ kind: "bots", id: "3" }`. Null on the
|
||||||
|
* settings list pages and everywhere else.
|
||||||
|
*/
|
||||||
|
export default function parseSettingsRecordPath({
|
||||||
|
pathname,
|
||||||
|
}: Params): SettingsRecordPath | null {
|
||||||
|
if (!pathname) return null;
|
||||||
|
|
||||||
|
const match = pathname
|
||||||
|
.replace(/\/$/, "")
|
||||||
|
.match(/^\/admin\/settings\/(bots|workspaces|users)\/([^/]+)$/);
|
||||||
|
|
||||||
|
if (!match) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
kind: match[1] as SettingsRecordKind,
|
||||||
|
id: match[2]!,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user