Add dedicated bot, workspace and user settings pages

This commit is contained in:
2026-10-19 13:17:17 +01:00
parent 731ea73486
commit 7667f2bc2f
74 changed files with 2183 additions and 358 deletions
@@ -1,92 +0,0 @@
import { useState } from "react";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
type Props = {
bot: BotWithLastMessage;
/**
* In a workspace the form saves the workspace's models, for every bot
* working there.
*/
workspace_id?: number | null;
/**
* A worker's `grabBotHarnessOverview`; for the coordinator, the
* defaults (`grabHarnessOverview`).
*/
harnesses: HarnessOverview[];
/**
* Gets the fresh harnesses after each save.
*/
onSaved?: (harnesses: HarnessOverview[]) => void;
};
/**
* The Models settings form (`HarnessSettingsForm`) for one bot. A worker
* saves its own picks ("same as the default" drops one). The coordinator
* runs on the defaults, so it saves the defaults themselves, which only a
* super admin can.
*/
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
const [harnesses, setHarnesses] = useState(initial);
/**
* Remounts the form when the data changes under it.
*/
const [version, setVersion] = useState(0);
const [notice, setNotice] = useState<string>();
const saved = (fresh: HarnessOverview[], msg?: string) => {
setHarnesses(fresh);
setNotice(msg || "Saved.");
setVersion((prev) => prev + 1);
onSaved?.(fresh);
};
return (
<HarnessSettingsForm
key={version}
harnesses={harnesses}
notice={notice}
save={async ({ settings }) => {
if (workspace_id) {
const res = await saveWorkspaceHarnessSettings({
workspace_id,
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
if (bot.kind == "coordinator") {
const res = await postHarnessApi({
path: "save-settings",
body: { settings },
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}
const res = await saveBotHarnessSettings({
bot_id: Number(bot.id),
settings: settings.map((setting) => ({
...setting,
inherit: Boolean(setting.inherit),
})),
});
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
return res;
}}
/>
);
}
@@ -9,7 +9,7 @@ import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
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";
type Props = {
@@ -2,15 +2,19 @@ import { useContext, useState } from "react";
import Row from "@moduletrace/twui/layout/Row";
import Stack from "@moduletrace/twui/layout/Stack";
import H1 from "@moduletrace/twui/layout/H1";
import { CpuAi, 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 { 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 BotComputersModal from "./bot-computers-modal";
import BotModelsModal from "./bot-models-modal";
import BotProfileModal from "./bot-profile-modal";
import BotSessionsModal from "./bot-sessions-modal";
import NewSessionButton from "./new-session-button";
import HeaderIconButton from "./header-icon-button";
import HeaderMetaItem from "./header-meta-item";
type Props = {
@@ -36,9 +40,14 @@ type Props = {
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
const [harnesses, setHarnesses] = useState(initial);
const { computers } = useContext(BotComputersContext);
const { pageProps } = useContext(AppContext);
const model = harnesses?.find((h) => h.harness == "text")?.current;
const computer = computers[0];
const can_open_settings =
bot.kind == "coordinator"
? isPassThroughUser({ user_types: pageProps?.user_types })
: canManageCrew({ user_types: pageProps?.user_types });
return (
<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}
onSaved={setHarnesses}
/>
{can_open_settings ? (
<HeaderIconButton
icon={Settings}
title={`${bot.name || "This bot"}'s settings`}
href={`/admin/settings/bots/${bot.id}`}
/>
) : null}
</Row>
</Row>
);
@@ -13,13 +13,24 @@ type Props = {
* For a button that acts on its own; a modal's target leaves it out.
*/
onClick?: () => void;
/**
* For a button that opens a page.
*/
href?: string;
};
/**
* A ghost icon button on the right of the conversation header, usually a
* modal's target.
*/
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
export default function HeaderIconButton({
icon: Icon,
title,
disabled,
loading,
onClick,
href,
}: Props) {
return (
<Button
title={title}
@@ -30,6 +41,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading,
disabled={disabled}
loading={loading}
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"
>
<Icon size={18} aria-hidden />