Files
cobble/src/functions/backend/db/extensions/grab-extensions-overview.ts
T
tbenandClaude Opus 5.5 e7b23b7454 Add knowledgebases
Knowledgebases give bots RAG over documents, scoped like extensions: to
particular bots, to a workspace, or to every bot, with their own button
beside Tools in the composer. Documents are chunked and embedded by the
file-ingest worker with the install's embedding model, which is now an
install-wide setting rather than a per-bot one.

- Word search runs on an FTS5 index ranked by BM25, with a scored LIKE
  fallback for scripts written without spaces.
- Changing the embedding model re-indexes documents automatically.
- Ingest jobs wait without spending attempts while every embedding model
  rests, fail at once on errors retrying can't fix, and keep passages
  searchable by words when the model refuses.
- Settings > Knowledgebases shows each knowledgebase's and document's
  index state: spinner, warning, grey check (words only) or green check.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-26 16:41:01 +01:00

192 lines
8.1 KiB
TypeScript

import BunSQLite from "@moduletrace/bun-sqlite";
import type {
BUN_SQLITE_COBBLE_CUSTOM_TOOLS,
BUN_SQLITE_COBBLE_KNOWLEDGEBASES,
BUN_SQLITE_COBBLE_MCP_SERVERS,
BUN_SQLITE_COBBLE_SKILLS,
} from "@/db/types/db";
import { CustomToolKinds } from "@/src/dict/custom-tool-dict";
import { McpTransports } from "@/src/dict/mcp-dict";
import type { BotExtension, BotExtensionsPayload } from "@/src/types";
import parseKnowledgebaseRow from "../knowledgebases/parse-knowledgebase-row";
import parseCustomToolRow from "./parse-custom-tool-row";
import parseMcpServerRow from "./parse-mcp-server-row";
import parseSkillRow from "./parse-skill-row";
type Params = {
/**
* Whether the viewer can give and take them: only then is what can be
* added listed.
*/
can_manage: boolean;
/**
* A bot's: given to it, and (with `workspace_id`) that workspace's
* too, not removable from the bot. Without it, only the workspace's.
*/
bot_id?: number | string | null;
workspace_id?: number | string | null;
};
type Linked<T> = T & { source: "bot" | "workspace" };
/**
* The MCP servers, tools, skills and knowledgebases a bot or a workspace has, and the rest of
* them, which can be added (`grabBotExtensionsOverview`,
* `grabWorkspaceExtensionsOverview`).
*/
export default async function grabExtensionsOverview({
can_manage,
bot_id,
workspace_id,
}: Params): Promise<BotExtensionsPayload> {
const bot = Number(bot_id || 0);
const workspace = Number(workspace_id || 0);
const linked = (column: string, bot_link: string, workspace_link: string) => ({
sql: `SELECT x.*, CASE WHEN x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?) THEN 'bot' ELSE 'workspace' END AS source
FROM %TABLE% x WHERE x.id IN (SELECT ${column} FROM ${bot_link} WHERE bot_id = ?)
OR x.id IN (SELECT ${column} FROM ${workspace_link} WHERE workspace_id = ?)
ORDER BY x.name COLLATE NOCASE ASC`,
values: [bot, bot, workspace],
});
const mcp_query = linked("mcp_server_id", "bot_mcp_servers", "workspace_mcp_servers");
const tool_query = linked("custom_tool_id", "bot_custom_tools", "workspace_custom_tools");
const skill_query = linked("skill_id", "bot_skills", "workspace_skills");
const knowledgebase_query = linked("knowledgebase_id", "bot_knowledgebases", "workspace_knowledgebases");
const [servers, tools, skills, knowledgebases, documents, all_servers, all_tools, all_skills, all_knowledgebases] =
await Promise.all([
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_MCP_SERVERS>>({
sql: mcp_query.sql.replace("%TABLE%", "mcp_servers"),
values: mcp_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_CUSTOM_TOOLS>>({
sql: tool_query.sql.replace("%TABLE%", "custom_tools"),
values: tool_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_SKILLS>>({
sql: skill_query.sql.replace("%TABLE%", "skills"),
values: skill_query.values,
}),
BunSQLite.sql<Linked<BUN_SQLITE_COBBLE_KNOWLEDGEBASES>>({
sql: knowledgebase_query.sql.replace("%TABLE%", "knowledgebases"),
values: knowledgebase_query.values,
}),
BunSQLite.sql<{ knowledgebase_id: number; documents: number; failed: number }>({
sql: `SELECT knowledgebase_id, COUNT(*) AS documents,
SUM(CASE WHEN ingest_error IS NOT NULL AND COALESCE(chunk_count, 0) = 0 THEN 1 ELSE 0 END) AS failed
FROM knowledgebase_documents GROUP BY knowledgebase_id`,
}),
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM mcp_servers ORDER BY name COLLATE NOCASE` })
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({ sql: `SELECT id, name FROM custom_tools ORDER BY name` })
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM skills ORDER BY name COLLATE NOCASE`,
})
: null,
can_manage
? BunSQLite.sql<{ id: number; name: string }>({
sql: `SELECT id, name FROM knowledgebases ORDER BY name COLLATE NOCASE`,
})
: null,
]);
const document_counts = new Map(
(documents.payload || []).map((row) => [Number(row.knowledgebase_id), row]),
);
const attached: BotExtension[] = [
...(servers.payload || []).map((row) => {
const server = parseMcpServerRow({ row });
const transport = McpTransports.find((t) => t.value == server.transport)?.value == "stdio" ? "Command" : "Remote";
const count = server.tools.filter((tool) => !server.excluded_tools.includes(tool.name)).length;
return {
kind: "mcp" as const,
id: server.id,
name: server.name,
description: server.description,
detail: `MCP · ${transport}${server.status == "ok" ? ` · ${count} ${count == 1 ? "tool" : "tools"}` : ""}`,
state: server.status,
enabled: server.enabled,
source: row.source,
};
}),
...(tools.payload || []).map((row) => {
const tool = parseCustomToolRow({ row });
return {
kind: "tool" as const,
id: tool.id,
name: tool.name,
description: tool.description,
detail: `Tool · ${CustomToolKinds.find((k) => k.value == tool.kind)?.title || tool.kind}`,
state: tool.status,
enabled: tool.enabled,
source: row.source,
};
}),
...(skills.payload || []).map((row) => {
const skill = parseSkillRow({ row });
return {
kind: "skill" as const,
id: skill.id,
name: skill.name,
description: skill.description,
detail: `Skill · ${skill.slug}`,
state: "ok" as const,
enabled: skill.enabled,
source: row.source,
};
}),
...(knowledgebases.payload || []).map((row) => {
const knowledgebase = parseKnowledgebaseRow({ row });
const count = document_counts.get(knowledgebase.id);
const total = Number(count?.documents) || 0;
return {
kind: "knowledgebase" as const,
id: knowledgebase.id,
name: knowledgebase.name,
description: knowledgebase.description,
detail: `Knowledgebase · ${total} ${total == 1 ? "document" : "documents"}`,
state: Number(count?.failed) ? ("error" as const) : ("ok" as const),
enabled: knowledgebase.enabled,
source: row.source,
};
}),
];
/**
* A bot can be given one its workspace has; a workspace only what it
* hasn't.
*/
const has = (kind: BotExtension["kind"], id: number) =>
attached.some((item) => item.kind == kind && item.id == id && (!bot || item.source == "bot"));
return {
attached,
addable: [
...(all_servers?.payload || [])
.filter((row) => !has("mcp", Number(row.id)))
.map((row) => ({ kind: "mcp" as const, id: Number(row.id), name: row.name })),
...(all_tools?.payload || [])
.filter((row) => !has("tool", Number(row.id)))
.map((row) => ({ kind: "tool" as const, id: Number(row.id), name: row.name })),
...(all_skills?.payload || [])
.filter((row) => !has("skill", Number(row.id)))
.map((row) => ({ kind: "skill" as const, id: Number(row.id), name: row.name })),
...(all_knowledgebases?.payload || [])
.filter((row) => !has("knowledgebase", Number(row.id)))
.map((row) => ({ kind: "knowledgebase" as const, id: Number(row.id), name: row.name })),
],
can_manage,
};
}