Add workspaces, conversation sessions, app-managed computers and pictures
- Rail: Add icon with stacked form modals (bot, workspace, computer), coordinator, recent bots with "See all bots", Workspaces section, and a workspace mode listing only its crew - Workspaces: page for context, bots (per-workspace instructions and memory), computers (default, link, unlink), models and delete; conversations scoped per bot per workspace - Sessions: conversations split into searchable, reopenable sessions with carry-over summaries; sessions and new-session header icons - Computers: created and provisioned over the docker socket, home dir kept at DATA_DIR/computers/<id> after delete; super admins only - Models: workspace settings win over a bot's own, then the defaults - Pictures for bots and workspaces; bots editable from the header - Coordinator tools: update_workspace, set_bot_workspace_instructions Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -11,6 +11,11 @@ import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* In a workspace the bot uses the workspace's computers, and a switch
|
||||
* changes the workspace's default for every bot there.
|
||||
*/
|
||||
workspace_name?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -18,7 +23,7 @@ type Props = {
|
||||
* computers are, the one it's on checked, and with more than one,
|
||||
* switches it. Each can be opened in the viewer.
|
||||
*/
|
||||
export default function BotComputersModal({ bot }: Props) {
|
||||
export default function BotComputersModal({ bot, workspace_name }: Props) {
|
||||
const { computers, switching_id, error, switchComputer, openComputer } =
|
||||
useContext(BotComputersContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -52,9 +57,13 @@ export default function BotComputersModal({ bot }: Props) {
|
||||
{`A computer is a sandbox ${name} works in: a shell, a browser and files of its own. Its tasks run on the checked one. Open one to use it yourself.`}
|
||||
</Span>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{can_switch
|
||||
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
|
||||
: `This is the only computer ${name} can use right now.`}
|
||||
{workspace_name
|
||||
? can_switch
|
||||
? `These are ${workspace_name}'s computers. Picking another makes it the default for every bot working here.`
|
||||
: `This is ${workspace_name}'s only computer. Add more on the workspace's page.`
|
||||
: can_switch
|
||||
? `Pick another to move ${name} there. You can also click a computer it names in a message.`
|
||||
: `This is the only computer ${name} can use right now.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Stack
|
||||
|
||||
@@ -3,9 +3,15 @@ import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import HarnessSettingsForm from "@/src/pages/admin/settings/models/(partials)/harness-settings-form";
|
||||
import postHarnessApi from "@/src/pages/admin/settings/models/(functions)/post-harness-api";
|
||||
import saveBotHarnessSettings from "../models/(functions)/save-bot-harness-settings";
|
||||
import saveWorkspaceHarnessSettings from "@/src/pages/admin/workspaces/[workspace_id]/(functions)/save-workspace-harness-settings";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* In a workspace the form saves the workspace's models, for every bot
|
||||
* working there.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* A worker's `grabBotHarnessOverview`; for the coordinator, the
|
||||
* defaults (`grabHarnessOverview`).
|
||||
@@ -23,7 +29,7 @@ type Props = {
|
||||
* runs on the defaults, so it saves the defaults themselves, which only a
|
||||
* super admin can.
|
||||
*/
|
||||
export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Props) {
|
||||
export default function BotHarnessForm({ bot, workspace_id, harnesses: initial, onSaved }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
/**
|
||||
* Remounts the form when the data changes under it.
|
||||
@@ -44,6 +50,20 @@ export default function BotHarnessForm({ bot, harnesses: initial, onSaved }: Pro
|
||||
harnesses={harnesses}
|
||||
notice={notice}
|
||||
save={async ({ settings }) => {
|
||||
if (workspace_id) {
|
||||
const res = await saveWorkspaceHarnessSettings({
|
||||
workspace_id,
|
||||
settings: settings.map((setting) => ({
|
||||
...setting,
|
||||
inherit: Boolean(setting.inherit),
|
||||
})),
|
||||
});
|
||||
|
||||
if (res.success && res.singleRes) saved(res.singleRes.harnesses, res.msg);
|
||||
|
||||
return res;
|
||||
}
|
||||
|
||||
if (bot.kind == "coordinator") {
|
||||
const res = await postHarnessApi({
|
||||
path: "save-settings",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import type { MessageGroup } from "../(utils)/group-messages";
|
||||
@@ -18,7 +18,8 @@ type Props = {
|
||||
export default function BotMessageGroup({ bot, group }: Props) {
|
||||
return (
|
||||
<Row className="w-full gap-3! flex-nowrap items-start">
|
||||
<BotStone
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={28}
|
||||
|
||||
@@ -5,6 +5,7 @@ import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { CpuAi } from "lucide-react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import isPassThroughUser from "@/src/utils/rbac/is-pass-through-user";
|
||||
import BotHarnessForm from "./bot-harness-form";
|
||||
@@ -12,6 +13,10 @@ import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in: the modal edits its models.
|
||||
*/
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
harnesses?: HarnessOverview[] | null;
|
||||
onSaved: (harnesses: HarnessOverview[]) => void;
|
||||
};
|
||||
@@ -22,13 +27,14 @@ type Props = {
|
||||
* the coordinator that form is the defaults, for super admins only;
|
||||
* everyone else gets the icon disabled, its title saying why.
|
||||
*/
|
||||
export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
export default function BotModelsModal({ bot, workspace, harnesses, onSaved }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
if (!harnesses?.length || !bot.id) return null;
|
||||
|
||||
const is_coordinator = bot.kind == "coordinator";
|
||||
const in_workspace = Boolean(workspace?.id);
|
||||
const is_coordinator = bot.kind == "coordinator" && !in_workspace;
|
||||
const can_edit =
|
||||
!is_coordinator ||
|
||||
isPassThroughUser({ user_types: pageProps?.user_types });
|
||||
@@ -39,7 +45,9 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
icon={CpuAi}
|
||||
title={
|
||||
can_edit
|
||||
? is_coordinator
|
||||
? in_workspace
|
||||
? `Change ${workspace?.name || "the workspace"}'s models`
|
||||
: is_coordinator
|
||||
? "Change the default models"
|
||||
: `Change ${name}'s models`
|
||||
: `${name} uses the default models. A super admin can change them in Settings, under Models.`
|
||||
@@ -60,15 +68,26 @@ export default function BotModelsModal({ bot, harnesses, onSaved }: Props) {
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch pb-2">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">
|
||||
{is_coordinator ? "Default models" : `${name}'s models`}
|
||||
{in_workspace
|
||||
? `Models in ${workspace?.name || "this workspace"}`
|
||||
: is_coordinator
|
||||
? "Default models"
|
||||
: `${name}'s models`}
|
||||
</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{is_coordinator
|
||||
{in_workspace
|
||||
? `Every bot working in ${workspace?.name || "this workspace"} uses these, the coordinator too. Left on "Same as the default", ${name} uses its own models, else the defaults.`
|
||||
: is_coordinator
|
||||
? `${name} and every bot left on the default use these. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it. Providers are in Settings, under Models.`
|
||||
: `${name} uses the default models unless you pick its own. The text model chats, plans and drives computers; the others read media, transcribe, speak and make images for it.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<BotHarnessForm bot={bot} harnesses={harnesses} onSaved={onSaved} />
|
||||
<BotHarnessForm
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
harnesses={harnesses}
|
||||
onSaved={onSaved}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { useState } from "react";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import BotForm from "@/src/layouts/bots/(partials)/bot-form";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's avatar: opens the bot's profile (picture, name, job,
|
||||
* colour, instructions) to edit.
|
||||
*/
|
||||
export default function BotProfileModal({ bot }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const name = bot.name || "This bot";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<Button
|
||||
title={`Edit ${name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
aria-label={`Edit ${name}`}
|
||||
className="shrink-0 p-0! rounded-default hover:opacity-80 focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-paper"
|
||||
>
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Stack className="w-full gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">{`Edit ${name}`}</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{bot.kind == "coordinator"
|
||||
? "The coordinator keeps its name. Give it a picture, or change its core instructions."
|
||||
: `${name}'s core instructions apply everywhere. What it does in one workspace is set on that workspace's page.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
{open ? <BotForm bot={bot} onCancel={() => setOpen(false)} /> : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
import messagePreview from "@/src/utils/message-preview";
|
||||
import relativeTime from "@/src/utils/relative-time";
|
||||
|
||||
type Props = {
|
||||
session: BotSessionListItem;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
onOpen: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* One session in the sessions modal: its title, the summary or last
|
||||
* message, how long and how recent. The active one says so; any other
|
||||
* reopens on click.
|
||||
*/
|
||||
export default function BotSessionRow({ session, loading, disabled, onOpen }: Props) {
|
||||
const active = session.status == "active";
|
||||
const preview = session.summary || session.last_message_text || "";
|
||||
const at = session.last_activity_at || session.created_at;
|
||||
|
||||
return (
|
||||
<Button
|
||||
title={active ? "This is the current session" : `Reopen "${session.title || "Untitled"}"`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="text"
|
||||
loading={loading}
|
||||
disabled={active || disabled}
|
||||
onClick={onOpen}
|
||||
className={twMerge(
|
||||
"w-full justify-start! items-start! gap-0! px-3! py-2.5! rounded-default text-left text-ink dark:text-ink hover:bg-sunken hover:dark:bg-sunken focus-visible:ring-2 focus-visible:ring-ink",
|
||||
active && "bg-ink/5 opacity-100!",
|
||||
)}
|
||||
>
|
||||
<Stack className="w-full min-w-0 gap-0.5! items-stretch">
|
||||
<Row className="w-full gap-2! flex-nowrap items-center">
|
||||
<Span className="grow min-w-0 truncate text-sm font-bold leading-5 text-ink">
|
||||
{session.title || "Untitled session"}
|
||||
</Span>
|
||||
{session.unread && !active ? (
|
||||
<Span className="shrink-0 text-[11px] font-bold leading-4 text-ink">New</Span>
|
||||
) : null}
|
||||
<Span className="shrink-0 text-[11px] leading-4 text-ink-secondary">
|
||||
{active ? "Current" : at ? relativeTime({ at: Number(at) }) : ""}
|
||||
</Span>
|
||||
</Row>
|
||||
<Span className="w-full truncate text-xs leading-4 text-ink-secondary">
|
||||
{`${session.message_count} message${session.message_count == 1 ? "" : "s"}`}
|
||||
{preview ? ` · ${messagePreview({ text: preview })}` : ""}
|
||||
</Span>
|
||||
</Stack>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useState } from "react";
|
||||
import Input from "@moduletrace/twui/form/Input";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import { History, Plus } from "lucide-react";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
import SettingsStatus from "@/src/pages/admin/settings/(partials)/settings-status";
|
||||
import useBotSessions from "../(hooks)/use-bot-sessions";
|
||||
import BotSessionRow from "./bot-session-row";
|
||||
import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
workspace_id?: number | null;
|
||||
/**
|
||||
* A bot message arrived in another session of this conversation.
|
||||
*/
|
||||
has_other_unread?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The header's sessions icon. A conversation is split into sessions so a
|
||||
* bot's context never grows without bound; this lists them, searches
|
||||
* them, starts a fresh one, or reopens an old one to carry on.
|
||||
*/
|
||||
export default function BotSessionsModal({ bot, workspace_id, has_other_unread }: Props) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const sessions = useBotSessions({ bot_id: Number(bot.id), workspace_id, open });
|
||||
const name = bot.name || "the bot";
|
||||
|
||||
return (
|
||||
<Modal
|
||||
target={
|
||||
<HeaderIconButton
|
||||
icon={History}
|
||||
title={has_other_unread ? "Sessions: new messages in another session" : "Sessions"}
|
||||
/>
|
||||
}
|
||||
targetWrapperProps={{ className: "shrink-0" }}
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
className="max-w-lg bg-paper p-6"
|
||||
>
|
||||
<Row className="w-full gap-3! flex-nowrap items-start justify-between">
|
||||
<Stack className="min-w-0 gap-1! items-stretch">
|
||||
<H2 className="text-lg! leading-6! tracking-normal! font-bold text-ink">Sessions</H2>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">
|
||||
{`Each session keeps ${name}'s context short. A new one starts with a summary of the last; reopen any to pick up where you left off.`}
|
||||
</Span>
|
||||
</Stack>
|
||||
<Button
|
||||
title="Start a new session"
|
||||
type="button"
|
||||
size="small"
|
||||
loading={sessions.busy_id == "new"}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onClick={sessions.startSession}
|
||||
className="shrink-0 gap-1!"
|
||||
>
|
||||
<Plus size={14} aria-hidden />
|
||||
New
|
||||
</Button>
|
||||
</Row>
|
||||
<Input
|
||||
id="bot-sessions-search"
|
||||
title="Search sessions"
|
||||
placeholder="Search titles, summaries and messages"
|
||||
autoComplete="off"
|
||||
defaultValue={sessions.query}
|
||||
onChange={(e) => sessions.setQuery(e.target.value)}
|
||||
wrapperWrapperProps={{ className: "mt-4" }}
|
||||
/>
|
||||
<SettingsStatus status={sessions.error ? { error: true, msg: sessions.error } : undefined} />
|
||||
<Stack className="w-full mt-2 max-h-[60vh] overflow-y-auto gap-0! items-stretch">
|
||||
{sessions.sessions.map((session) => (
|
||||
<BotSessionRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
loading={sessions.busy_id == session.id}
|
||||
disabled={Boolean(sessions.busy_id)}
|
||||
onOpen={() => sessions.reopenSession({ session_id: Number(session.id) })}
|
||||
/>
|
||||
))}
|
||||
{sessions.sessions.length || sessions.loading ? null : (
|
||||
<Span className="px-3 py-6 text-[13px] leading-[18px] text-ink-secondary">
|
||||
{sessions.query.trim() ? `No session matches "${sessions.query.trim()}".` : "No sessions yet."}
|
||||
</Span>
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import BotAvatar from "@/src/components/general/bot-avatar";
|
||||
import type { BotHue } from "@/src/dict/bot-dict";
|
||||
import { BotReplyJobTitle, BotWaitingStatusLine } from "@/src/dict/bot-queue-dict";
|
||||
import type { BotWithLastMessage } from "@/src/types";
|
||||
@@ -36,7 +36,8 @@ export default function BotThinkingRow({ bot, progress }: Props) {
|
||||
|
||||
return (
|
||||
<Row role="status" className="w-full gap-3! flex-nowrap items-center">
|
||||
<BotStone
|
||||
<BotAvatar
|
||||
image_media_id={bot.image_media_id}
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={waiting ? "asleep" : "working"}
|
||||
size={28}
|
||||
|
||||
@@ -11,6 +11,10 @@ import ComposerSendButton from "./composer-send-button";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in, if any.
|
||||
*/
|
||||
workspace_id?: number | null;
|
||||
onSent: (params: { message: BotMessageWithAttachments }) => void;
|
||||
};
|
||||
|
||||
@@ -19,7 +23,7 @@ type Props = {
|
||||
* Its top rule lines up with the rail's user-row rule; picked files sit
|
||||
* just above it. Files can also be pasted or dropped in.
|
||||
*/
|
||||
export default function Composer({ bot, onSent }: Props) {
|
||||
export default function Composer({ bot, workspace_id, onSent }: Props) {
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -34,7 +38,12 @@ export default function Composer({ bot, onSent }: Props) {
|
||||
setSending(true);
|
||||
setError(undefined);
|
||||
|
||||
const res = await sendBotMessage({ bot_id: bot.id, text, media_ids: files.media_ids });
|
||||
const res = await sendBotMessage({
|
||||
bot_id: bot.id,
|
||||
workspace_id,
|
||||
text,
|
||||
media_ids: files.media_ids,
|
||||
});
|
||||
|
||||
setSending(false);
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useContext, useState } from "react";
|
||||
import { AppContext } from "@/src/pages/__root";
|
||||
import Modal from "@moduletrace/twui/elements/Modal";
|
||||
import Button from "@moduletrace/twui/layout/Button";
|
||||
import H2 from "@moduletrace/twui/layout/H2";
|
||||
@@ -21,6 +22,7 @@ type Props = {
|
||||
* bot in chat so it carries on.
|
||||
*/
|
||||
export default function ComputerViewerModal({ bot }: Props) {
|
||||
const { pageProps } = useContext(AppContext);
|
||||
const { computers, viewing, closeComputer } = useContext(BotComputersContext);
|
||||
const [handing_back, setHandingBack] = useState(false);
|
||||
const [error, setError] = useState<string>();
|
||||
@@ -38,6 +40,7 @@ export default function ComputerViewerModal({ bot }: Props) {
|
||||
|
||||
const res = await sendBotMessage({
|
||||
bot_id: bot.id || 0,
|
||||
workspace_id: pageProps?.workspace?.id || null,
|
||||
text: `I'm done on ${name}. You can carry on.`,
|
||||
media_ids: [],
|
||||
}).catch(() => null);
|
||||
|
||||
@@ -2,17 +2,25 @@ import { useContext, useState } from "react";
|
||||
import Row from "@moduletrace/twui/layout/Row";
|
||||
import Stack from "@moduletrace/twui/layout/Stack";
|
||||
import H1 from "@moduletrace/twui/layout/H1";
|
||||
import { CpuAi, Monitor } from "lucide-react";
|
||||
import BotStone from "@/src/components/general/bot-stone";
|
||||
import type { BotFace, BotHue } from "@/src/dict/bot-dict";
|
||||
import { CpuAi, Layers, Monitor } from "lucide-react";
|
||||
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import type { BotWithLastMessage, HarnessOverview } from "@/src/types";
|
||||
import { BotComputersContext } from "../(data)/bot-computers-context";
|
||||
import BotComputersModal from "./bot-computers-modal";
|
||||
import BotModelsModal from "./bot-models-modal";
|
||||
import BotProfileModal from "./bot-profile-modal";
|
||||
import BotSessionsModal from "./bot-sessions-modal";
|
||||
import NewSessionButton from "./new-session-button";
|
||||
import HeaderMetaItem from "./header-meta-item";
|
||||
|
||||
type Props = {
|
||||
bot: BotWithLastMessage;
|
||||
/**
|
||||
* The workspace the conversation is in: its name shows under the
|
||||
* bot's, and the models modal edits its models.
|
||||
*/
|
||||
workspace?: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
has_other_unread?: boolean;
|
||||
/**
|
||||
* The bot's models (the defaults for the coordinator).
|
||||
*/
|
||||
@@ -25,7 +33,7 @@ type Props = {
|
||||
* that open the computers and models modals. Kept here so a save or a
|
||||
* switch shows under the name at once.
|
||||
*/
|
||||
export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
export default function ConversationHeader({ bot, workspace, has_other_unread, harnesses: initial }: Props) {
|
||||
const [harnesses, setHarnesses] = useState(initial);
|
||||
const { computers } = useContext(BotComputersContext);
|
||||
|
||||
@@ -34,16 +42,15 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
|
||||
return (
|
||||
<Row className="h-16 shrink-0 gap-3! flex-nowrap items-center px-6 border-0 border-b border-solid border-hairline">
|
||||
<BotStone
|
||||
hue={(bot.hue || null) as BotHue | null}
|
||||
face={(bot.face || "idle") as BotFace}
|
||||
size={40}
|
||||
/>
|
||||
<BotProfileModal bot={bot} />
|
||||
<Stack className="min-w-0 gap-0!">
|
||||
<H1 className="text-lg! leading-6! tracking-normal! font-bold text-ink truncate w-full">
|
||||
{bot.name}
|
||||
</H1>
|
||||
<Row className="min-w-0 w-full gap-x-3! gap-y-0! flex-nowrap">
|
||||
{workspace?.id ? (
|
||||
<HeaderMetaItem icon={Layers} label="Workspace" text={workspace.name || "Workspace"} />
|
||||
) : null}
|
||||
<HeaderMetaItem
|
||||
icon={CpuAi}
|
||||
label="Model"
|
||||
@@ -63,10 +70,17 @@ export default function ConversationHeader({ bot, harnesses: initial }: Props) {
|
||||
</Row>
|
||||
</Stack>
|
||||
<Row className="ml-auto shrink-0 gap-1! flex-nowrap justify-end">
|
||||
<BotComputersModal bot={bot} />
|
||||
<BotSessionsModal
|
||||
bot={bot}
|
||||
workspace_id={workspace?.id || null}
|
||||
has_other_unread={has_other_unread}
|
||||
/>
|
||||
<NewSessionButton bot_id={Number(bot.id)} workspace_id={workspace?.id || null} />
|
||||
<BotComputersModal bot={bot} workspace_name={workspace?.id ? workspace.name || "This workspace" : null} />
|
||||
<BotModelsModal
|
||||
key={bot.id}
|
||||
bot={bot}
|
||||
workspace={workspace}
|
||||
harnesses={harnesses}
|
||||
onSaved={setHarnesses}
|
||||
/>
|
||||
|
||||
@@ -9,13 +9,17 @@ type Props = {
|
||||
title: string;
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
/**
|
||||
* For a button that acts on its own; a modal's target leaves it out.
|
||||
*/
|
||||
onClick?: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A ghost icon button on the right of the conversation header. Used as a
|
||||
* modal's target, so it takes no click handler of its own.
|
||||
* A ghost icon button on the right of the conversation header, usually a
|
||||
* modal's target.
|
||||
*/
|
||||
export default function HeaderIconButton({ icon: Icon, title, disabled, loading }: Props) {
|
||||
export default function HeaderIconButton({ icon: Icon, title, disabled, loading, onClick }: Props) {
|
||||
return (
|
||||
<Button
|
||||
title={title}
|
||||
@@ -25,6 +29,7 @@ export default function HeaderIconButton({ icon: Icon, title, disabled, loading
|
||||
color="text"
|
||||
disabled={disabled}
|
||||
loading={loading}
|
||||
onClick={onClick}
|
||||
className="shrink-0 p-2! text-ink-secondary dark:text-ink-secondary hover:text-ink hover:dark:text-ink hover:bg-sunken hover:dark:bg-sunken rounded-default transition-colors duration-[var(--m-fast)] focus-visible:ring-2 focus-visible:ring-ink"
|
||||
>
|
||||
<Icon size={18} aria-hidden />
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { useState } from "react";
|
||||
import { MessageSquarePlus } from "lucide-react";
|
||||
import postSessionApi from "../(functions)/post-session-api";
|
||||
import HeaderIconButton from "./header-icon-button";
|
||||
|
||||
type Props = {
|
||||
bot_id: number;
|
||||
workspace_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Beside the sessions icon: closes this session and starts a fresh one
|
||||
* that carries a summary of it, then shows it.
|
||||
*/
|
||||
export default function NewSessionButton({ bot_id, workspace_id }: Props) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
return (
|
||||
<HeaderIconButton
|
||||
icon={MessageSquarePlus}
|
||||
title="New session"
|
||||
loading={loading}
|
||||
disabled={loading}
|
||||
onClick={async () => {
|
||||
setLoading(true);
|
||||
|
||||
const res = await postSessionApi({
|
||||
path: "new-session",
|
||||
body: { bot_id, workspace_id },
|
||||
}).catch(() => null);
|
||||
|
||||
if (res?.success) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
window.alert(res?.msg || "Couldn't start a new session.");
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user