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,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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 }))!;
|
||||
}
|
||||
Reference in New Issue
Block a user