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
@@ -0,0 +1,51 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BUN_SQLITE_COBBLE_BOTS, BunSQLiteTables } from "@/db/types/db";
import type { BotHue } from "@/src/dict/bot-dict";
import assertPromptLength from "@/src/utils/assert-prompt-length";
type Params = {
user_id: number | string;
name: string;
role?: string;
hue: BotHue;
/**
* Its core instructions (markdown).
*/
system_prompt: string;
};
/**
* Adds a worker bot to the user's crew and returns its id. It has no
* computer of its own until it's given one; in a workspace it uses the
* workspace's.
*/
export default async function createBot({ user_id, name, role, hue, system_prompt }: Params) {
assertPromptLength({ label: "Instructions", text: system_prompt });
const res = await BunSQLite.insert<
BUN_SQLITE_COBBLE_BOTS,
(typeof BunSQLiteTables)[number]
>({
table: "bots",
data: [
{
user_id: Number(user_id),
name,
kind: "worker",
hue,
role: role || undefined,
face: "idle",
status_line: "",
system_prompt,
},
],
});
const bot_id = res.singleRes?.id || res.postInsertReturn?.insertId;
if (!res.success || !bot_id) {
throw new Error(`Couldn't add the bot: ${res.msg}`);
}
return Number(bot_id);
}
@@ -55,8 +55,6 @@ export default async function ensureCoordinatorBot({ user_id }: Params) {
face: "idle",
status_line: "",
system_prompt: CoordinatorSystemPrompt,
unread: 0,
last_activity_at: Date.now(),
},
],
});
@@ -8,18 +8,25 @@ import grabBotJobUpdates from "../bots-queue/grab-bot-job-updates";
type Params = {
bot_id: number | string;
/**
* Only a task started from this session.
*/
session_id?: number | string | null;
};
/**
* The bot's most recent active task, its steps and what its job has done
* so far, for the working-on panel.
* The bot's most recent active task (in one session, when given), its
* steps and what its job has done so far, for the working-on panel.
*/
export default async function grabActiveBotTask({
bot_id,
session_id,
}: Params): Promise<BotTaskWithSteps | null> {
const task_res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_TASKS>({
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active' ORDER BY created_at DESC, id DESC LIMIT 1`,
values: [bot_id],
sql: `SELECT * FROM bot_tasks WHERE bot_id = ? AND status = 'active'
${session_id ? `AND session_id = ?` : ""}
ORDER BY created_at DESC, id DESC LIMIT 1`,
values: session_id ? [bot_id, session_id] : [bot_id],
});
const task = task_res.singleRes || task_res.payload?.[0];
@@ -5,30 +5,29 @@ import grabMessagesMedia from "./grab-messages-media";
import mediaToAttachment from "./media-to-attachment";
type Params = {
bot_id: number | string;
session_id: number | string;
limit?: number;
};
/**
* The latest messages of a bot's conversation, oldest first, with their
* attachments.
* The latest messages of a session, oldest first, with their attachments.
*/
export default async function grabBotMessages({
bot_id,
session_id,
limit = 100,
}: Params): Promise<BotMessageWithAttachments[]> {
let sql = ``;
sql += `SELECT * FROM (\n`;
sql += ` SELECT * FROM bot_messages\n`;
sql += ` WHERE bot_id = ?\n`;
sql += ` WHERE session_id = ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT ?\n`;
sql += `) ORDER BY created_at ASC, id ASC\n`;
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_MESSAGES>({
sql,
values: [bot_id, limit],
values: [session_id, limit],
});
const messages = res.payload || [];
@@ -1,39 +1,42 @@
import type { BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
import BunSQLite from "@moduletrace/bun-sqlite";
import composeBotPrompt from "@/src/utils/compose-bot-prompt";
import AppError from "@/src/utils/app-error";
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
import grabUserBot from "./grab-user-bot";
type Params = {
bot_id: number | string;
user_id: number | string;
/**
* The workspace the bot is working in. Its context and the bot's
* instructions and memory there join the prompt.
*/
workspace_id?: number | string | null;
};
/**
* The full prompt for one of the user's bots: its core instructions, the
* system prompt of every non-archived workspace it's in, and its memory.
* The full prompt for one of the user's bots (`composeBotPrompt`): its
* core instructions and memory, and in a workspace that workspace's
* context and what the bot has for it.
*/
export default async function grabBotPrompt({ bot_id, user_id }: Params) {
export default async function grabBotPrompt({ bot_id, user_id, workspace_id }: Params) {
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
const workspaces =
(
await BunSQLite.sql<BUN_SQLITE_COBBLE_WORKSPACES>({
sql: `SELECT w.name, w.system_prompt FROM workspaces w
JOIN workspace_bots wb ON wb.workspace_id = w.id
WHERE wb.bot_id = ? AND w.user_id = ? AND w.archived = 0
ORDER BY w.id ASC`,
values: [bot.id, user_id],
})
).payload || [];
const found = workspace_id
? await grabWorkspaceBot({ workspace_id, bot_id: bot.id, user_id })
: null;
if (workspace_id && !found) {
throw new AppError(`${bot.name || "This bot"} isn't in that workspace.`);
}
return composeBotPrompt({
system_prompt: bot.system_prompt,
memory: bot.memory,
workspaces,
workspace: found?.workspace,
workspace_bot: found?.membership,
});
}
+25 -4
View File
@@ -3,36 +3,57 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
user_id: number | string;
/**
* A workspace's crew instead: the coordinator and the bots in it, with
* their conversations in that workspace.
*/
workspace_id?: number | string | null;
};
/**
* A user's crew for the rail: the coordinator pinned first, then
* workers by latest activity. Each row carries its latest message.
* workers by latest activity. Each row carries its conversation's unread
* flag, latest activity and latest message: in its own chat, or in the
* workspace when one is given.
*/
export default async function grabBots({
user_id,
workspace_id,
}: Params): Promise<BotWithLastMessage[]> {
const scope = workspace_id ? Number(workspace_id) : null;
let sql = ``;
sql += `SELECT\n`;
sql += ` b.*,\n`;
sql += ` COALESCE(t.unread, 0) AS unread,\n`;
sql += ` t.last_activity_at AS last_activity_at,\n`;
sql += ` lm.text AS last_message_text,\n`;
sql += ` lm.created_at AS last_message_at\n`;
sql += `FROM bots b\n`;
sql += `LEFT JOIN (\n`;
sql += ` SELECT bot_id, MAX(unread) AS unread, MAX(last_activity_at) AS last_activity_at\n`;
sql += ` FROM bot_sessions WHERE workspace_id IS ? GROUP BY bot_id\n`;
sql += `) t ON t.bot_id = b.id\n`;
sql += `LEFT JOIN bot_messages lm ON lm.id = (\n`;
sql += ` SELECT id FROM bot_messages\n`;
sql += ` WHERE bot_id = b.id\n`;
sql += ` WHERE bot_id = b.id AND workspace_id IS ?\n`;
sql += ` ORDER BY created_at DESC, id DESC\n`;
sql += ` LIMIT 1\n`;
sql += `)\n`;
sql += `WHERE b.user_id = ?\n`;
if (scope) {
sql += `AND (b.kind = 'coordinator' OR b.id IN (SELECT bot_id FROM workspace_bots WHERE workspace_id = ?))\n`;
}
sql += `ORDER BY\n`;
sql += ` CASE WHEN b.kind = 'coordinator' THEN 0 ELSE 1 END,\n`;
sql += ` COALESCE(lm.created_at, b.last_activity_at, b.created_at) DESC\n`;
sql += ` COALESCE(lm.created_at, t.last_activity_at, b.created_at) DESC\n`;
const res = await BunSQLite.sql<BotWithLastMessage>({
sql,
values: [user_id],
values: [scope, scope, user_id, ...(scope ? [scope] : [])],
});
return res.payload || [];
@@ -6,16 +6,23 @@ import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number;
/**
* The session it goes in, and that session's workspace.
*/
session_id: number;
workspace_id?: number | null;
text: string;
needs_you?: boolean;
};
/**
* Appends a bot message to its conversation, marks the bot unread and
* bumps its activity. Callers must check the bot first.
* Appends a bot message to a session, marks the session unread and bumps
* its activity. Callers must check the bot and session first.
*/
export default async function insertBotMessage({
bot_id,
session_id,
workspace_id,
text,
needs_you,
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_MESSAGES> {
@@ -27,7 +34,16 @@ export default async function insertBotMessage({
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "bot", text, needs_you: needs_you_flag }],
data: [
{
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "bot",
text,
needs_you: needs_you_flag,
},
],
});
const id = res.postInsertReturn?.insertId;
@@ -37,13 +53,15 @@ export default async function insertBotMessage({
}
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ?, unread = 1 WHERE id = ?`,
values: [now, bot_id],
sql: `UPDATE bot_sessions SET last_activity_at = ?, unread = 1 WHERE id = ?`,
values: [now, session_id],
});
return {
id,
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "bot",
text,
needs_you: needs_you_flag,
@@ -9,6 +9,11 @@ import mediaToAttachment from "./media-to-attachment";
type Params = {
bot_id: number;
/**
* The session it goes in, and that session's workspace.
*/
session_id: number;
workspace_id?: number | null;
text: string;
/**
* Files to attach, already checked to be the user's own
@@ -18,12 +23,14 @@ type Params = {
};
/**
* Appends a user message (and its files) to a bot's conversation and
* bumps its activity. Callers must check the bot belongs to the session
* user first.
* Appends a user message (and its files) to a session and bumps its
* activity. The first one titles the session. Callers must check the bot
* and session belong to the session user first.
*/
export default async function insertUserMessage({
bot_id,
session_id,
workspace_id,
text,
media = [],
}: Params): Promise<BotMessageWithAttachments> {
@@ -34,7 +41,16 @@ export default async function insertUserMessage({
(typeof BunSQLiteTables)[number]
>({
table: "bot_messages",
data: [{ bot_id, author: "user", text, needs_you: 0 }],
data: [
{
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "user",
text,
needs_you: 0,
},
],
});
const id = res.postInsertReturn?.insertId;
@@ -50,14 +66,20 @@ export default async function insertUserMessage({
});
}
const title = (text || media[0]?.title || "").replace(/\s+/g, " ").trim().slice(0, 80);
await BunSQLite.sql({
sql: `UPDATE bots SET last_activity_at = ? WHERE id = ?`,
values: [now, bot_id],
sql: `UPDATE bot_sessions SET last_activity_at = ?,
title = CASE WHEN COALESCE(title, '') = '' THEN ? ELSE title END
WHERE id = ?`,
values: [now, title, session_id],
});
return {
id,
bot_id,
session_id,
workspace_id: workspace_id || undefined,
author: "user",
text,
needs_you: 0,
@@ -1,16 +0,0 @@
import BunSQLite from "@moduletrace/bun-sqlite";
type Params = {
bot_id: number | string;
user_id: number | string;
};
/**
* Clears a bot's unread flag when its owner opens the conversation.
*/
export default async function markBotRead({ bot_id, user_id }: Params) {
await BunSQLite.sql({
sql: `UPDATE bots SET unread = 0 WHERE id = ? AND user_id = ? AND unread = 1`,
values: [bot_id, user_id],
});
}
@@ -0,0 +1,43 @@
import BunSQLite from "@moduletrace/bun-sqlite";
import type { BotHue } from "@/src/dict/bot-dict";
import AppError from "@/src/utils/app-error";
import assertPromptLength from "@/src/utils/assert-prompt-length";
import grabUserBot from "./grab-user-bot";
type Params = {
user_id: number | string;
bot_id: number | string;
name: string;
role?: string;
hue: BotHue;
system_prompt: string;
};
/**
* Saves one of the user's bots: name, job, hue and core instructions. The
* coordinator keeps its name and stays hue-less, so only its instructions
* change. Returns the saved bot.
*/
export default async function updateBot({ user_id, bot_id, name, role, hue, system_prompt }: Params) {
assertPromptLength({ label: "Instructions", text: system_prompt });
const bot = await grabUserBot({ bot_id, user_id });
if (!bot?.id) {
throw new AppError(`Bot not found.`);
}
if (bot.kind == "coordinator") {
await BunSQLite.sql({
sql: `UPDATE bots SET system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [system_prompt, Date.now(), bot.id],
});
} else {
await BunSQLite.sql({
sql: `UPDATE bots SET name = ?, role = ?, hue = ?, system_prompt = ?, updated_at = ? WHERE id = ?`,
values: [name, role || null, hue, system_prompt, Date.now(), bot.id],
});
}
return (await grabUserBot({ bot_id, user_id }))!;
}