Add dedicated bot, workspace and user settings pages
This commit is contained in:
-17
@@ -1,17 +0,0 @@
|
||||
import fetchApi from "@moduletrace/twui/utils/fetch/fetchApi";
|
||||
import type { APIResponseObject } from "@moduletrace/bunext/types";
|
||||
import type { SaveWorkspaceHarnessSettingsRequest } from "@/src/schemas/harness-schema";
|
||||
import type { BotHarnessOverviewPayload } from "@/src/types";
|
||||
|
||||
/**
|
||||
* Saves a workspace's model settings and returns the fresh page data.
|
||||
*/
|
||||
export default async function saveWorkspaceHarnessSettings(body: SaveWorkspaceHarnessSettingsRequest) {
|
||||
return await fetchApi<
|
||||
SaveWorkspaceHarnessSettingsRequest,
|
||||
APIResponseObject<BotHarnessOverviewPayload>
|
||||
>(`/api/admin/workspaces/save-harness-settings`, {
|
||||
method: "POST",
|
||||
body,
|
||||
});
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Select from "@moduletrace/twui/form/Select";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
/**
|
||||
* What's being picked, e.g. "bot".
|
||||
*/
|
||||
noun: string;
|
||||
options: { value: string; title: string }[];
|
||||
loading?: boolean;
|
||||
onAdd: (params: { value: string }) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A select of things to add, and an Add button beside it.
|
||||
*/
|
||||
export default function InlineAddSelect({ id, noun, options, loading, onAdd }: Props) {
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
if (!options.length) return null;
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-2! flex-nowrap items-end">
|
||||
<Select
|
||||
id={id}
|
||||
title={`Pick a ${noun}`}
|
||||
aria-label={`Pick a ${noun}`}
|
||||
options={[
|
||||
{ value: "", title: `Pick a ${noun}…`, default: !value },
|
||||
...options.map((option) => ({ ...option, default: option.value == value })),
|
||||
]}
|
||||
changeHandler={(next) => setValue(String(next))}
|
||||
wrapperWrapperProps={{ className: "grow min-w-0" }}
|
||||
/>
|
||||
<Button
|
||||
title={`Add the ${noun}`}
|
||||
type="button"
|
||||
size="small"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
loading={loading}
|
||||
disabled={!value || loading}
|
||||
onClick={() => value && onAdd({ value })}
|
||||
className="shrink-0"
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import AddFormActions from "@/src/layouts/bots/(partials)/add-form-actions";
|
||||
import AddModalShell from "@/src/layouts/bots/(partials)/add-modal-shell";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
workspace_name: string;
|
||||
bot: WorkspaceBot;
|
||||
onClose: () => void;
|
||||
onSaved: (bots: WorkspaceBot[]) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* What one bot has for this workspace alone: instructions on top of its
|
||||
* own, and its memory here. The bot rewrites both as it learns; the
|
||||
* coordinator can set the instructions too.
|
||||
*/
|
||||
export default function WorkspaceBotModal({ workspace_id, workspace_name, bot, onClose, onSaved }: Props) {
|
||||
const [system_prompt, setSystemPrompt] = useState(bot.workspace_system_prompt || "");
|
||||
const [memory, setMemory] = useState(bot.workspace_memory || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
const submit = async () => {
|
||||
if (loading) return;
|
||||
if (!window.confirm(`Save ${bot.name || "this bot"}'s instructions and memory here?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: "workspaces/update-bot",
|
||||
body: { workspace_id, bot_id: bot.id, system_prompt, memory },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success) {
|
||||
setError(res.msg || "Couldn't save.");
|
||||
return;
|
||||
}
|
||||
|
||||
onSaved(res.payload || []);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<AddModalShell
|
||||
open
|
||||
setOpen={(open) => !open && onClose()}
|
||||
title={`${name} in ${workspace_name}`}
|
||||
description={`${name} keeps its own instructions and memory everywhere. These apply only here, and it updates them itself as it learns how this workspace works.`}
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch pt-4" onSubmit={submit}>
|
||||
<Textarea
|
||||
id="workspace-bot-instructions"
|
||||
title="Instructions here"
|
||||
label="Instructions here"
|
||||
showLabel
|
||||
rows={7}
|
||||
defaultValue={system_prompt}
|
||||
placeholder="In this workspace, write for the marketing team: short, plain, no jargon."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-bot-memory"
|
||||
title="Memory here"
|
||||
label="Memory here"
|
||||
showLabel
|
||||
rows={5}
|
||||
defaultValue={memory}
|
||||
info="What it must remember in this workspace."
|
||||
onChange={(e) => setMemory(e.target.value)}
|
||||
/>
|
||||
<AddFormActions submit_label="Save" loading={loading} error={error} onCancel={onClose} />
|
||||
</Form>
|
||||
</AddModalShell>
|
||||
);
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Link from "@moduletrace/twui/layout/Link";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { NotebookPen, X } from "lucide-react";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { WorkspaceBot } from "@/src/types";
|
||||
|
||||
type Props = {
|
||||
bot: WorkspaceBot;
|
||||
href: string;
|
||||
removing?: boolean;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot in the workspace: opens its conversation here, edits what it has
|
||||
* for this workspace alone, or takes it out.
|
||||
*/
|
||||
export default function WorkspaceBotRow({ bot, href, removing, onEdit, onRemove }: Props) {
|
||||
const name = bot.name || "This bot";
|
||||
const has_own = Boolean(bot.workspace_system_prompt || bot.workspace_memory);
|
||||
|
||||
return (
|
||||
<Row className="w-full min-h-14 gap-3! flex-nowrap items-center px-3 py-2">
|
||||
<BotAvatar image_media_id={bot.image_media_id} hue={(bot.hue || null) as BotHue | null} face={(bot.face || "idle") as BotFace} />
|
||||
<Link
|
||||
href={href}
|
||||
title={`Talk to ${name} here`}
|
||||
className="grow min-w-0 flex flex-col border-0 leading-normal hover:opacity-100 focus-visible:ring-2 focus-visible:ring-ink rounded-default-sm"
|
||||
>
|
||||
<Span className="text-sm font-bold leading-5 text-ink truncate w-full">{name}</Span>
|
||||
<Span className="text-xs leading-4 text-ink-secondary truncate w-full">
|
||||
{has_own ? "Has instructions for this workspace" : bot.role || "Uses its own instructions here"}
|
||||
</Span>
|
||||
</Link>
|
||||
<Stack className="shrink-0 flex-row! gap-1!">
|
||||
<Button
|
||||
title={`${name}'s instructions and memory here`}
|
||||
aria-label={`${name}'s instructions and memory here`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
onClick={onEdit}
|
||||
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
|
||||
>
|
||||
<NotebookPen size={16} aria-hidden />
|
||||
</Button>
|
||||
<Button
|
||||
title={`Take ${name} out of this workspace`}
|
||||
aria-label={`Take ${name} out of this workspace`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={removing}
|
||||
disabled={removing}
|
||||
onClick={onRemove}
|
||||
className="p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default"
|
||||
>
|
||||
<X size={16} aria-hidden />
|
||||
</Button>
|
||||
</Stack>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -1,114 +0,0 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { Plus } from "lucide-react";
|
||||
import type { BotWithLastMessage, WorkspaceBot } from "@/src/types";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceBotModal from "./workspace-bot-modal";
|
||||
import WorkspaceBotRow from "./workspace-bot-row";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
workspace_name: string;
|
||||
/**
|
||||
* The workspace's bots (the coordinator included, always a member).
|
||||
*/
|
||||
bots: WorkspaceBot[];
|
||||
/**
|
||||
* Every bot the user has.
|
||||
*/
|
||||
all_bots: BotWithLastMessage[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which bots work here. A bot can be in any number of workspaces; in each
|
||||
* it can have instructions and memory of its own. Changes reload the
|
||||
* page, so the rail shows the new crew.
|
||||
*/
|
||||
export default function WorkspaceBotsGroup({ workspace_id, workspace_name, bots: initial, all_bots }: Props) {
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const [bots, setBots] = useState(initial);
|
||||
const [busy_id, setBusyId] = useState<number | "add">();
|
||||
const [editing, setEditing] = useState<WorkspaceBot>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const workers = bots.filter((bot) => bot.kind != "coordinator");
|
||||
const addable = all_bots.filter((bot) => bot.kind != "coordinator" && !bots.some((b) => b.id == bot.id));
|
||||
|
||||
const change = async ({ path, bot_id }: { path: "add-bot" | "remove-bot"; bot_id: number }) => {
|
||||
setBusyId(path == "add-bot" ? "add" : bot_id);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi<WorkspaceBot>({
|
||||
path: `workspaces/${path}`,
|
||||
body: { workspace_id, bot_id },
|
||||
});
|
||||
|
||||
if (res.success) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setBusyId(undefined);
|
||||
setError(res.msg || "That didn't work.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Bots"
|
||||
description="The coordinator works in every workspace. Add the bots this one needs; each keeps its skills and follows this workspace's ways here."
|
||||
>
|
||||
<Stack className="w-full py-1 gap-0! items-stretch rounded-default border border-solid border-hairline bg-surface">
|
||||
{workers.map((bot) => (
|
||||
<WorkspaceBotRow
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
href={`/admin/workspaces/${workspace_id}/bots/${bot.id}`}
|
||||
removing={busy_id == bot.id}
|
||||
onEdit={() => setEditing(bot)}
|
||||
onRemove={() => change({ path: "remove-bot", bot_id: Number(bot.id) })}
|
||||
/>
|
||||
))}
|
||||
{workers.length ? null : (
|
||||
<Span className="px-4 py-4 text-[13px] leading-[18px] text-ink-secondary">
|
||||
No bots here yet. Only the coordinator works in this workspace.
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-bot"
|
||||
noun="bot"
|
||||
options={addable.map((bot) => ({ value: String(bot.id), title: bot.name || `Bot ${bot.id}` }))}
|
||||
loading={busy_id == "add"}
|
||||
onAdd={({ value }) => change({ path: "add-bot", bot_id: Number(value) })}
|
||||
/>
|
||||
<Button
|
||||
title="Add a new bot to this workspace"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "bot" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New bot
|
||||
</Button>
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
{editing ? (
|
||||
<WorkspaceBotModal
|
||||
workspace_id={workspace_id}
|
||||
workspace_name={workspace_name}
|
||||
bot={editing}
|
||||
onClose={() => setEditing(undefined)}
|
||||
onSaved={setBots}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
import ComputerIconTile from "@/src/components/general/computer-icon-tile";
|
||||
import SettingsListRow from "@/src/pages/admin/settings/(partials)/settings-list-row";
|
||||
import SettingsRowAction from "@/src/pages/admin/settings/(partials)/settings-row-action";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import computerStatusLabel from "@/src/utils/computer-status-label";
|
||||
|
||||
type Props = {
|
||||
computer: ComputerWithStatus;
|
||||
is_default: boolean;
|
||||
is_super_admin: boolean;
|
||||
busy?: boolean;
|
||||
onMakeDefault: () => void;
|
||||
onUnlink: () => void;
|
||||
onRetry: () => void;
|
||||
onDelete: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A computer linked to the workspace: its name, state and whether it's
|
||||
* the default, then what can be done with it.
|
||||
*/
|
||||
export default function WorkspaceComputerRow({
|
||||
computer,
|
||||
is_default,
|
||||
is_super_admin,
|
||||
busy,
|
||||
onMakeDefault,
|
||||
onUnlink,
|
||||
onRetry,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const name = computer.name || `Computer ${computer.id}`;
|
||||
|
||||
return (
|
||||
<SettingsListRow
|
||||
avatar={<ComputerIconTile />}
|
||||
title={name}
|
||||
badge={is_default ? "Default" : undefined}
|
||||
subtitle={`${computerStatusLabel({ computer })}${computer.description ? ` · ${computer.description}` : ""}`}
|
||||
>
|
||||
{is_default ? null : (
|
||||
<SettingsRowAction title={`Make ${name} the default`} label="Make default" disabled={busy} onClick={onMakeDefault} />
|
||||
)}
|
||||
{computer.status == "failed" && is_super_admin ? (
|
||||
<SettingsRowAction title={`Set ${name} up again`} label="Retry" disabled={busy} onClick={onRetry} />
|
||||
) : null}
|
||||
<SettingsRowAction title={`Unlink ${name}; it stays yours`} label="Unlink" disabled={busy} onClick={onUnlink} />
|
||||
{is_super_admin && computer.kind != "main" ? (
|
||||
<SettingsRowAction title={`Delete ${name}; its files are kept`} label="Delete" disabled={busy} onClick={onDelete} />
|
||||
) : null}
|
||||
</SettingsListRow>
|
||||
);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
import { useContext, useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import { Plus } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { ComputerWithStatus } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import { AddFlowContext } from "@/src/layouts/bots/(data)/add-flow-context";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsList from "@/src/pages/admin/settings/(partials)/settings-list";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useLiveComputers from "@/src/hooks/use-live-computers";
|
||||
import InlineAddSelect from "./inline-add-select";
|
||||
import WorkspaceComputerRow from "./workspace-computer-row";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
/**
|
||||
* Linked to the workspace, its default first.
|
||||
*/
|
||||
computers: ComputerWithStatus[];
|
||||
/**
|
||||
* Every computer the user has.
|
||||
*/
|
||||
all_computers: ComputerWithStatus[];
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The computers bots work on here. Tasks run on the default unless a bot
|
||||
* picks another; with none, tasks needing a computer can't run. Unlinking
|
||||
* keeps the computer; deleting it (super admins) keeps its files.
|
||||
*/
|
||||
export default function WorkspaceComputersGroup({ workspace_id, computers: initial, all_computers }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { openForm } = useContext(AddFlowContext);
|
||||
const { computers, setComputers } = useLiveComputers({ computers: initial });
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const is_super_admin = isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
const default_id = computers[0]?.id;
|
||||
const addable = all_computers.filter((computer) => !computers.some((c) => c.id == computer.id));
|
||||
|
||||
const run = async ({ path, computer_id }: { path: string; computer_id: number }) => {
|
||||
setBusy(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<ComputerWithStatus>({
|
||||
path,
|
||||
body: { workspace_id, computer_id },
|
||||
});
|
||||
|
||||
setBusy(false);
|
||||
|
||||
if (!res.success) {
|
||||
setStatus({ error: true, msg: res.msg || "That didn't work." });
|
||||
return;
|
||||
}
|
||||
|
||||
if (res.payload) {
|
||||
setComputers(res.payload);
|
||||
} else if (res.singleRes) {
|
||||
const fresh = res.singleRes;
|
||||
setComputers((prev) => prev.map((c) => (c.id == fresh.id ? { ...c, ...fresh } : c)));
|
||||
} else {
|
||||
setComputers((prev) => prev.filter((c) => c.id != computer_id));
|
||||
}
|
||||
|
||||
setStatus(res.msg ? { success: true, msg: res.msg } : undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Computers"
|
||||
description="Bots here work on these. Their tasks run on the default one unless they pick another."
|
||||
>
|
||||
<SettingsList
|
||||
is_empty={!computers.length}
|
||||
empty="No computers yet. Bots here can chat, but tasks that need a computer can't run."
|
||||
>
|
||||
{computers.map((computer) => (
|
||||
<WorkspaceComputerRow
|
||||
key={computer.id}
|
||||
computer={computer}
|
||||
is_default={computer.id == default_id}
|
||||
is_super_admin={is_super_admin}
|
||||
busy={busy}
|
||||
onMakeDefault={() => run({ path: "workspaces/set-computer", computer_id: Number(computer.id) })}
|
||||
onUnlink={() => run({ path: "workspaces/remove-computer", computer_id: Number(computer.id) })}
|
||||
onRetry={() => run({ path: "computers/retry-computer", computer_id: Number(computer.id) })}
|
||||
onDelete={() => {
|
||||
if (!window.confirm(`Delete ${computer.name || "this computer"}? Its container goes; its files stay on disk.`)) return;
|
||||
|
||||
run({ path: "computers/delete-computer", computer_id: Number(computer.id) });
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</SettingsList>
|
||||
<InlineAddSelect
|
||||
id="workspace-add-computer"
|
||||
noun="computer"
|
||||
options={addable.map((c) => ({ value: String(c.id), title: c.name || `Computer ${c.id}` }))}
|
||||
loading={busy}
|
||||
onAdd={({ value }) => run({ path: "workspaces/add-computer", computer_id: Number(value) })}
|
||||
/>
|
||||
{is_super_admin ? (
|
||||
<Button
|
||||
title="Create a new computer for this workspace"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
onClick={() => openForm({ kind: "computer" })}
|
||||
className="self-start gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New computer
|
||||
</Button>
|
||||
) : null}
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
};
|
||||
|
||||
/**
|
||||
* Deletes the workspace and its conversations, after a confirm. Its bots
|
||||
* and computers stay.
|
||||
*/
|
||||
export default function WorkspaceDeleteGroup({ workspace }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
const name = workspace.name || "this workspace";
|
||||
|
||||
const remove = async () => {
|
||||
if (!window.confirm(`Delete ${name} and every conversation in it? Its bots and computers stay.`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await postAdminApi({ path: "workspaces/delete-workspace", body: { workspace_id: workspace.id } });
|
||||
|
||||
if (res.success) {
|
||||
window.location.href = AdminHomePath;
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
setError(res.msg || "Couldn't delete the workspace.");
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Delete"
|
||||
description="Deletes the workspace and its conversations. Its bots and computers stay yours."
|
||||
>
|
||||
<Button
|
||||
title={`Delete ${name}`}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={remove}
|
||||
className="self-start"
|
||||
>
|
||||
Delete workspace
|
||||
</Button>
|
||||
<SettingsStatus status={error ? { error: true, msg: error } : undefined} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import Form from "@moduletrace/twui/form/Form";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Textarea from "@moduletrace/twui/form/Textarea";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import postAdminApi from "@/src/layouts/bots/(functions)/post-admin-api";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
onSaved: (workspace: BUN_SQLITE_COBBLE_WORKSPACES) => void;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The workspace's name, what it's for, and the context every bot working
|
||||
* in it gets, saved together.
|
||||
*/
|
||||
export default function WorkspaceDetailsGroup({ workspace, onSaved }: Props) {
|
||||
const [name, setName] = useState(workspace.name || "");
|
||||
const [description, setDescription] = useState(workspace.description || "");
|
||||
const [system_prompt, setSystemPrompt] = useState(workspace.system_prompt || "");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const dirty =
|
||||
name != (workspace.name || "") ||
|
||||
description != (workspace.description || "") ||
|
||||
system_prompt != (workspace.system_prompt || "");
|
||||
|
||||
const submit = async () => {
|
||||
if (!dirty || loading) return;
|
||||
if (!window.confirm(`Save ${name || "this workspace"}?`)) return;
|
||||
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await postAdminApi<BUN_SQLITE_COBBLE_WORKSPACES>({
|
||||
path: "workspaces/update-workspace",
|
||||
body: { workspace_id: workspace.id, name, description, system_prompt },
|
||||
});
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res.msg || "Couldn't save the workspace." });
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
onSaved(res.singleRes);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
title="Workspace"
|
||||
description="Every bot working here gets this context on top of its own instructions."
|
||||
>
|
||||
<Form className="w-full gap-4! items-stretch" onSubmit={submit}>
|
||||
<Input
|
||||
id="workspace-name"
|
||||
title="Name"
|
||||
label="Name"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
id="workspace-description"
|
||||
title="What it's for"
|
||||
label="What it's for"
|
||||
showLabel
|
||||
autoComplete="off"
|
||||
defaultValue={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="workspace-context"
|
||||
title="Context"
|
||||
label="Context"
|
||||
showLabel
|
||||
rows={8}
|
||||
defaultValue={system_prompt}
|
||||
info="Markdown. Goals, people, links, rules: whatever every bot here should know."
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<Row className="w-full gap-3! flex-nowrap items-center justify-between">
|
||||
<SettingsStatus status={status} />
|
||||
<Button
|
||||
title="Save the workspace"
|
||||
type="submit"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={!dirty || loading}
|
||||
className="ml-auto"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</Row>
|
||||
</Form>
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import ImagePickField from "@/src/components/general/image-pick-field";
|
||||
import WorkspaceAvatar from "@/src/components/general/workspace-avatar";
|
||||
import setProfileImage from "@/src/functions/frontend/set-profile-image";
|
||||
import SettingsGroup from "@/src/pages/admin/settings/(partials)/settings-group";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
|
||||
type Props = {
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
|
||||
};
|
||||
|
||||
type Status = { error?: boolean; success?: boolean; msg?: string };
|
||||
|
||||
/**
|
||||
* The workspace's picture, shown in the rail. Saved as soon as it's
|
||||
* picked or removed.
|
||||
*/
|
||||
export default function WorkspaceImageGroup({ workspace }: Props) {
|
||||
const [image_media_id, setImageMediaId] = useState(workspace.image_media_id || null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [status, setStatus] = useState<Status>();
|
||||
|
||||
const save = async ({ data_url }: { data_url: string | null }) => {
|
||||
setLoading(true);
|
||||
setStatus(undefined);
|
||||
|
||||
const res = await setProfileImage({
|
||||
target: "workspace",
|
||||
id: Number(workspace.id),
|
||||
data_url,
|
||||
}).catch(() => null);
|
||||
|
||||
setLoading(false);
|
||||
|
||||
if (!res?.success || !res.singleRes) {
|
||||
setStatus({ error: true, msg: res?.msg || "Couldn't save the picture." });
|
||||
return;
|
||||
}
|
||||
|
||||
setImageMediaId(res.singleRes.image_media_id);
|
||||
setStatus({ success: true, msg: res.msg || "Saved." });
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingsGroup title="Picture" description="Shown beside the workspace's name in the rail.">
|
||||
<ImagePickField
|
||||
preview={<WorkspaceAvatar size="lg" image_media_id={image_media_id} />}
|
||||
has_image={Boolean(image_media_id)}
|
||||
onPick={({ data_url }) => save({ data_url })}
|
||||
onRemove={() => save({ data_url: null })}
|
||||
loading={loading}
|
||||
/>
|
||||
<SettingsStatus status={status} />
|
||||
</SettingsGroup>
|
||||
);
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { useState } from "react";
|
||||
import type { HarnessOverview } from "@/src/types";
|
||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||
import saveWorkspaceHarnessSettings from "../(functions)/save-workspace-harness-settings";
|
||||
|
||||
type Props = {
|
||||
workspace_id: number;
|
||||
harnesses: HarnessOverview[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Which model each harness uses in the workspace. These win over a bot's
|
||||
* own models; "Same as the default" leaves each bot on its own, else the
|
||||
* defaults.
|
||||
*/
|
||||
export default function WorkspaceModels({ workspace_id, harnesses: initial }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
const [version, setVersion] = useState(0);
|
||||
const [notice, setNotice] = useState<string>();
|
||||
|
||||
return (
|
||||
<HarnessSettingsForm
|
||||
key={version}
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
const res = await saveWorkspaceHarnessSettings({
|
||||
workspace_id,
|
||||
settings: settings.map((setting) => ({ ...setting, inherit: Boolean(setting.inherit) })),
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) {
|
||||
setHarnesses(res.singleRes.harnesses);
|
||||
setNotice(res.msg || "Saved.");
|
||||
setVersion((prev) => prev + 1);
|
||||
}
|
||||
|
||||
return res;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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,66 +0,0 @@
|
||||
import { useContext, useState } from "react";
|
||||
import EmptyContent from "@moduletrace/twui/elements/EmptyContent";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import SettingsShell from "@/src/pages/admin/settings/(partials)/settings-shell";
|
||||
import WorkspaceBotsGroup from "../(partials)/workspace-bots-group";
|
||||
import WorkspaceComputersGroup from "../(partials)/workspace-computers-group";
|
||||
import WorkspaceDeleteGroup from "../(partials)/workspace-delete-group";
|
||||
import WorkspaceDetailsGroup from "../(partials)/workspace-details-group";
|
||||
import WorkspaceImageGroup from "../(partials)/workspace-image-group";
|
||||
import WorkspaceModels from "../(partials)/workspace-models";
|
||||
import WorkspaceSectionHeading from "../(partials)/workspace-section-heading";
|
||||
|
||||
/**
|
||||
* A workspace's page beside its rail: its context, bots, computers, then
|
||||
* its models, and deleting it. A member only lands here while it has no
|
||||
* bots yet.
|
||||
*/
|
||||
export default function WorkspaceOverview() {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [workspace, setWorkspace] = useState(pageProps?.workspace);
|
||||
|
||||
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 harnesses = pageProps?.workspace_harness_overview?.harnesses;
|
||||
|
||||
return (
|
||||
<SettingsShell
|
||||
title={workspace.name || "Workspace"}
|
||||
description={workspace.description || "Context, bots, computers and models for this workspace."}
|
||||
>
|
||||
<WorkspaceImageGroup workspace={workspace} />
|
||||
<WorkspaceDetailsGroup workspace={workspace} onSaved={setWorkspace} />
|
||||
<WorkspaceBotsGroup
|
||||
workspace_id={workspace_id}
|
||||
workspace_name={workspace.name || "this workspace"}
|
||||
bots={pageProps?.workspace_bots || []}
|
||||
all_bots={pageProps?.all_bots || []}
|
||||
/>
|
||||
<WorkspaceComputersGroup
|
||||
workspace_id={workspace_id}
|
||||
computers={pageProps?.workspace_computers || []}
|
||||
all_computers={pageProps?.computers || []}
|
||||
/>
|
||||
<WorkspaceDeleteGroup workspace={workspace} />
|
||||
{harnesses?.length ? (
|
||||
<>
|
||||
<WorkspaceSectionHeading
|
||||
title="Models"
|
||||
description="Every bot working here uses these, the coordinator too. Left on “Same as the default”, each bot uses its own models, else the defaults."
|
||||
/>
|
||||
<WorkspaceModels workspace_id={workspace_id} harnesses={harnesses} />
|
||||
</>
|
||||
) : null}
|
||||
</SettingsShell>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,18 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabConversationProps from "@/src/functions/backend/db/bot-sessions/grab-conversation-props";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A bot's conversation inside a workspace: the bot works with the
|
||||
* workspace's context, computers and models. Only the coordinator and the
|
||||
* workspace's bots have one; anyone else goes back to the workspace.
|
||||
* workspace's bots have one; anyone else goes back to the workspace
|
||||
* settings (managers) or their bots.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
const { user } = await userAuth({ req: params.req });
|
||||
const { user, user_types } = await userAuth({ req: params.req });
|
||||
|
||||
if (!user?.id) {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
@@ -23,7 +25,13 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
});
|
||||
|
||||
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 };
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import userAuth from "@/src/functions/backend/auth/user-auth";
|
||||
import grabBots from "@/src/functions/backend/db/bots/grab-bots";
|
||||
import grabUserComputers from "@/src/functions/backend/db/computers/grab-user-computers";
|
||||
import grabBotHarnessOverview from "@/src/functions/backend/db/harness/grab-bot-harness-overview";
|
||||
import grabUserWorkspaces from "@/src/functions/backend/db/workspaces/grab-user-workspaces";
|
||||
import grabUserWorkspace from "@/src/functions/backend/db/workspaces/grab-user-workspace";
|
||||
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 canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { PagePropsType, PageQueryObject } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
import type { BunextPageServerFn } from "@moduletrace/bunext/types";
|
||||
|
||||
/**
|
||||
* A workspace's page: its details, crew, computers and models, with the
|
||||
* rail showing its crew. Only super admins and admins manage it; a member
|
||||
* opens its first bot instead.
|
||||
* The workspace's old landing page. Management moved to
|
||||
* `/admin/settings/workspaces/<id>`: managers go there, members go to the
|
||||
* workspace's first bot. No page renders.
|
||||
*/
|
||||
export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
const query: PageQueryObject = params.query;
|
||||
@@ -23,47 +19,37 @@ export const server: BunextPageServerFn<PagePropsType> = async (params) => {
|
||||
return { redirect: { destination: "/auth/login" } };
|
||||
}
|
||||
|
||||
const workspaces = await grabUserWorkspaces({ user_id: user.id });
|
||||
const workspace = workspaces.find((w) => String(w.id) == String(query.workspace_id));
|
||||
const workspace = await grabUserWorkspace({
|
||||
workspace_id: String(query.workspace_id),
|
||||
user_id: user.id,
|
||||
});
|
||||
|
||||
if (!workspace?.id) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
return { redirect: { destination: AdminHomePath, permanent: false } };
|
||||
}
|
||||
|
||||
const workspace_id = Number(workspace.id);
|
||||
|
||||
/**
|
||||
* Members don't manage it: they go straight to its first bot.
|
||||
*/
|
||||
if (!canManageCrew({ user_types })) {
|
||||
const workspace_bots = await grabWorkspaceBots({ user_id: user.id, workspace_id });
|
||||
|
||||
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 } };
|
||||
if (canManageCrew({ user_types })) {
|
||||
return {
|
||||
redirect: {
|
||||
destination: `/admin/settings/workspaces/${workspace_id}`,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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),
|
||||
]);
|
||||
const workspace_bots = await grabWorkspaceBots({
|
||||
user_id: user.id,
|
||||
workspace_id,
|
||||
});
|
||||
|
||||
return {
|
||||
props: {
|
||||
workspaces,
|
||||
workspace,
|
||||
bots: workspace_bots,
|
||||
workspace_bots,
|
||||
all_bots,
|
||||
workspace_computers,
|
||||
computers,
|
||||
workspace_harness_overview,
|
||||
redirect: {
|
||||
destination: workspace_bots[0]?.id
|
||||
? `/admin/workspaces/${workspace_id}/bots/${workspace_bots[0].id}`
|
||||
: AdminHomePath,
|
||||
permanent: false,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import type { BunextPageModuleMetaFn } from "@moduletrace/bunext/types";
|
||||
import type { BunextPageModuleMeta } from "@moduletrace/bunext/types";
|
||||
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() {
|
||||
return <WorkspaceOverview />;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const meta: BunextPageModuleMetaFn = async (params) => {
|
||||
const workspace = (params.serverRes?.props as PagePropsType | undefined)?.workspace;
|
||||
|
||||
return {
|
||||
title: `${workspace?.name || "Workspace"} | ${SiteData["SiteName"]}`,
|
||||
description: workspace?.description || `A workspace.`,
|
||||
};
|
||||
export const meta: BunextPageModuleMeta = {
|
||||
title: `Workspaces | ${SiteData["SiteName"]}`,
|
||||
description: `Your workspaces.`,
|
||||
};
|
||||
|
||||
@@ -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 { AdminHomePath } from "@/src/dict/admin-sections-dict";
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import canManageCrew from "@/src/utils/rbac/can-manage-crew";
|
||||
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 }) => {
|
||||
const { user } = await userAuth({ req });
|
||||
const { user, user_types } = await userAuth({ req });
|
||||
|
||||
if (!user?.id) {
|
||||
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];
|
||||
|
||||
if (!first?.id) {
|
||||
return { redirect: { destination: AdminHomePath } };
|
||||
}
|
||||
|
||||
return {
|
||||
redirect: {
|
||||
destination: first?.id ? `/admin/workspaces/${first.id}` : AdminHomePath,
|
||||
destination: canManageCrew({ user_types })
|
||||
? `/admin/settings/workspaces/${first.id}`
|
||||
: `/admin/workspaces/${first.id}`,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user