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 type {
|
||||
BUN_SQLITE_COBBLE_BOT_SESSIONS,
|
||||
BunSQLiteTables,
|
||||
} from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* The session this one carries on from.
|
||||
*/
|
||||
previous_session_id?: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Starts an active session in a conversation. Callers close the old one
|
||||
* first (`startBotSession`).
|
||||
*/
|
||||
export default async function createBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||
const now = Date.now();
|
||||
|
||||
const row: BUN_SQLITE_COBBLE_BOT_SESSIONS = {
|
||||
bot_id: Number(bot_id),
|
||||
workspace_id: workspace_id ? Number(workspace_id) : undefined,
|
||||
previous_session_id: previous_session_id || undefined,
|
||||
status: "active",
|
||||
unread: 0,
|
||||
last_activity_at: now,
|
||||
};
|
||||
|
||||
const res = await BunSQLite.insert<
|
||||
BUN_SQLITE_COBBLE_BOT_SESSIONS,
|
||||
(typeof BunSQLiteTables)[number]
|
||||
>({
|
||||
table: "bot_sessions",
|
||||
data: [row],
|
||||
});
|
||||
|
||||
const id = res.singleRes?.id || res.postInsertReturn?.insertId;
|
||||
|
||||
if (!res.success || !id) {
|
||||
throw new Error(`Couldn't start a session: ${res.msg}`);
|
||||
}
|
||||
|
||||
return { ...row, id, created_at: now, updated_at: now };
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import createBotSession from "./create-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
import startBotSession from "./start-bot-session";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Start the next session when the active one is full
|
||||
* (`AppData.BotSessionMaxMessages`). Set when the user writes, so a
|
||||
* message never lands in a session that's about to be cut.
|
||||
*/
|
||||
rollover?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* The conversation's active session, started first when there is none
|
||||
* (carrying on from its latest closed one) or, with `rollover`, when the
|
||||
* active one is full.
|
||||
*/
|
||||
export default async function ensureBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
rollover,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||
const active = await grabActiveBotSession({ bot_id, workspace_id });
|
||||
|
||||
if (active?.id) {
|
||||
if (!rollover) return active;
|
||||
|
||||
const count =
|
||||
Number(
|
||||
(
|
||||
await BunSQLite.sql<{ n: number }>({
|
||||
sql: `SELECT COUNT(*) AS n FROM bot_messages WHERE session_id = ?`,
|
||||
values: [active.id],
|
||||
})
|
||||
).payload?.[0]?.n,
|
||||
) || 0;
|
||||
|
||||
return count < AppData.BotSessionMaxMessages
|
||||
? active
|
||||
: await startBotSession({ bot_id, workspace_id });
|
||||
}
|
||||
|
||||
const latest = (
|
||||
await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT id FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ?
|
||||
ORDER BY last_activity_at DESC, id DESC LIMIT 1`,
|
||||
values: [bot_id, workspace_id ? Number(workspace_id) : null],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
return await createBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id: latest?.id || null,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The active session of one conversation (a bot in its own chat or in one
|
||||
* workspace), or null when it has none yet.
|
||||
*/
|
||||
export default async function grabActiveBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT * FROM bot_sessions
|
||||
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
|
||||
ORDER BY id DESC LIMIT 1`,
|
||||
values: [bot_id, workspace_id ? Number(workspace_id) : null],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import { AppData } from "@/src/data/app-data";
|
||||
import type { BotSessionListItem } from "@/src/types";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
/**
|
||||
* Matches the session's title, summary or any of its messages.
|
||||
*/
|
||||
query?: string;
|
||||
page?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* A conversation's sessions for the sessions modal, latest activity first,
|
||||
* each with its message count and latest message. Empty sessions are left
|
||||
* out unless active.
|
||||
*/
|
||||
export default async function grabBotSessions({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
query,
|
||||
page = 1,
|
||||
}: Params): Promise<BotSessionListItem[]> {
|
||||
const search = query?.trim();
|
||||
const like = search ? `%${search.replace(/[\\%_]/g, (c) => `\\${c}`)}%` : null;
|
||||
const limit = AppData.BotSessionsPageSize;
|
||||
|
||||
let sql = ``;
|
||||
|
||||
sql += `SELECT s.*,\n`;
|
||||
sql += ` (SELECT COUNT(*) FROM bot_messages m WHERE m.session_id = s.id) AS message_count,\n`;
|
||||
sql += ` (SELECT m.text FROM bot_messages m WHERE m.session_id = s.id ORDER BY m.created_at DESC, m.id DESC LIMIT 1) AS last_message_text\n`;
|
||||
sql += `FROM bot_sessions s\n`;
|
||||
sql += `WHERE s.bot_id = ? AND s.workspace_id IS ?\n`;
|
||||
sql += `AND (s.status = 'active' OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id))\n`;
|
||||
|
||||
if (like) {
|
||||
sql += `AND (s.title LIKE ? ESCAPE '\\' OR s.summary LIKE ? ESCAPE '\\'\n`;
|
||||
sql += ` OR EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = s.id AND m.text LIKE ? ESCAPE '\\'))\n`;
|
||||
}
|
||||
|
||||
sql += `ORDER BY s.status = 'active' DESC, s.last_activity_at DESC, s.id DESC\n`;
|
||||
sql += `LIMIT ? OFFSET ?\n`;
|
||||
|
||||
const res = await BunSQLite.sql<BotSessionListItem>({
|
||||
sql,
|
||||
values: [
|
||||
bot_id,
|
||||
workspace_id ? Number(workspace_id) : null,
|
||||
...(like ? [like, like, like] : []),
|
||||
limit,
|
||||
(Math.max(1, page) - 1) * limit,
|
||||
],
|
||||
});
|
||||
|
||||
return res.payload || [];
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { PagePropsType } from "@/src/types";
|
||||
import grabBots from "../bots/grab-bots";
|
||||
import grabActiveBotTask from "../bots/grab-active-bot-task";
|
||||
import grabBotMessages from "../bots/grab-bot-messages";
|
||||
import grabBotComputers from "../computers/grab-bot-computers";
|
||||
import grabBotHarnessOverview from "../harness/grab-bot-harness-overview";
|
||||
import grabHarnessOverview from "../harness/grab-harness-overview";
|
||||
import grabUserWorkspaces from "../workspaces/grab-user-workspaces";
|
||||
import ensureBotSession from "./ensure-bot-session";
|
||||
import grabUserConversation from "./grab-user-conversation";
|
||||
import markBotSessionRead from "./mark-bot-session-read";
|
||||
|
||||
type Params = {
|
||||
user_id: number;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* The workspace the conversation is in; none for the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* The page props of a conversation (`/admin/bots/<id>` or
|
||||
* `/admin/workspaces/<id>/bots/<id>`): the rail's crew (the workspace's
|
||||
* in a workspace) and workspaces, the bot, its active session (marked
|
||||
* read) and its messages, its models, computers and active task there.
|
||||
* Null when the bot isn't the user's or isn't in the workspace.
|
||||
*/
|
||||
export default async function grabConversationProps({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<PagePropsType | null> {
|
||||
const conversation = await grabUserConversation({ user_id, bot_id, workspace_id }).catch(
|
||||
() => null,
|
||||
);
|
||||
|
||||
if (!conversation) return null;
|
||||
|
||||
const scope = conversation.workspace_id;
|
||||
const session = await ensureBotSession({ bot_id: conversation.bot.id, workspace_id: scope });
|
||||
|
||||
if (session.unread) {
|
||||
await markBotSessionRead({ session_id: session.id!, user_id });
|
||||
session.unread = 0;
|
||||
}
|
||||
|
||||
const [bots, workspaces] = await Promise.all([
|
||||
grabBots({ user_id, workspace_id: scope }),
|
||||
grabUserWorkspaces({ user_id }),
|
||||
]);
|
||||
|
||||
const bot = bots.find((b) => b.id == conversation.bot.id);
|
||||
|
||||
if (!bot?.id) return null;
|
||||
|
||||
bot.unread = 0;
|
||||
|
||||
/**
|
||||
* The header's models modal: a worker's own settings, or for the
|
||||
* coordinator the defaults it runs on (without the providers). In a
|
||||
* workspace the workspace's own models come first, so what runs now
|
||||
* shows its pick.
|
||||
*/
|
||||
const [bot_messages, bot_harness_overview, bot_computers, bot_task] = await Promise.all([
|
||||
grabBotMessages({ session_id: session.id! }),
|
||||
(bot.kind == "coordinator" && !scope
|
||||
? grabHarnessOverview().then(({ harnesses }) => ({ harnesses }))
|
||||
: grabBotHarnessOverview({
|
||||
bot_id: bot.kind == "coordinator" ? undefined : bot.id,
|
||||
workspace_id: scope || undefined,
|
||||
})
|
||||
).catch(() => null),
|
||||
grabBotComputers({ bot_id: bot.id, user_id, workspace_id: scope }),
|
||||
grabActiveBotTask({ bot_id: bot.id, session_id: session.id }),
|
||||
]);
|
||||
|
||||
return {
|
||||
bots,
|
||||
bot,
|
||||
workspaces,
|
||||
workspace: conversation.workspace,
|
||||
bot_session: session,
|
||||
bot_messages,
|
||||
bot_harness_overview,
|
||||
bot_computers,
|
||||
bot_task,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* A session only if its bot belongs to the given owner.
|
||||
*/
|
||||
export default async function grabUserBotSession({
|
||||
session_id,
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS | null> {
|
||||
const res = await BunSQLite.sql<BUN_SQLITE_COBBLE_BOT_SESSIONS>({
|
||||
sql: `SELECT s.* FROM bot_sessions s JOIN bots b ON b.id = s.bot_id
|
||||
WHERE s.id = ? AND b.user_id = ? LIMIT 1`,
|
||||
values: [session_id, user_id],
|
||||
});
|
||||
|
||||
return res.payload?.[0] || null;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOTS, BUN_SQLITE_COBBLE_WORKSPACES } from "@/db/types/db";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBot from "../bots/grab-user-bot";
|
||||
import grabWorkspaceBot from "../workspaces/grab-workspace-bot";
|
||||
|
||||
type Params = {
|
||||
user_id: number | string;
|
||||
bot_id: number | string;
|
||||
/**
|
||||
* Null or undefined: the bot's own chat.
|
||||
*/
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
export type UserConversation = {
|
||||
bot: BUN_SQLITE_COBBLE_BOTS & { id: number };
|
||||
workspace: BUN_SQLITE_COBBLE_WORKSPACES | null;
|
||||
workspace_id: number | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* One of the user's conversations: their bot, in its own chat or in one of
|
||||
* their workspaces it works in. Throws when the bot isn't theirs or isn't
|
||||
* in that workspace.
|
||||
*/
|
||||
export default async function grabUserConversation({
|
||||
user_id,
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<UserConversation> {
|
||||
if (workspace_id) {
|
||||
const found = await grabWorkspaceBot({ workspace_id, bot_id, user_id });
|
||||
|
||||
if (!found?.bot.id || !found.workspace.id) {
|
||||
throw new AppError(`That bot isn't in this workspace.`);
|
||||
}
|
||||
|
||||
return {
|
||||
bot: { ...found.bot, id: found.bot.id },
|
||||
workspace: found.workspace,
|
||||
workspace_id: found.workspace.id,
|
||||
};
|
||||
}
|
||||
|
||||
const bot = await grabUserBot({ bot_id, user_id });
|
||||
|
||||
if (!bot?.id) {
|
||||
throw new AppError(`Bot not found`);
|
||||
}
|
||||
|
||||
return { bot: { ...bot, id: bot.id }, workspace: null, workspace_id: null };
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Clears a session's unread flag when its owner has the conversation open.
|
||||
*/
|
||||
export default async function markBotSessionRead({ session_id, user_id }: Params) {
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET unread = 0
|
||||
WHERE id = ? AND unread = 1 AND bot_id IN (SELECT id FROM bots WHERE user_id = ?)`,
|
||||
values: [session_id, user_id],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import AppError from "@/src/utils/app-error";
|
||||
import grabUserBotSession from "./grab-user-bot-session";
|
||||
|
||||
type Params = {
|
||||
session_id: number | string;
|
||||
user_id: number | string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Makes a closed session of one of the user's conversations the active
|
||||
* one again, so the bot carries on from where it left off. The session
|
||||
* that was active is closed (an empty one is deleted).
|
||||
*/
|
||||
export default async function reopenBotSession({
|
||||
session_id,
|
||||
user_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||
const session = await grabUserBotSession({ session_id, user_id });
|
||||
|
||||
if (!session?.id) {
|
||||
throw new AppError(`Session not found.`);
|
||||
}
|
||||
|
||||
if (session.status == "active") return session;
|
||||
|
||||
const now = Date.now();
|
||||
const workspace_id = session.workspace_id || null;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `DELETE FROM bot_sessions WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'
|
||||
AND NOT EXISTS (SELECT 1 FROM bot_messages m WHERE m.session_id = bot_sessions.id)`,
|
||||
values: [session.bot_id!, workspace_id],
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ?
|
||||
WHERE bot_id = ? AND workspace_id IS ? AND status = 'active'`,
|
||||
values: [now, session.bot_id!, workspace_id],
|
||||
});
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET status = 'active', updated_at = ? WHERE id = ?`,
|
||||
values: [now, session.id],
|
||||
});
|
||||
|
||||
return { ...session, status: "active", updated_at: now };
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import type { BUN_SQLITE_COBBLE_BOT_SESSIONS } from "@/db/types/db";
|
||||
import BunSQLite from "@moduletrace/bun-sqlite";
|
||||
import createBotSession from "./create-bot-session";
|
||||
import grabActiveBotSession from "./grab-active-bot-session";
|
||||
|
||||
type Params = {
|
||||
bot_id: number | string;
|
||||
workspace_id?: number | string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Closes the conversation's active session and starts the next one, which
|
||||
* carries on from it (its summary is written when the next reply runs).
|
||||
* An active session with no messages yet is kept as it is.
|
||||
*/
|
||||
export default async function startBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
}: Params): Promise<BUN_SQLITE_COBBLE_BOT_SESSIONS> {
|
||||
const active = await grabActiveBotSession({ bot_id, workspace_id });
|
||||
|
||||
if (active?.id) {
|
||||
const has_messages = (
|
||||
await BunSQLite.sql({
|
||||
sql: `SELECT id FROM bot_messages WHERE session_id = ? LIMIT 1`,
|
||||
values: [active.id],
|
||||
})
|
||||
).payload?.[0];
|
||||
|
||||
if (!has_messages) return active;
|
||||
|
||||
await BunSQLite.sql({
|
||||
sql: `UPDATE bot_sessions SET status = 'closed', unread = 0, updated_at = ? WHERE id = ?`,
|
||||
values: [Date.now(), active.id],
|
||||
});
|
||||
}
|
||||
|
||||
return await createBotSession({
|
||||
bot_id,
|
||||
workspace_id,
|
||||
previous_session_id: active?.id || null,
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user