Add workspaces, conversation sessions, app-managed computers and pictures
- Rail: Add icon with stacked form modals (bot, workspace, computer), coordinator, recent bots with "See all bots", Workspaces section, and a workspace mode listing only its crew - Workspaces: page for context, bots (per-workspace instructions and memory), computers (default, link, unlink), models and delete; conversations scoped per bot per workspace - Sessions: conversations split into searchable, reopenable sessions with carry-over summaries; sessions and new-session header icons - Computers: created and provisioned over the docker socket, home dir kept at DATA_DIR/computers/<id> after delete; super admins only - Models: workspace settings win over a bot's own, then the defaults - Pictures for bots and workspaces; bots editable from the header - Coordinator tools: update_workspace, set_bot_workspace_instructions Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
import type { ComponentProps } from "react";
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import profileImageUrls from "@/src/utils/profile-image-urls";
|
||||
import BotStone from "./bot-stone";
|
||||
|
||||
/**
|
||||
* Picture sizes, matching the stone sizes in use.
|
||||
*/
|
||||
const BotAvatarSizeClasses: Record<number, string> = {
|
||||
18: "size-[18px] rounded-[4px]",
|
||||
28: "size-7 rounded-default-sm",
|
||||
32: "size-8 rounded-default-sm",
|
||||
40: "size-10 rounded-default",
|
||||
56: "size-14 rounded-default",
|
||||
};
|
||||
|
||||
type Props = ComponentProps<typeof BotStone> & {
|
||||
/**
|
||||
* The bot's custom picture, shown instead of the stone.
|
||||
*/
|
||||
image_media_id?: number | string | null;
|
||||
/**
|
||||
* A picture to show instead, e.g. one just picked and not saved yet.
|
||||
*/
|
||||
src?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A bot's avatar: its custom picture when it has one (a rounded square,
|
||||
* never a circle, so it isn't mistaken for a person), else its stone. Its
|
||||
* hue still marks it everywhere else, e.g. the unread dot.
|
||||
*/
|
||||
export default function BotAvatar({ image_media_id, src: picked, ...stone }: Props) {
|
||||
const saved = profileImageUrls({ media_id: image_media_id });
|
||||
const thumbnail_src = picked || saved.thumbnail_src;
|
||||
const src = picked || saved.src;
|
||||
const size = stone.size || 32;
|
||||
|
||||
if (!thumbnail_src) return <BotStone {...stone} />;
|
||||
|
||||
return (
|
||||
<Img
|
||||
src={thumbnail_src}
|
||||
imgErrSrc={src}
|
||||
alt={stone.label || ""}
|
||||
className={twMerge(
|
||||
"shrink-0 object-cover bg-sunken",
|
||||
BotAvatarSizeClasses[size] || BotAvatarSizeClasses[32],
|
||||
stone.className,
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useRef, type ReactNode } from "react";
|
||||
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 imageInputToBase64 from "@moduletrace/twui/utils/form/imageInputToBase64";
|
||||
|
||||
type Props = {
|
||||
/**
|
||||
* The avatar as it looks now (or will, with the picked picture).
|
||||
*/
|
||||
preview: ReactNode;
|
||||
has_image: boolean;
|
||||
onPick: (params: { data_url: string }) => void;
|
||||
onRemove: () => void;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* An avatar preview with Upload/Change and Remove. The picked picture comes
|
||||
* back as a data URL; saving is up to the caller.
|
||||
*/
|
||||
export default function ImagePickField({
|
||||
preview,
|
||||
has_image,
|
||||
onPick,
|
||||
onRemove,
|
||||
loading,
|
||||
disabled,
|
||||
note = "PNG, JPG or WebP. A square picture looks best.",
|
||||
}: Props) {
|
||||
const input_ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
return (
|
||||
<Row className="w-full gap-4! flex-nowrap items-center">
|
||||
{preview}
|
||||
<Stack className="gap-2! min-w-0">
|
||||
<Row className="gap-2!">
|
||||
<Button
|
||||
title={has_image ? "Change picture" : "Upload picture"}
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="gray"
|
||||
size="small"
|
||||
loading={loading}
|
||||
disabled={disabled || loading}
|
||||
onClick={() => input_ref.current?.click()}
|
||||
>
|
||||
{has_image ? "Change picture" : "Upload picture"}
|
||||
</Button>
|
||||
{has_image ? (
|
||||
<Button
|
||||
title="Remove the picture"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="gray"
|
||||
size="small"
|
||||
disabled={disabled || loading}
|
||||
onClick={() => {
|
||||
onRemove();
|
||||
if (input_ref.current) input_ref.current.value = "";
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
) : null}
|
||||
</Row>
|
||||
<Span className="text-[13px] leading-[18px] text-ink-secondary">{note}</Span>
|
||||
</Stack>
|
||||
{/*
|
||||
* Raw on purpose: twui `Input` writes `value` on change, which
|
||||
* browsers forbid on file inputs, and `ImageUpload` is a full
|
||||
* card widget. This is only the hidden picker behind the button.
|
||||
*/}
|
||||
<input
|
||||
ref={input_ref}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp,image/gif"
|
||||
className="hidden"
|
||||
tabIndex={-1}
|
||||
aria-hidden
|
||||
onChange={(e) => {
|
||||
if (!e.target.files?.[0]) return;
|
||||
|
||||
imageInputToBase64({ imageInput: e.target }).then((res) => {
|
||||
if (res?.imageBase64Full) onPick({ data_url: res.imageBase64Full });
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Row>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import Img from "@moduletrace/twui/layout/Img";
|
||||
import Span from "@moduletrace/twui/layout/Span";
|
||||
import { Layers } from "lucide-react";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
import profileImageUrls from "@/src/utils/profile-image-urls";
|
||||
|
||||
const WorkspaceAvatarSizes = {
|
||||
sm: { box: "size-8 rounded-default-sm", icon: 16 },
|
||||
lg: { box: "size-14 rounded-default", icon: 24 },
|
||||
} as const;
|
||||
|
||||
type Props = {
|
||||
image_media_id?: number | string | null;
|
||||
/**
|
||||
* A picture to show instead, e.g. one just picked and not saved yet.
|
||||
*/
|
||||
src?: string;
|
||||
size?: keyof typeof WorkspaceAvatarSizes;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A workspace's picture, or the workspace icon on a tile when it has none.
|
||||
*/
|
||||
export default function WorkspaceAvatar({ image_media_id, src: picked, size = "sm", className }: Props) {
|
||||
const saved = profileImageUrls({ media_id: image_media_id });
|
||||
const thumbnail_src = picked || saved.thumbnail_src;
|
||||
const src = picked || saved.src;
|
||||
const { box, icon } = WorkspaceAvatarSizes[size];
|
||||
|
||||
if (thumbnail_src) {
|
||||
return (
|
||||
<Img
|
||||
src={thumbnail_src}
|
||||
imgErrSrc={src}
|
||||
alt=""
|
||||
className={twMerge("shrink-0 object-cover bg-sunken", box, className)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Span
|
||||
aria-hidden
|
||||
className={twMerge(
|
||||
"shrink-0 flex items-center justify-center border border-solid border-hairline bg-surface text-ink",
|
||||
box,
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<Layers size={icon} aria-hidden />
|
||||
</Span>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user