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:
2026-10-19 08:39:33 +01:00
co-authored by Claude Opus 5.5
parent 56daf0c462
commit 0ce61cdb49
223 changed files with 8166 additions and 585 deletions
@@ -10,8 +10,9 @@ type Params = {
};
/**
* Assigns one of the user's computers to one of their workspaces, so every
* bot in the workspace gets access to it. Assigning it twice is a no-op.
* Links one of the user's computers to one of their workspaces, so every
* bot working there can use it. The first one linked becomes the
* workspace's default. Linking it twice is a no-op.
*/
export default async function addComputerToWorkspace({
user_id,
@@ -29,4 +30,11 @@ export default async function addComputerToWorkspace({
sql: `INSERT OR IGNORE INTO workspace_computers (workspace_id, computer_id, created_at, updated_at) VALUES (?, ?, ?, ?)`,
values: [workspace.id, computer.id, Date.now(), Date.now()],
});
if (!workspace.computer_id) {
await BunSQLite.sql({
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), workspace.id],
});
}
}
@@ -0,0 +1,36 @@
import deleteMedia from "@/src/functions/backend/db/media/delete-media";
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabUserWorkspace from "./grab-user-workspace";
type Params = {
user_id: number | string;
workspace_id: number | string;
};
/**
* Deletes one of the user's workspaces with its conversations, its
* links and its models. Its bots and computers stay.
*/
export default async function deleteWorkspace({ user_id, workspace_id }: Params) {
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
throw new AppError(`Workspace not found.`);
}
const res = await BunSQLite.sql({
sql: `DELETE FROM workspaces WHERE id = ?`,
values: [workspace.id],
});
if (!res.success) {
throw new Error(`Couldn't delete the workspace: ${res.error}`);
}
if (workspace.image_media_id) {
await deleteMedia({ id: workspace.image_media_id, user_id: Number(user_id) }).catch(() => {});
}
return workspace;
}
@@ -0,0 +1,26 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { WorkspaceWithActivity } from "@/src/types";
type Params = {
user_id: number | string;
};
/**
* The user's workspaces for the rail, not archived, oldest first, each
* unread while any conversation in it is.
*/
export default async function grabUserWorkspaces({
user_id,
}: Params): Promise<WorkspaceWithActivity[]> {
const res = await BunSQLite.sql<WorkspaceWithActivity>({
sql: `SELECT w.*,
COALESCE((SELECT MAX(s.unread) FROM bot_sessions s WHERE s.workspace_id = w.id), 0) AS unread,
(SELECT MAX(s.last_activity_at) FROM bot_sessions s WHERE s.workspace_id = w.id) AS last_activity_at
FROM workspaces w
WHERE w.user_id = ? AND w.archived = 0
ORDER BY w.id ASC`,
values: [user_id],
});
return res.payload || [];
}
@@ -0,0 +1,60 @@
import type {
BUN_SQLITE_COBBLE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACE_BOTS,
BUN_SQLITE_COBBLE_WORKSPACES,
} from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
workspace_id: number | string;
bot_id: number | string;
user_id: number | string;
};
export type WorkspaceBotMembership = {
workspace: BUN_SQLITE_COBBLE_WORKSPACES;
bot: BUN_SQLITE_COBBLE_BOTS;
/**
* Its `workspace_bots` row. Null for the coordinator, which works in
* every workspace without one until it's given instructions there.
*/
membership: BUN_SQLITE_COBBLE_WORKSPACE_BOTS | null;
};
/**
* One of the user's bots in one of their (non-archived) workspaces: a
* member, or the coordinator, which is in all of them. Null otherwise.
*/
export default async function grabWorkspaceBot({
workspace_id,
bot_id,
user_id,
}: Params): Promise<WorkspaceBotMembership | null> {
const workspace = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT * FROM workspaces WHERE id = ? AND user_id = ? AND archived = 0 LIMIT 1`,
values: [workspace_id, user_id],
})
).payload?.[0];
const bot = (
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOTS>({
sql: `SELECT * FROM bots WHERE id = ? AND user_id = ? LIMIT 1`,
values: [bot_id, user_id],
})
).payload?.[0];
if (!workspace?.id || !bot?.id) return null;
const membership =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
sql: `SELECT * FROM workspace_bots WHERE workspace_id = ? AND bot_id = ? LIMIT 1`,
values: [workspace.id, bot.id],
})
).payload?.[0] || null;
if (!membership && bot.kind != "coordinator") return null;
return { workspace, bot, membership };
}
@@ -0,0 +1,37 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_WORKSPACE_BOTS } from "@/db/types/db";
import type { WorkspaceBot } from "@/src/types";
import grabBots from "../bots/grab-bots";
type Params = {
user_id: number | string;
workspace_id: number | string;
};
/**
* A workspace's crew (the coordinator first, then its bots by latest
* activity there), each with its instructions and memory for the
* workspace.
*/
export default async function grabWorkspaceBots({
user_id,
workspace_id,
}: Params): Promise<WorkspaceBot[]> {
const [bots, rows] = await Promise.all([
grabBots({ user_id, workspace_id }),
BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACE_BOTS>({
sql: `SELECT bot_id, system_prompt, memory FROM workspace_bots WHERE workspace_id = ?`,
values: [workspace_id],
}).then((res) => res.payload || []),
]);
return bots.map((bot) => {
const row = rows.find((r) => r.bot_id == bot.id);
return {
...bot,
workspace_system_prompt: row?.system_prompt || null,
workspace_memory: row?.memory || null,
};
});
}
@@ -0,0 +1,33 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { ComputerWithStatus } from "@/src/types";
import parseJobJson from "@/src/bots-queue/parse-job-json";
type Params = {
workspace_id: number | string;
};
/**
* The computers linked to a workspace, with their container's state, its
* default first.
*/
export default async function grabWorkspaceComputers({
workspace_id,
}: Params): Promise<ComputerWithStatus[]> {
const res = await BunSQLite.sql<ComputerWithStatus & { metadata?: string | null }>({
sql: `SELECT c.*, co.status AS status, co.metadata AS metadata FROM computers c
JOIN workspace_computers wc ON wc.computer_id = c.id AND wc.workspace_id = ?
JOIN workspaces w ON w.id = wc.workspace_id AND w.user_id = c.user_id
LEFT JOIN containers co ON co.id = c.container_id
ORDER BY c.id = w.computer_id DESC, wc.id ASC`,
values: [workspace_id],
});
return (res.payload || []).map(({ metadata, ...computer }) => {
const error = parseJobJson({ text: metadata })?.error;
return {
...computer,
error: computer.status == "failed" && typeof error == "string" ? error : null,
};
});
}
@@ -9,8 +9,9 @@ type Params = {
};
/**
* Takes a computer off one of the user's workspaces. Its bots keep any
* access they were given directly.
* Unlinks a computer from one of the user's workspaces; the computer
* itself stays. When it was the default, the oldest computer still linked
* takes over (none: the workspace has no computer).
*/
export default async function removeComputerFromWorkspace({
user_id,
@@ -27,4 +28,13 @@ export default async function removeComputerFromWorkspace({
sql: `DELETE FROM workspace_computers WHERE workspace_id = ? AND computer_id = ?`,
values: [workspace.id, computer_id],
});
if (String(workspace.computer_id) == String(computer_id)) {
await BunSQLite.sql({
sql: `UPDATE workspaces SET updated_at = ?, computer_id = (
SELECT computer_id FROM workspace_computers WHERE workspace_id = ? ORDER BY id ASC LIMIT 1
) WHERE id = ?`,
values: [Date.now(), workspace.id, workspace.id],
});
}
}
@@ -0,0 +1,44 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import grabWorkspaceComputers from "./grab-workspace-computers";
import grabUserWorkspace from "./grab-user-workspace";
type Params = {
user_id: number | string;
workspace_id: number | string;
computer_id: number | string;
};
/**
* Makes one of a workspace's computers its default, where its tasks run.
* Returns the workspace's computers, the new default first.
*/
export default async function setWorkspaceComputer({
user_id,
workspace_id,
computer_id,
}: Params) {
const workspace = await grabUserWorkspace({ workspace_id, user_id });
if (!workspace?.id) {
throw new AppError(`Workspace not found.`);
}
const linked = await grabWorkspaceComputers({ workspace_id: workspace.id });
const computer = linked.find((c) => String(c.id) == String(computer_id));
if (!computer?.id) {
throw new AppError(`That computer isn't in ${workspace.name || "this workspace"}.`);
}
await BunSQLite.sql({
sql: `UPDATE workspaces SET computer_id = ?, updated_at = ? WHERE id = ?`,
values: [computer.id, Date.now(), workspace.id],
});
return {
workspace: { ...workspace, computer_id: computer.id },
computer,
computers: [computer, ...linked.filter((c) => c.id != computer.id)],
};
}
@@ -0,0 +1,59 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabWorkspaceBot from "./grab-workspace-bot";
type Params = {
user_id: number | string;
workspace_id: number | string;
bot_id: number | string;
/**
* Only the fields given are changed: the bot's instructions and memory
* for this workspace (markdown, whole-text replace).
*/
system_prompt?: string;
memory?: string;
};
/**
* Rewrites what a bot has for one workspace alone: its instructions there
* and its memory there. The bot, the coordinator and the user all can. The
* coordinator gets its row on first write.
*/
export default async function updateWorkspaceBot({
user_id,
workspace_id,
bot_id,
system_prompt,
memory,
}: Params) {
assertPromptLength({ label: "Workspace instructions", text: system_prompt });
assertPromptLength({ label: "Workspace memory", text: memory });
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
if (!found) {
throw new AppError(`That bot isn't in this workspace.`);
}
const now = Date.now();
await BunSQLite.sql({
sql: `INSERT OR IGNORE INTO workspace_bots (created_at, updated_at, workspace_id, bot_id) VALUES (?, ?, ?, ?)`,
values: [now, now, found.workspace.id!, found.bot.id!],
});
if (system_prompt !== undefined) {
await BunSQLite.sql({
sql: `UPDATE workspace_bots SET system_prompt = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [system_prompt, now, found.workspace.id!, found.bot.id!],
});
}
if (memory !== undefined) {
await BunSQLite.sql({
sql: `UPDATE workspace_bots SET memory = ?, updated_at = ? WHERE workspace_id = ? AND bot_id = ?`,
values: [memory, now, found.workspace.id!, found.bot.id!],
});
}
}