@protobase/server 0.1.6 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/aggregate-service.js +1 -1
- package/dist/assistant/app-context.js +29 -0
- package/dist/assistant/approval.d.ts +26 -0
- package/dist/assistant/approval.js +58 -0
- package/dist/assistant/assistant-settings.d.ts +47 -0
- package/dist/assistant/assistant-settings.js +55 -0
- package/dist/assistant/built-in-assistant.js +44 -0
- package/dist/assistant/cache-breakpoints.d.ts +9 -0
- package/dist/assistant/cache-breakpoints.js +25 -0
- package/dist/assistant/chat-completions.d.ts +74 -0
- package/dist/assistant/chat-completions.js +101 -0
- package/dist/assistant/conversation-store.d.ts +28 -0
- package/dist/assistant/conversations.d.ts +41 -0
- package/dist/assistant/conversations.js +108 -0
- package/dist/assistant/file-conversation-store.d.ts +8 -0
- package/dist/assistant/file-conversation-store.js +40 -0
- package/dist/assistant/protocol-routes.d.ts +28 -0
- package/dist/assistant/protocol-routes.js +78 -0
- package/dist/assistant/query-tools.d.ts +15 -0
- package/dist/assistant/query-tools.js +76 -0
- package/dist/assistant/run-query.d.ts +24 -0
- package/dist/assistant/run-query.js +39 -0
- package/dist/assistant/tools.d.ts +26 -0
- package/dist/assistant/tools.js +5 -0
- package/dist/assistant/turn.d.ts +37 -0
- package/dist/assistant/turn.js +123 -0
- package/dist/create-admin.js +16 -3
- package/dist/index.d.ts +13 -1
- package/dist/index.js +13 -2
- package/dist/meta.js +3 -1
- package/dist/types.d.ts +3 -0
- package/package.json +4 -4
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { ConversationStore } from "./conversation-store.js";
|
|
2
|
+
//#region src/assistant/file-conversation-store.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* Conversations as files: `<directory>/<owner>/<id>.jsonl`, one record per line, appended as the chat goes on. A
|
|
5
|
+
* relative directory is resolved against the working directory; it is made on the first append.
|
|
6
|
+
*/
|
|
7
|
+
export declare const fileConversationStore: (directory: string) => ConversationStore;
|
|
8
|
+
//#endregion
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
//#region src/assistant/file-conversation-store.ts
|
|
2
|
+
const fs = () => import("node:fs/promises");
|
|
3
|
+
const missing = (error) => error instanceof Error && error.code === "ENOENT";
|
|
4
|
+
const segment = (name) => encodeURIComponent(name).replaceAll(".", "%2E");
|
|
5
|
+
const extension = ".jsonl";
|
|
6
|
+
/**
|
|
7
|
+
* Conversations as files: `<directory>/<owner>/<id>.jsonl`, one record per line, appended as the chat goes on. A
|
|
8
|
+
* relative directory is resolved against the working directory; it is made on the first append.
|
|
9
|
+
*/
|
|
10
|
+
const fileConversationStore = (directory) => {
|
|
11
|
+
const folder = (owner) => `${directory}/${segment(owner)}`;
|
|
12
|
+
const file = (owner, id) => `${folder(owner)}/${segment(id)}${extension}`;
|
|
13
|
+
return {
|
|
14
|
+
load: async (owner, id) => {
|
|
15
|
+
return (await (await fs()).readFile(file(owner, id), "utf8").catch((error) => {
|
|
16
|
+
if (missing(error)) return "";
|
|
17
|
+
throw error;
|
|
18
|
+
})).split("\n").slice(0, -1).map((line) => JSON.parse(line));
|
|
19
|
+
},
|
|
20
|
+
append: async (owner, id, records) => {
|
|
21
|
+
if (records.length === 0) return;
|
|
22
|
+
const { appendFile, mkdir } = await fs();
|
|
23
|
+
await mkdir(folder(owner), { recursive: true });
|
|
24
|
+
await appendFile(file(owner, id), records.map((record) => `${JSON.stringify(record)}\n`).join(""));
|
|
25
|
+
},
|
|
26
|
+
list: async (owner) => {
|
|
27
|
+
const { readdir, stat } = await fs();
|
|
28
|
+
const names = await readdir(folder(owner)).catch((error) => {
|
|
29
|
+
if (missing(error)) return [];
|
|
30
|
+
throw error;
|
|
31
|
+
});
|
|
32
|
+
return (await Promise.all(names.filter((name) => name.endsWith(extension)).map(async (name) => ({
|
|
33
|
+
id: decodeURIComponent(name.slice(0, -6)),
|
|
34
|
+
updatedAt: (await stat(`${folder(owner)}/${name}`)).mtime
|
|
35
|
+
})))).sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime());
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
export { fileConversationStore };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Authenticator, Session } from "../types.js";
|
|
2
|
+
import { Conversation } from "./conversations.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { AssistantMessageRequest } from "@protobase/schema";
|
|
5
|
+
//#region src/assistant/protocol-routes.d.ts
|
|
6
|
+
export type AssistantBackend = {
|
|
7
|
+
/** Resolves the caller from the request's bearer token, or throws a 401 problem. */
|
|
8
|
+
authenticate: Authenticator;
|
|
9
|
+
/** Whether the caller may use the assistant; others get 403. */
|
|
10
|
+
allows: (session: Session) => boolean;
|
|
11
|
+
conversationOf: (session: Session) => Conversation | Promise<Conversation>;
|
|
12
|
+
/** Answers a message of the user with events on the conversation; called only while the backend is not replying. */
|
|
13
|
+
onMessage: (message: AssistantMessageRequest, session: Session, conversation: Conversation) => Promise<void>;
|
|
14
|
+
/** Gets errors of `onMessage` and of requests that become a 500. */
|
|
15
|
+
report?: (error: unknown) => void;
|
|
16
|
+
/** The longest message accepted. Default 4000. */
|
|
17
|
+
maxMessageLength?: number;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* The assistant protocol's requests as a Hono app: `GET /events` (the event stream, a `state` event first),
|
|
21
|
+
* `POST /messages` and `POST /actions`. Mount it where `/api/meta` says the backend is.
|
|
22
|
+
*/
|
|
23
|
+
export declare const assistantProtocolRoutes: (backend: AssistantBackend) => Hono<{
|
|
24
|
+
Variables: {
|
|
25
|
+
session: Session;
|
|
26
|
+
};
|
|
27
|
+
}, import("hono/types").BlankSchema, "/">;
|
|
28
|
+
//#endregion
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { HttpProblem, badRequest, forbidden, notFound } from "../problem.js";
|
|
2
|
+
import { createErrorHandler } from "../error-handler.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import { streamSSE } from "hono/streaming";
|
|
5
|
+
//#region src/assistant/protocol-routes.ts
|
|
6
|
+
const isObject = (body) => typeof body === "object" && body !== null;
|
|
7
|
+
const maxPageLength = 2e3;
|
|
8
|
+
const messageOf = (body, max) => {
|
|
9
|
+
const { text, page } = isObject(body) ? body : {};
|
|
10
|
+
if (typeof text !== "string" || !text.trim()) throw badRequest("invalid-message", "The body must be { \"text\": \"...\" }");
|
|
11
|
+
if (text.length > max) throw badRequest("invalid-message", `A message is at most ${max} characters`);
|
|
12
|
+
if (page !== void 0 && (typeof page !== "string" || page.length > maxPageLength)) throw badRequest("invalid-message", `"page" must be a path of at most ${maxPageLength} characters`);
|
|
13
|
+
return {
|
|
14
|
+
text: text.trim(),
|
|
15
|
+
...page && { page }
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
const actionOf = (body) => {
|
|
19
|
+
const { partId, actionId } = isObject(body) ? body : {};
|
|
20
|
+
if (typeof partId !== "string" || typeof actionId !== "string") throw badRequest("invalid-action", "The body must be { \"partId\": \"...\", \"actionId\": \"...\" }");
|
|
21
|
+
return {
|
|
22
|
+
partId,
|
|
23
|
+
actionId
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* The assistant protocol's requests as a Hono app: `GET /events` (the event stream, a `state` event first),
|
|
28
|
+
* `POST /messages` and `POST /actions`. Mount it where `/api/meta` says the backend is.
|
|
29
|
+
*/
|
|
30
|
+
const assistantProtocolRoutes = (backend) => {
|
|
31
|
+
const app = new Hono();
|
|
32
|
+
app.onError(createErrorHandler(backend.report));
|
|
33
|
+
app.use("*", async (c, next) => {
|
|
34
|
+
const session = await backend.authenticate(c.req.raw);
|
|
35
|
+
if (!backend.allows(session)) throw forbidden("assistant-forbidden", "The assistant needs the admin or ai role");
|
|
36
|
+
c.set("session", session);
|
|
37
|
+
await next();
|
|
38
|
+
});
|
|
39
|
+
app.get("/events", async (c) => {
|
|
40
|
+
const conversation = await backend.conversationOf(c.get("session"));
|
|
41
|
+
return streamSSE(c, async (stream) => {
|
|
42
|
+
const queue = [{
|
|
43
|
+
type: "state",
|
|
44
|
+
state: conversation.state()
|
|
45
|
+
}];
|
|
46
|
+
let wake = () => {};
|
|
47
|
+
const unsubscribe = conversation.subscribe((event) => {
|
|
48
|
+
queue.push(event);
|
|
49
|
+
wake();
|
|
50
|
+
});
|
|
51
|
+
stream.onAbort(() => {
|
|
52
|
+
unsubscribe();
|
|
53
|
+
wake();
|
|
54
|
+
});
|
|
55
|
+
while (!stream.aborted) {
|
|
56
|
+
const event = queue.shift();
|
|
57
|
+
if (event) await stream.writeSSE({ data: JSON.stringify(event) });
|
|
58
|
+
else await new Promise((resolve) => wake = resolve);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
app.post("/messages", async (c) => {
|
|
63
|
+
const session = c.get("session");
|
|
64
|
+
const message = messageOf(await c.req.json(), backend.maxMessageLength ?? 4e3);
|
|
65
|
+
const conversation = await backend.conversationOf(session);
|
|
66
|
+
if (conversation.state().replying) throw new HttpProblem(409, "assistant-replying", "Conflict", "The assistant is still answering");
|
|
67
|
+
backend.onMessage(message, session, conversation).catch((error) => backend.report?.(error));
|
|
68
|
+
return c.body(null, 202);
|
|
69
|
+
});
|
|
70
|
+
app.post("/actions", async (c) => {
|
|
71
|
+
const { partId, actionId } = actionOf(await c.req.json());
|
|
72
|
+
if (!(await backend.conversationOf(c.get("session"))).act(partId, actionId)) throw notFound("No card waits for that action");
|
|
73
|
+
return c.body(null, 202);
|
|
74
|
+
});
|
|
75
|
+
return app;
|
|
76
|
+
};
|
|
77
|
+
//#endregion
|
|
78
|
+
export { assistantProtocolRoutes };
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { AssistantTool } from "./tools.js";
|
|
2
|
+
import { QueryOptions } from "./run-query.js";
|
|
3
|
+
import { Db } from "@protobase/query";
|
|
4
|
+
//#region src/assistant/query-tools.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Lets the model read the app's database: one SELECT per call, in a read-only transaction (see `runReadOnlyQuery`).
|
|
7
|
+
* The rows show as a table in the chat. It runs as the app's database role, outside Protobase's access rules.
|
|
8
|
+
*/
|
|
9
|
+
export declare const readOnlyQueryTool: (db: Db, options?: QueryOptions) => AssistantTool;
|
|
10
|
+
/**
|
|
11
|
+
* Lets the model change the app's database, never on its own: each call shows the statement on an approval card, and
|
|
12
|
+
* only the user's Approve runs it, in a read-write transaction (see `runReadWriteQuery`). Reject, or no answer, runs nothing.
|
|
13
|
+
*/
|
|
14
|
+
export declare const readWriteQueryTool: (db: Db, options?: QueryOptions) => AssistantTool;
|
|
15
|
+
//#endregion
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { runReadOnlyQuery, runReadWriteQuery } from "./run-query.js";
|
|
2
|
+
import { defineTool } from "./tools.js";
|
|
3
|
+
//#region src/assistant/query-tools.ts
|
|
4
|
+
const maxResultLength = 2e4;
|
|
5
|
+
const sqlParameter = (description) => ({
|
|
6
|
+
type: "string",
|
|
7
|
+
description
|
|
8
|
+
});
|
|
9
|
+
const isDatabaseError = (error) => error instanceof Error && /^[0-9A-Z]{5}$/.test(String(error.code));
|
|
10
|
+
const refused = (error) => {
|
|
11
|
+
if (isDatabaseError(error)) return `Postgres refused the statement: ${error.message}`;
|
|
12
|
+
throw error;
|
|
13
|
+
};
|
|
14
|
+
const shown = (result, statement, context) => {
|
|
15
|
+
context.show({
|
|
16
|
+
type: "table",
|
|
17
|
+
id: crypto.randomUUID(),
|
|
18
|
+
columns: result.columns,
|
|
19
|
+
rows: result.rows,
|
|
20
|
+
caption: statement,
|
|
21
|
+
...result.truncated && { truncated: true }
|
|
22
|
+
});
|
|
23
|
+
const json = JSON.stringify(result);
|
|
24
|
+
return json.length > maxResultLength ? `${json.slice(0, maxResultLength)}... (cut off)` : json;
|
|
25
|
+
};
|
|
26
|
+
const statementOf = (args) => typeof args.sql === "string" && args.sql.trim() ? args.sql : void 0;
|
|
27
|
+
/**
|
|
28
|
+
* Lets the model read the app's database: one SELECT per call, in a read-only transaction (see `runReadOnlyQuery`).
|
|
29
|
+
* The rows show as a table in the chat. It runs as the app's database role, outside Protobase's access rules.
|
|
30
|
+
*/
|
|
31
|
+
const readOnlyQueryTool = (db, options = {}) => defineTool({
|
|
32
|
+
name: "run_read_only_query",
|
|
33
|
+
description: `Runs one PostgreSQL SELECT on the app's database in a read-only transaction and returns up to ${options.rowLimit ?? 200} rows as JSON ({ columns, rows, truncated }). The user sees the rows as a table. Use it to answer questions about the data. It cannot change anything.`,
|
|
34
|
+
parameters: {
|
|
35
|
+
type: "object",
|
|
36
|
+
properties: { sql: sqlParameter("One SELECT statement, without a trailing semicolon.") },
|
|
37
|
+
required: ["sql"]
|
|
38
|
+
},
|
|
39
|
+
run: async (args, context) => {
|
|
40
|
+
const statement = statementOf(args);
|
|
41
|
+
if (!statement) return "Pass the statement as { \"sql\": \"select ...\" }.";
|
|
42
|
+
return runReadOnlyQuery(db, statement, options).then((result) => shown(result, statement, context), refused);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
/**
|
|
46
|
+
* Lets the model change the app's database, never on its own: each call shows the statement on an approval card, and
|
|
47
|
+
* only the user's Approve runs it, in a read-write transaction (see `runReadWriteQuery`). Reject, or no answer, runs nothing.
|
|
48
|
+
*/
|
|
49
|
+
const readWriteQueryTool = (db, options = {}) => defineTool({
|
|
50
|
+
name: "propose_write_query",
|
|
51
|
+
description: "Proposes one PostgreSQL INSERT, UPDATE or DELETE ending in a RETURNING clause. The user sees the statement and your summary and must approve it; only then it runs in a read-write transaction, and you get the returned rows as JSON, or the answer that it did not run. Use it only when the user asked for a change.",
|
|
52
|
+
parameters: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
sql: sqlParameter("One INSERT, UPDATE or DELETE with a RETURNING clause, without a trailing semicolon."),
|
|
56
|
+
summary: {
|
|
57
|
+
type: "string",
|
|
58
|
+
description: "What the statement changes, in one plain sentence for the user."
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
required: ["sql", "summary"]
|
|
62
|
+
},
|
|
63
|
+
run: async (args, context) => {
|
|
64
|
+
const statement = statementOf(args);
|
|
65
|
+
if (!statement) return "Pass the statement as { \"sql\": \"update ... returning ...\", \"summary\": \"...\" }.";
|
|
66
|
+
const answer = await context.approve({
|
|
67
|
+
title: "Run this change?",
|
|
68
|
+
body: typeof args.summary === "string" ? args.summary : void 0,
|
|
69
|
+
code: statement
|
|
70
|
+
});
|
|
71
|
+
if (answer !== "approved") return `The user did not approve the statement (${answer}); nothing ran.`;
|
|
72
|
+
return runReadWriteQuery(db, statement, options).then((result) => shown(result, statement, context), refused);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
//#endregion
|
|
76
|
+
export { readOnlyQueryTool, readWriteQueryTool };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Db } from "@protobase/query";
|
|
2
|
+
//#region src/assistant/run-query.d.ts
|
|
3
|
+
export type QueryOptions = {
|
|
4
|
+
/** Postgres cancels the statement after this long. Default 5000. */
|
|
5
|
+
timeoutMs?: number;
|
|
6
|
+
/** At most this many rows come back; `truncated` says there were more. Default 200. */
|
|
7
|
+
rowLimit?: number;
|
|
8
|
+
};
|
|
9
|
+
export type QueryResult = {
|
|
10
|
+
columns: string[];
|
|
11
|
+
rows: unknown[][];
|
|
12
|
+
truncated: boolean;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Runs one SELECT (or VALUES, or a WITH query) in a READ ONLY transaction that is always rolled back, so nothing it
|
|
16
|
+
* does, settings included, outlives it. Postgres refuses any write in it, and the statement cannot leave it.
|
|
17
|
+
*/
|
|
18
|
+
export declare const runReadOnlyQuery: (db: Db, statement: string, options?: QueryOptions) => Promise<QueryResult>;
|
|
19
|
+
/**
|
|
20
|
+
* Runs one INSERT, UPDATE or DELETE with a RETURNING clause (or a query) in a READ WRITE transaction, committed when it
|
|
21
|
+
* succeeds. Only call it for a statement the user approved.
|
|
22
|
+
*/
|
|
23
|
+
export declare const runReadWriteQuery: (db: Db, statement: string, options?: QueryOptions) => Promise<QueryResult>;
|
|
24
|
+
//#endregion
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { sql } from "kysely";
|
|
2
|
+
//#region src/assistant/run-query.ts
|
|
3
|
+
const wrapped = (statement, rowLimit) => sql`with result as (\n${sql.raw(statement.trim().replace(/;\s*$/, ""))}\n) select * from result limit ${rowLimit + 1}`;
|
|
4
|
+
const cell = (value) => {
|
|
5
|
+
if (typeof value === "bigint") return value.toString();
|
|
6
|
+
if (value instanceof Date) return value.toISOString();
|
|
7
|
+
if (value instanceof Uint8Array) return `\\x${Array.from(value, (byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
8
|
+
return value;
|
|
9
|
+
};
|
|
10
|
+
const resultOf = (rows, rowLimit) => ({
|
|
11
|
+
columns: Object.keys(rows[0] ?? {}),
|
|
12
|
+
rows: rows.slice(0, rowLimit).map((row) => Object.values(row).map(cell)),
|
|
13
|
+
truncated: rows.length > rowLimit
|
|
14
|
+
});
|
|
15
|
+
const run = (db, statement, options, begin, end) => db.connection().execute(async (connection) => {
|
|
16
|
+
const rowLimit = options.rowLimit ?? 200;
|
|
17
|
+
await sql.raw(begin).execute(connection);
|
|
18
|
+
let succeeded = false;
|
|
19
|
+
try {
|
|
20
|
+
await sql`select set_config('statement_timeout', ${String(options.timeoutMs ?? 5e3)}, true)`.execute(connection);
|
|
21
|
+
const { rows } = await wrapped(statement, rowLimit).execute(connection);
|
|
22
|
+
succeeded = true;
|
|
23
|
+
return resultOf(rows, rowLimit);
|
|
24
|
+
} finally {
|
|
25
|
+
await sql.raw(end(succeeded)).execute(connection);
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
/**
|
|
29
|
+
* Runs one SELECT (or VALUES, or a WITH query) in a READ ONLY transaction that is always rolled back, so nothing it
|
|
30
|
+
* does, settings included, outlives it. Postgres refuses any write in it, and the statement cannot leave it.
|
|
31
|
+
*/
|
|
32
|
+
const runReadOnlyQuery = (db, statement, options = {}) => run(db, statement, options, "begin transaction read only", () => "rollback");
|
|
33
|
+
/**
|
|
34
|
+
* Runs one INSERT, UPDATE or DELETE with a RETURNING clause (or a query) in a READ WRITE transaction, committed when it
|
|
35
|
+
* succeeds. Only call it for a statement the user approved.
|
|
36
|
+
*/
|
|
37
|
+
const runReadWriteQuery = (db, statement, options = {}) => run(db, statement, options, "begin transaction read write", (succeeded) => succeeded ? "commit" : "rollback");
|
|
38
|
+
//#endregion
|
|
39
|
+
export { runReadOnlyQuery, runReadWriteQuery };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Session } from "../types.js";
|
|
2
|
+
import { Conversation } from "./conversations.js";
|
|
3
|
+
import { ApprovalAnswer, ApprovalRequest } from "./approval.js";
|
|
4
|
+
import { AssistantPart } from "@protobase/schema";
|
|
5
|
+
//#region src/assistant/tools.d.ts
|
|
6
|
+
export type ToolContext = {
|
|
7
|
+
session: Session;
|
|
8
|
+
conversation: Conversation;
|
|
9
|
+
/** Adds a part, such as a table of results, to the assistant message the turn is writing. */
|
|
10
|
+
show: (part: AssistantPart) => void;
|
|
11
|
+
/** Asks the user to approve with a card in that message (see `requestApproval`). */
|
|
12
|
+
approve: (request: ApprovalRequest) => Promise<ApprovalAnswer>;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* A function the model may call during a turn. `parameters` is the JSON Schema of its arguments; `run` gets them
|
|
16
|
+
* parsed, but not checked against the schema, and returns what the model reads as the result.
|
|
17
|
+
*/
|
|
18
|
+
export type AssistantTool = {
|
|
19
|
+
name: string;
|
|
20
|
+
description: string;
|
|
21
|
+
parameters: Record<string, unknown>;
|
|
22
|
+
run: (args: Record<string, unknown>, context: ToolContext) => Promise<string>;
|
|
23
|
+
};
|
|
24
|
+
/** Typed identity, for a tool of your own. */
|
|
25
|
+
export declare const defineTool: (tool: AssistantTool) => AssistantTool;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { ModelSettings } from "./assistant-settings.js";
|
|
2
|
+
import { Session } from "../types.js";
|
|
3
|
+
import { ReasoningEffort } from "./chat-completions.js";
|
|
4
|
+
import { Conversation } from "./conversations.js";
|
|
5
|
+
import { AssistantTool } from "./tools.js";
|
|
6
|
+
//#region src/assistant/turn.d.ts
|
|
7
|
+
export type TurnOptions = {
|
|
8
|
+
model: ModelSettings;
|
|
9
|
+
conversation: Conversation;
|
|
10
|
+
session: Session;
|
|
11
|
+
/** The system prompt. */
|
|
12
|
+
system: string;
|
|
13
|
+
tools?: AssistantTool[];
|
|
14
|
+
/** Model calls in one turn before it stops. Default 8. */
|
|
15
|
+
maxSteps?: number;
|
|
16
|
+
/** Gets errors other than the model endpoint's refusals, which the user only sees as a failure. */
|
|
17
|
+
report?: (error: unknown) => void;
|
|
18
|
+
/** Text the model reads before the user's message, such as the page they are on; the transcript keeps it with the message. */
|
|
19
|
+
context?: string;
|
|
20
|
+
/** How much a reasoning model thinks before it answers (OpenRouter's `reasoning.effort`); the model's default without it. */
|
|
21
|
+
reasoning?: ReasoningEffort;
|
|
22
|
+
/**
|
|
23
|
+
* Marks prompt-cache breakpoints (`cache_control`) on the system prompt, the transcript before this turn and the
|
|
24
|
+
* latest message, so an endpoint that caches on them (OpenRouter, Anthropic) bills the repeated prefix as a cache read.
|
|
25
|
+
* Off by default: an endpoint that does not know the field may refuse it.
|
|
26
|
+
*/
|
|
27
|
+
cache?: boolean;
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
|
|
31
|
+
* tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
|
|
32
|
+
* one assistant message, beside the parts tools show; a failure ends the turn with a danger card. The transcript keeps
|
|
33
|
+
* every message as the model read it, so the next turn's request starts with the same prefix; the turn ends by saving
|
|
34
|
+
* the conversation.
|
|
35
|
+
*/
|
|
36
|
+
export declare const runTurn: (options: TurnOptions, text: string) => Promise<void>;
|
|
37
|
+
//#endregion
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { requestApproval } from "./approval.js";
|
|
2
|
+
import { withCacheBreakpoints } from "./cache-breakpoints.js";
|
|
3
|
+
import { ModelError, streamChatCompletion } from "./chat-completions.js";
|
|
4
|
+
//#region src/assistant/turn.ts
|
|
5
|
+
const toolResult = async (call, tools, context) => {
|
|
6
|
+
const tool = tools.find((entry) => entry.name === call.function.name);
|
|
7
|
+
if (!tool) return `There is no tool named "${call.function.name}".`;
|
|
8
|
+
const args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
9
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return "The arguments must be a JSON object.";
|
|
10
|
+
return tool.run(args, context);
|
|
11
|
+
};
|
|
12
|
+
const failure = (error, report) => {
|
|
13
|
+
if (error instanceof ModelError) return error.message;
|
|
14
|
+
if (error instanceof SyntaxError) return "The model answered with JSON that could not be read.";
|
|
15
|
+
report?.(error);
|
|
16
|
+
return "The server failed to answer.";
|
|
17
|
+
};
|
|
18
|
+
const marked = (messages, before, cache) => cache ? withCacheBreakpoints(messages, [
|
|
19
|
+
0,
|
|
20
|
+
before,
|
|
21
|
+
messages.length - 1
|
|
22
|
+
]) : messages;
|
|
23
|
+
/**
|
|
24
|
+
* One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
|
|
25
|
+
* tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
|
|
26
|
+
* one assistant message, beside the parts tools show; a failure ends the turn with a danger card. The transcript keeps
|
|
27
|
+
* every message as the model read it, so the next turn's request starts with the same prefix; the turn ends by saving
|
|
28
|
+
* the conversation.
|
|
29
|
+
*/
|
|
30
|
+
const runTurn = async (options, text) => {
|
|
31
|
+
const { model, conversation, session, system, tools = [], maxSteps = 8, reasoning, cache = false } = options;
|
|
32
|
+
const messageId = crypto.randomUUID();
|
|
33
|
+
const show = (part) => conversation.apply({
|
|
34
|
+
type: "part",
|
|
35
|
+
messageId,
|
|
36
|
+
part
|
|
37
|
+
});
|
|
38
|
+
const context = {
|
|
39
|
+
session,
|
|
40
|
+
conversation,
|
|
41
|
+
show,
|
|
42
|
+
approve: (request) => requestApproval(conversation, messageId, request)
|
|
43
|
+
};
|
|
44
|
+
const specs = tools.map((tool) => ({
|
|
45
|
+
type: "function",
|
|
46
|
+
function: {
|
|
47
|
+
name: tool.name,
|
|
48
|
+
description: tool.description,
|
|
49
|
+
parameters: tool.parameters
|
|
50
|
+
}
|
|
51
|
+
}));
|
|
52
|
+
conversation.apply({
|
|
53
|
+
type: "message",
|
|
54
|
+
message: {
|
|
55
|
+
id: crypto.randomUUID(),
|
|
56
|
+
from: "user",
|
|
57
|
+
parts: [{
|
|
58
|
+
type: "text",
|
|
59
|
+
id: crypto.randomUUID(),
|
|
60
|
+
text
|
|
61
|
+
}]
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
conversation.apply({
|
|
65
|
+
type: "patch",
|
|
66
|
+
replying: true
|
|
67
|
+
});
|
|
68
|
+
conversation.remember({
|
|
69
|
+
role: "user",
|
|
70
|
+
content: options.context ? `${options.context}\n\n${text}` : text
|
|
71
|
+
});
|
|
72
|
+
const earlier = [{
|
|
73
|
+
role: "system",
|
|
74
|
+
content: system
|
|
75
|
+
}, ...conversation.transcript()];
|
|
76
|
+
const added = [];
|
|
77
|
+
const steps = async () => {
|
|
78
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
79
|
+
const part = {
|
|
80
|
+
type: "text",
|
|
81
|
+
id: `${messageId}-${step}`,
|
|
82
|
+
text: ""
|
|
83
|
+
};
|
|
84
|
+
const { content, toolCalls, reasoningDetails } = await streamChatCompletion(model, marked([...earlier, ...added], earlier.length - 2, cache), (piece) => {
|
|
85
|
+
part.text += piece;
|
|
86
|
+
show({ ...part });
|
|
87
|
+
}, {
|
|
88
|
+
tools: specs,
|
|
89
|
+
reasoning
|
|
90
|
+
});
|
|
91
|
+
added.push({
|
|
92
|
+
role: "assistant",
|
|
93
|
+
content: content || null,
|
|
94
|
+
...toolCalls.length > 0 && { tool_calls: toolCalls },
|
|
95
|
+
...reasoningDetails.length > 0 && { reasoning_details: reasoningDetails }
|
|
96
|
+
});
|
|
97
|
+
if (toolCalls.length === 0) return;
|
|
98
|
+
for (const call of toolCalls) added.push({
|
|
99
|
+
role: "tool",
|
|
100
|
+
tool_call_id: call.id,
|
|
101
|
+
content: await toolResult(call, tools, context)
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
show({
|
|
105
|
+
type: "text",
|
|
106
|
+
id: `${messageId}-stopped`,
|
|
107
|
+
text: `Stopped after ${maxSteps} steps.`
|
|
108
|
+
});
|
|
109
|
+
};
|
|
110
|
+
await steps().then(() => conversation.remember(...added), (error) => show({
|
|
111
|
+
type: "card",
|
|
112
|
+
id: `${messageId}-error`,
|
|
113
|
+
title: "The assistant could not answer",
|
|
114
|
+
tone: "danger",
|
|
115
|
+
body: failure(error, options.report)
|
|
116
|
+
}));
|
|
117
|
+
await conversation.save().finally(() => conversation.apply({
|
|
118
|
+
type: "patch",
|
|
119
|
+
replying: false
|
|
120
|
+
}));
|
|
121
|
+
};
|
|
122
|
+
//#endregion
|
|
123
|
+
export { runTurn };
|
package/dist/create-admin.js
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
|
+
import { assistantSettings } from "./assistant/assistant-settings.js";
|
|
2
|
+
import { createErrorHandler } from "./error-handler.js";
|
|
3
|
+
import { builtInAssistant } from "./assistant/built-in-assistant.js";
|
|
1
4
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
2
5
|
import { authBasePath } from "./better-auth/create-auth.js";
|
|
3
6
|
import { statusRoute } from "./better-auth/status-route.js";
|
|
4
7
|
import { checkShell } from "./check-shell.js";
|
|
5
8
|
import { checkViews } from "./check-views.js";
|
|
6
|
-
import { createErrorHandler } from "./error-handler.js";
|
|
7
9
|
import { createMeta } from "./meta.js";
|
|
8
10
|
import { buildRegistry } from "./registry.js";
|
|
9
11
|
import { batchRoute } from "./routes/batch.js";
|
|
@@ -28,6 +30,8 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
|
|
|
28
30
|
models: Object.fromEntries(registry.entries.map((entry) => [entry.name, entry.model])),
|
|
29
31
|
actions: (resource) => new Set(viewModels.filter((view) => view.resource === resource).flatMap((view) => view.actions.map((action) => action.name)))
|
|
30
32
|
});
|
|
33
|
+
const systemPath = basePath.slice(0, basePath.lastIndexOf("/"));
|
|
34
|
+
const assistant = assistantSettings(options.assistant, globalThis.process?.env ?? {});
|
|
31
35
|
const deps = {
|
|
32
36
|
db,
|
|
33
37
|
registry,
|
|
@@ -35,7 +39,7 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
|
|
|
35
39
|
pages: pageModels,
|
|
36
40
|
...userMenuModel && { userMenu: userMenuModel },
|
|
37
41
|
basePath,
|
|
38
|
-
systemPath
|
|
42
|
+
systemPath,
|
|
39
43
|
statementTimeoutMs: options.statementTimeoutMs ?? 15e3,
|
|
40
44
|
guard: createScanGuard(db, options.scanGuard),
|
|
41
45
|
hooks: options.writeHooks ?? [],
|
|
@@ -44,7 +48,8 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
|
|
|
44
48
|
...options.roles && { roles: options.roles },
|
|
45
49
|
...options.defaultAccess && { defaultAccess: options.defaultAccess }
|
|
46
50
|
},
|
|
47
|
-
rowPermissions: (resource) => typeof options.rowPermissions === "object" ? !options.rowPermissions.except.includes(resource) : options.rowPermissions !== false
|
|
51
|
+
rowPermissions: (resource) => typeof options.rowPermissions === "object" ? !options.rowPermissions.except.includes(resource) : options.rowPermissions !== false,
|
|
52
|
+
...assistant && { assistant: assistant.kind === "external" ? assistant.url : `${systemPath}/assistant` }
|
|
48
53
|
};
|
|
49
54
|
const meta = createMeta(deps);
|
|
50
55
|
const onError = createErrorHandler(options.onUnhandledError);
|
|
@@ -76,6 +81,14 @@ const createAdmin = ({ resources, views = [], pages = [], userMenu, db, authenti
|
|
|
76
81
|
app.route(authBasePath, statusRoute(auth));
|
|
77
82
|
app.on(["GET", "POST"], `${authBasePath}/*`, (c) => auth.handler(c.req.raw));
|
|
78
83
|
}
|
|
84
|
+
if (assistant?.kind === "built-in") app.route(`${systemPath}/assistant`, builtInAssistant({
|
|
85
|
+
model: assistant.model,
|
|
86
|
+
chats: assistant.chats,
|
|
87
|
+
db,
|
|
88
|
+
meta,
|
|
89
|
+
authenticate,
|
|
90
|
+
report: options.onUnhandledError
|
|
91
|
+
}));
|
|
79
92
|
app.route("/", batchRoute(deps, authenticate, meta));
|
|
80
93
|
app.route(basePath, api);
|
|
81
94
|
app.route(deps.systemPath || "/", system);
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AdminAuth, CreateAuthOptions, UserCreatedHook, createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
2
2
|
import { ResourceSource, configExports } from "./resource-source.js";
|
|
3
|
+
import { AssistantOptions, AssistantSettings, ModelSettings, assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
|
|
3
4
|
import { Admin, AdminOptions, AuditEvent, AuditQueue, Authenticator, PipelineHook, ScanGuardMode, Session, TenantValue, User, ViewSource, WriteEvent, WriteOperation } from "./types.js";
|
|
4
5
|
import { CreateAdminInput, createAdmin } from "./create-admin.js";
|
|
5
6
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
@@ -8,4 +9,15 @@ import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./pr
|
|
|
8
9
|
import { BetterAuthAuthenticatorOptions, betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
10
|
import { NewUser, StoredUser, createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
10
11
|
import { IssueTokenOptions, issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
|
|
11
|
-
|
|
12
|
+
import { ChatMessage, ChatTextPart, Completion, CompletionOptions, ModelError, ReasoningDetail, ReasoningEffort, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
13
|
+
import { ConversationRecord, ConversationStore } from "./assistant/conversation-store.js";
|
|
14
|
+
import { Conversation, ConversationsOptions, conversationKey, createConversations } from "./assistant/conversations.js";
|
|
15
|
+
import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
|
|
16
|
+
import { fileConversationStore } from "./assistant/file-conversation-store.js";
|
|
17
|
+
import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
|
|
18
|
+
import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
|
|
19
|
+
import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
|
|
20
|
+
import { TurnOptions, runTurn } from "./assistant/turn.js";
|
|
21
|
+
import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
|
|
22
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
|
|
23
|
+
export { type Admin, type AdminAuth, type AdminOptions, type ApprovalAnswer, type ApprovalRequest, type AssistantBackend, type AssistantOptions, type AssistantSettings, type AssistantTool, type AuditEvent, type AuditQueue, type Authenticator, type BetterAuthAuthenticatorOptions, type ChatMessage, type ChatTextPart, type Completion, type CompletionOptions, type Conversation, type ConversationRecord, type ConversationStore, type ConversationsOptions, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type PipelineHook, type QueryOptions, type QueryResult, type ReasoningDetail, type ReasoningEffort, type ResourceSource, type ScanGuardMode, type Session, type StoredUser, type TenantValue, type ToolCall, type ToolContext, type ToolSpec, type TurnOptions, type User, type UserCreatedHook, type ViewSource, type WriteEvent, type WriteOperation, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized, withCacheBreakpoints };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
|
|
2
|
+
import { conversationKey, createConversations } from "./assistant/conversations.js";
|
|
3
|
+
import { fileConversationStore } from "./assistant/file-conversation-store.js";
|
|
4
|
+
import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
5
|
+
import { assistantProtocolRoutes } from "./assistant/protocol-routes.js";
|
|
6
|
+
import { runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
|
|
7
|
+
import { defineTool } from "./assistant/tools.js";
|
|
8
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
|
|
9
|
+
import { requestApproval } from "./assistant/approval.js";
|
|
10
|
+
import { withCacheBreakpoints } from "./assistant/cache-breakpoints.js";
|
|
11
|
+
import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
12
|
+
import { runTurn } from "./assistant/turn.js";
|
|
1
13
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
2
14
|
import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
3
15
|
import { createUser, deleteUser, hasUsers, listUsers, parseRoles, roleChoices, setUserBanned, setUserRole } from "./better-auth/users.js";
|
|
4
|
-
import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
5
16
|
import { createAdmin } from "./create-admin.js";
|
|
6
17
|
import { configExports } from "./resource-source.js";
|
|
7
18
|
import { jwtAuthenticator } from "./auth/jwt.js";
|
|
8
19
|
import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
20
|
import { issueToken, maxTokenTtlSeconds } from "./better-auth/tokens.js";
|
|
10
|
-
export { HttpProblem, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, createAdmin, createAuth, createUser, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, roleChoices, setUserBanned, setUserRole, unauthorized };
|
|
21
|
+
export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, fileConversationStore, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized, withCacheBreakpoints };
|
package/dist/meta.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { seesAssistant } from "./assistant/assistant-settings.js";
|
|
1
2
|
import { sha256Hex, stableJson } from "./hash.js";
|
|
2
3
|
import { requestAccess } from "./request-access.js";
|
|
3
4
|
import { pruneView } from "./view-access.js";
|
|
@@ -45,7 +46,8 @@ const createMeta = (deps) => {
|
|
|
45
46
|
"delete"
|
|
46
47
|
].filter((op) => resolved.operations[op] && (resolved.recordChecks.includes(op) || resolved.rowFilter[op] !== void 0))
|
|
47
48
|
}])),
|
|
48
|
-
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } }
|
|
49
|
+
...deps.userMenu && { userMenu: { items: deps.userMenu.items.filter(shown) } },
|
|
50
|
+
...deps.assistant && seesAssistant(session.user.roles) && { assistant: { url: deps.assistant } }
|
|
49
51
|
};
|
|
50
52
|
return {
|
|
51
53
|
body,
|
package/dist/types.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { AssistantOptions } from "./assistant/assistant-settings.js";
|
|
1
2
|
import { Hono } from "hono";
|
|
2
3
|
import { PageModel } from "@protobase/layout";
|
|
3
4
|
import { ResolveOptions, ResourceModel, ViewModel } from "@protobase/schema";
|
|
@@ -86,6 +87,8 @@ export type AdminOptions = {
|
|
|
86
87
|
audit?: AuditQueue;
|
|
87
88
|
/** Called with every error the API turns into a 500, so the host can report it. */
|
|
88
89
|
onUnhandledError?: (error: unknown) => void;
|
|
90
|
+
/** The assistant in the admin app's dock: a backend elsewhere or the built-in one (see `AssistantOptions`); `false` turns it off. */
|
|
91
|
+
assistant?: AssistantOptions | false;
|
|
89
92
|
};
|
|
90
93
|
export type ViewSource = {
|
|
91
94
|
toModel(): ViewModel;
|