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