@protobase/server 0.1.5 → 0.2.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 +43 -0
- package/dist/assistant/assistant-settings.js +51 -0
- package/dist/assistant/built-in-assistant.js +35 -0
- package/dist/assistant/chat-completions.d.ts +50 -0
- package/dist/assistant/chat-completions.js +87 -0
- package/dist/assistant/conversations.d.ts +24 -0
- package/dist/assistant/conversations.js +50 -0
- package/dist/assistant/protocol-routes.d.ts +27 -0
- package/dist/assistant/protocol-routes.js +73 -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 +26 -0
- package/dist/assistant/turn.js +117 -0
- package/dist/create-admin.js +15 -3
- package/dist/index.d.ts +10 -1
- package/dist/index.js +11 -2
- package/dist/meta.js +3 -1
- package/dist/types.d.ts +3 -0
- package/package.json +4 -4
|
@@ -5,8 +5,8 @@ import { readTransaction } from "./transactions.js";
|
|
|
5
5
|
import { columnId, filterCondition, scopeConditions, tableId, whereClause } from "./sql-parts.js";
|
|
6
6
|
import { parseParams } from "./params.js";
|
|
7
7
|
import { facetCounts, histogram, series } from "@protobase/query";
|
|
8
|
-
import { z } from "zod";
|
|
9
8
|
import { sql } from "kysely";
|
|
9
|
+
import { z } from "zod";
|
|
10
10
|
//#region src/aggregate-service.ts
|
|
11
11
|
const field = z.string().min(1);
|
|
12
12
|
const filter = z.string().optional();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//#region src/assistant/app-context.ts
|
|
2
|
+
const fieldLine = (field, view) => {
|
|
3
|
+
const label = view?.fields[field.name]?.label;
|
|
4
|
+
const traits = [
|
|
5
|
+
field.type === "enum" && field.enumValues ? `one of ${field.enumValues.join(", ")}` : field.type,
|
|
6
|
+
field.relation && `links to ${field.relation.resource}`,
|
|
7
|
+
field.nullable && "optional",
|
|
8
|
+
field.readOnly && "read-only"
|
|
9
|
+
].filter(Boolean);
|
|
10
|
+
return ` - ${field.name}${field.column === field.name ? "" : ` (column ${field.column})`}${label ? `, "${label}"` : ""}: ${traits.join(", ")}`;
|
|
11
|
+
};
|
|
12
|
+
const tableName = (model) => model.table.schema ? `${model.table.schema}.${model.table.name}` : model.table.name;
|
|
13
|
+
const resourceBlock = (model, view) => {
|
|
14
|
+
const names = view?.names ? `, "${view.names.plural}"` : "";
|
|
15
|
+
const help = view?.help ? `\n ${view.help}` : "";
|
|
16
|
+
return `- ${model.name} (table ${tableName(model)}${names})${help}\n${Object.values(model.fields).map((field) => fieldLine(field, view)).join("\n")}`;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The built-in assistant's system prompt: what the app is, from what the caller may see of it in `/meta` (their
|
|
20
|
+
* restricted models and views), and how to use the query tools.
|
|
21
|
+
*/
|
|
22
|
+
const appContext = (resources, views) => [
|
|
23
|
+
"You are the assistant inside an admin app built with Protobase, on a PostgreSQL database. You answer questions about the app and its data.",
|
|
24
|
+
"To look at the data, call run_read_only_query with one SELECT; the user sees its rows as a table, so do not repeat them all. To change data, and only when the user asks for a change, call propose_write_query: the user has to approve the statement before it runs. Never claim a change ran unless the tool returned its rows.",
|
|
25
|
+
"Answer briefly, in plain text.",
|
|
26
|
+
resources.length === 0 ? "The user can see no resources." : `The resources the user can see, with their tables and fields:\n${resources.map((model) => resourceBlock(model, views.find((view) => view.resource === model.name))).join("\n")}`
|
|
27
|
+
].join("\n\n");
|
|
28
|
+
//#endregion
|
|
29
|
+
export { appContext };
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Conversation } from "./conversations.js";
|
|
2
|
+
import { AssistantTone } from "@protobase/schema";
|
|
3
|
+
//#region src/assistant/approval.d.ts
|
|
4
|
+
export type ApprovalRequest = {
|
|
5
|
+
title: string;
|
|
6
|
+
body?: string;
|
|
7
|
+
/** Monospace text, such as the statement that runs on approval. */
|
|
8
|
+
code?: string;
|
|
9
|
+
fields?: Array<{
|
|
10
|
+
label: string;
|
|
11
|
+
value: string;
|
|
12
|
+
}>;
|
|
13
|
+
tone?: AssistantTone;
|
|
14
|
+
badge?: string;
|
|
15
|
+
approveLabel?: string;
|
|
16
|
+
rejectLabel?: string;
|
|
17
|
+
/** Without an answer by then, the request is `expired`. Default 10 minutes. */
|
|
18
|
+
timeoutMs?: number;
|
|
19
|
+
};
|
|
20
|
+
export type ApprovalAnswer = 'approved' | 'rejected' | 'expired';
|
|
21
|
+
/**
|
|
22
|
+
* Shows a card with Approve and Reject buttons in the assistant message `messageId` and resolves with the user's
|
|
23
|
+
* answer, or `expired` after the timeout. The card then loses its buttons and says what was answered.
|
|
24
|
+
*/
|
|
25
|
+
export declare const requestApproval: (conversation: Conversation, messageId: string, request: ApprovalRequest) => Promise<ApprovalAnswer>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
//#region src/assistant/approval.ts
|
|
2
|
+
const notes = {
|
|
3
|
+
approved: "Approved.",
|
|
4
|
+
rejected: "Rejected. Nothing ran.",
|
|
5
|
+
expired: "Not answered in time. Nothing ran."
|
|
6
|
+
};
|
|
7
|
+
/**
|
|
8
|
+
* Shows a card with Approve and Reject buttons in the assistant message `messageId` and resolves with the user's
|
|
9
|
+
* answer, or `expired` after the timeout. The card then loses its buttons and says what was answered.
|
|
10
|
+
*/
|
|
11
|
+
const requestApproval = (conversation, messageId, request) => {
|
|
12
|
+
const card = {
|
|
13
|
+
type: "card",
|
|
14
|
+
id: crypto.randomUUID(),
|
|
15
|
+
title: request.title,
|
|
16
|
+
tone: request.tone ?? "warning",
|
|
17
|
+
...request.badge && { badge: request.badge },
|
|
18
|
+
...request.body && { body: request.body },
|
|
19
|
+
...request.code && { code: request.code },
|
|
20
|
+
...request.fields && { fields: request.fields }
|
|
21
|
+
};
|
|
22
|
+
conversation.apply({
|
|
23
|
+
type: "part",
|
|
24
|
+
messageId,
|
|
25
|
+
part: {
|
|
26
|
+
...card,
|
|
27
|
+
actions: [{
|
|
28
|
+
id: "approve",
|
|
29
|
+
label: request.approveLabel ?? "Approve",
|
|
30
|
+
style: "primary"
|
|
31
|
+
}, {
|
|
32
|
+
id: "reject",
|
|
33
|
+
label: request.rejectLabel ?? "Reject"
|
|
34
|
+
}]
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
return new Promise((resolve) => {
|
|
38
|
+
const answer = (value) => {
|
|
39
|
+
clearTimeout(timer);
|
|
40
|
+
stop();
|
|
41
|
+
conversation.apply({
|
|
42
|
+
type: "part",
|
|
43
|
+
messageId,
|
|
44
|
+
part: {
|
|
45
|
+
...card,
|
|
46
|
+
note: notes[value]
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
resolve(value);
|
|
50
|
+
};
|
|
51
|
+
const timer = setTimeout(() => answer("expired"), request.timeoutMs ?? 6e5);
|
|
52
|
+
const stop = conversation.onAction(card.id, (actionId) => {
|
|
53
|
+
if (actionId === "approve" || actionId === "reject") answer(actionId === "approve" ? "approved" : "rejected");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
};
|
|
57
|
+
//#endregion
|
|
58
|
+
export { requestApproval };
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
//#region src/assistant/assistant-settings.d.ts
|
|
2
|
+
/** An OpenAI-compatible chat completions endpoint for the built-in assistant. */
|
|
3
|
+
export type ModelSettings = {
|
|
4
|
+
baseUrl: string;
|
|
5
|
+
apiKey: string;
|
|
6
|
+
model: string;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* `url`: an assistant backend elsewhere, which the dock talks to instead of the built-in one.
|
|
11
|
+
* `apiKey`, `model`, `baseUrl`: the built-in assistant, which answers questions about the app with a chat model.
|
|
12
|
+
* Each falls back to its environment variable; `fetch` replaces the model endpoint, for tests.
|
|
13
|
+
*/
|
|
14
|
+
export type AssistantOptions = {
|
|
15
|
+
url?: string;
|
|
16
|
+
apiKey?: string;
|
|
17
|
+
model?: string;
|
|
18
|
+
baseUrl?: string;
|
|
19
|
+
fetch?: typeof fetch;
|
|
20
|
+
};
|
|
21
|
+
export type AssistantSettings = {
|
|
22
|
+
kind: 'external';
|
|
23
|
+
url: string;
|
|
24
|
+
} | {
|
|
25
|
+
kind: 'built-in';
|
|
26
|
+
model: ModelSettings;
|
|
27
|
+
};
|
|
28
|
+
export declare const assistantVariables: {
|
|
29
|
+
readonly url: "PROTOBASE_ASSISTANT_URL";
|
|
30
|
+
readonly apiKey: "PROTOBASE_ASSISTANT_API_KEY";
|
|
31
|
+
readonly model: "PROTOBASE_ASSISTANT_MODEL";
|
|
32
|
+
readonly baseUrl: "PROTOBASE_ASSISTANT_BASE_URL";
|
|
33
|
+
};
|
|
34
|
+
/** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
|
|
35
|
+
export declare const assistantRoles: readonly ["admin", "ai"];
|
|
36
|
+
export declare const seesAssistant: (roles: readonly string[]) => boolean;
|
|
37
|
+
/**
|
|
38
|
+
* Which assistant the app has: the backend at `url` when one is set, otherwise the built-in one when an API key is
|
|
39
|
+
* set, otherwise none (also when the option is `false`). The built-in one calls OpenRouter unless `baseUrl` names
|
|
40
|
+
* another OpenAI-compatible endpoint, which then needs a `model`. A value that cannot work stops the server at startup.
|
|
41
|
+
*/
|
|
42
|
+
export declare const assistantSettings: (option: AssistantOptions | false | undefined, env: Record<string, string | undefined>) => AssistantSettings | undefined;
|
|
43
|
+
//#endregion
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
//#region src/assistant/assistant-settings.ts
|
|
2
|
+
const assistantVariables = {
|
|
3
|
+
url: "PROTOBASE_ASSISTANT_URL",
|
|
4
|
+
apiKey: "PROTOBASE_ASSISTANT_API_KEY",
|
|
5
|
+
model: "PROTOBASE_ASSISTANT_MODEL",
|
|
6
|
+
baseUrl: "PROTOBASE_ASSISTANT_BASE_URL"
|
|
7
|
+
};
|
|
8
|
+
const openRouterUrl = "https://openrouter.ai/api/v1";
|
|
9
|
+
const openRouterModel = "openrouter/auto";
|
|
10
|
+
/** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
|
|
11
|
+
const assistantRoles = ["admin", "ai"];
|
|
12
|
+
const seesAssistant = (roles) => assistantRoles.some((role) => roles.includes(role));
|
|
13
|
+
const checkedUrl = (url, source) => {
|
|
14
|
+
if (!URL.canParse(url)) throw new Error(`${source} is not a URL; use https://...`);
|
|
15
|
+
const { protocol } = new URL(url);
|
|
16
|
+
if (protocol !== "https:" && protocol !== "http:") throw new Error(`${source} must start with https:// or http://, not ${protocol}//`);
|
|
17
|
+
return url.replace(/\/+$/, "");
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Which assistant the app has: the backend at `url` when one is set, otherwise the built-in one when an API key is
|
|
21
|
+
* set, otherwise none (also when the option is `false`). The built-in one calls OpenRouter unless `baseUrl` names
|
|
22
|
+
* another OpenAI-compatible endpoint, which then needs a `model`. A value that cannot work stops the server at startup.
|
|
23
|
+
*/
|
|
24
|
+
const assistantSettings = (option, env) => {
|
|
25
|
+
if (option === false) return void 0;
|
|
26
|
+
const value = (key) => {
|
|
27
|
+
return (option?.[key] ?? env[assistantVariables[key]])?.trim() || void 0;
|
|
28
|
+
};
|
|
29
|
+
const source = (key) => option?.[key] === void 0 ? assistantVariables[key] : `options.assistant.${key}`;
|
|
30
|
+
const url = value("url");
|
|
31
|
+
if (url) return {
|
|
32
|
+
kind: "external",
|
|
33
|
+
url: checkedUrl(url, source("url"))
|
|
34
|
+
};
|
|
35
|
+
const apiKey = value("apiKey");
|
|
36
|
+
if (!apiKey) return void 0;
|
|
37
|
+
const baseUrl = checkedUrl(value("baseUrl") ?? "https://openrouter.ai/api/v1", source("baseUrl"));
|
|
38
|
+
const model = value("model") ?? (baseUrl === "https://openrouter.ai/api/v1" ? "openrouter/auto" : void 0);
|
|
39
|
+
if (!model) throw new Error(`${assistantVariables.model} (or options.assistant.model) is required with an endpoint other than OpenRouter's`);
|
|
40
|
+
return {
|
|
41
|
+
kind: "built-in",
|
|
42
|
+
model: {
|
|
43
|
+
baseUrl,
|
|
44
|
+
apiKey,
|
|
45
|
+
model,
|
|
46
|
+
...option && option.fetch && { fetch: option.fetch }
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
//#endregion
|
|
51
|
+
export { assistantRoles, assistantSettings, assistantVariables, openRouterModel, openRouterUrl, seesAssistant };
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { seesAssistant } from "./assistant-settings.js";
|
|
2
|
+
import { appContext } from "./app-context.js";
|
|
3
|
+
import { conversationKey, createConversations } from "./conversations.js";
|
|
4
|
+
import { assistantProtocolRoutes } from "./protocol-routes.js";
|
|
5
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./query-tools.js";
|
|
6
|
+
import { runTurn } from "./turn.js";
|
|
7
|
+
//#region src/assistant/built-in-assistant.ts
|
|
8
|
+
/**
|
|
9
|
+
* Protobase's own assistant: answers questions about the app, reads its data with read-only queries and proposes
|
|
10
|
+
* changes the user approves, for callers with the `admin` or `ai` role. Made only of the exported building blocks and
|
|
11
|
+
* its prompt, which describes what the caller may see of the app.
|
|
12
|
+
*/
|
|
13
|
+
const builtInAssistant = ({ model, db, meta, authenticate, report }) => {
|
|
14
|
+
const conversations = createConversations();
|
|
15
|
+
const tools = [readOnlyQueryTool(db), readWriteQueryTool(db)];
|
|
16
|
+
return assistantProtocolRoutes({
|
|
17
|
+
authenticate,
|
|
18
|
+
allows: (session) => seesAssistant(session.user.roles),
|
|
19
|
+
conversationOf: (session) => conversations(conversationKey(session)),
|
|
20
|
+
report,
|
|
21
|
+
onMessage: async (text, session, conversation) => {
|
|
22
|
+
const { body } = await meta.forCaller(session);
|
|
23
|
+
await runTurn({
|
|
24
|
+
model,
|
|
25
|
+
conversation,
|
|
26
|
+
session,
|
|
27
|
+
system: appContext(body.resources, body.views),
|
|
28
|
+
tools,
|
|
29
|
+
report
|
|
30
|
+
}, text);
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
};
|
|
34
|
+
//#endregion
|
|
35
|
+
export { builtInAssistant };
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ModelSettings } from "./assistant-settings.js";
|
|
2
|
+
//#region src/assistant/chat-completions.d.ts
|
|
3
|
+
export type ToolCall = {
|
|
4
|
+
id: string;
|
|
5
|
+
type: 'function';
|
|
6
|
+
function: {
|
|
7
|
+
name: string;
|
|
8
|
+
arguments: string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** A message of a chat completion: what the model reads, and the tool calls it made with their results. */
|
|
12
|
+
export type ChatMessage = {
|
|
13
|
+
role: 'system' | 'user';
|
|
14
|
+
content: string;
|
|
15
|
+
} | {
|
|
16
|
+
role: 'assistant';
|
|
17
|
+
content: string | null;
|
|
18
|
+
tool_calls?: ToolCall[];
|
|
19
|
+
} | {
|
|
20
|
+
role: 'tool';
|
|
21
|
+
tool_call_id: string;
|
|
22
|
+
content: string;
|
|
23
|
+
};
|
|
24
|
+
/** A tool as the endpoint's `tools` field describes it: a function with JSON Schema parameters. */
|
|
25
|
+
export type ToolSpec = {
|
|
26
|
+
type: 'function';
|
|
27
|
+
function: {
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
parameters: Record<string, unknown>;
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
export type Completion = {
|
|
34
|
+
content: string;
|
|
35
|
+
toolCalls: ToolCall[];
|
|
36
|
+
};
|
|
37
|
+
/** A failed call to the model endpoint, with a message an admin can act on. */
|
|
38
|
+
export declare class ModelError extends Error {
|
|
39
|
+
name: string;
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
|
|
43
|
+
* offering `tools` and calling `onText` with each piece of the answer. Resolves with the whole answer and the tool
|
|
44
|
+
* calls the model made; rejects with a `ModelError`.
|
|
45
|
+
*/
|
|
46
|
+
export declare const streamChatCompletion: (settings: ModelSettings, messages: ChatMessage[], onText: (text: string) => void, options?: {
|
|
47
|
+
tools?: ToolSpec[];
|
|
48
|
+
signal?: AbortSignal;
|
|
49
|
+
}) => Promise<Completion>;
|
|
50
|
+
//#endregion
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { readEventStream } from "@protobase/schema";
|
|
2
|
+
//#region src/assistant/chat-completions.ts
|
|
3
|
+
/** A failed call to the model endpoint, with a message an admin can act on. */
|
|
4
|
+
var ModelError = class extends Error {
|
|
5
|
+
name = "ModelError";
|
|
6
|
+
};
|
|
7
|
+
const errorText = async (response) => {
|
|
8
|
+
const text = await response.text();
|
|
9
|
+
if (!text) return void 0;
|
|
10
|
+
if (!response.headers.get("content-type")?.includes("json")) return text.slice(0, 300);
|
|
11
|
+
const body = JSON.parse(text);
|
|
12
|
+
const message = typeof body.error === "string" ? body.error : body.error?.message ?? body.message;
|
|
13
|
+
return typeof message === "string" ? message : void 0;
|
|
14
|
+
};
|
|
15
|
+
const statusHints = {
|
|
16
|
+
401: "check the API key",
|
|
17
|
+
402: "the account behind the API key is out of credit",
|
|
18
|
+
403: "the API key may not use this model",
|
|
19
|
+
404: "check the base URL and the model",
|
|
20
|
+
429: "the endpoint is rate limiting; try again shortly"
|
|
21
|
+
};
|
|
22
|
+
const refusal = async (response, settings) => {
|
|
23
|
+
const detail = await errorText(response);
|
|
24
|
+
const hint = statusHints[response.status];
|
|
25
|
+
return new ModelError(`The model endpoint ${settings.baseUrl} answered ${response.status}${detail ? `: ${detail}` : ""}${hint ? ` (${hint})` : ""}`);
|
|
26
|
+
};
|
|
27
|
+
const send = (settings, messages, tools, signal) => {
|
|
28
|
+
return (settings.fetch ?? fetch)(`${settings.baseUrl}/chat/completions`, {
|
|
29
|
+
method: "POST",
|
|
30
|
+
headers: {
|
|
31
|
+
"content-type": "application/json",
|
|
32
|
+
accept: "text/event-stream",
|
|
33
|
+
authorization: `Bearer ${settings.apiKey}`
|
|
34
|
+
},
|
|
35
|
+
body: JSON.stringify({
|
|
36
|
+
model: settings.model,
|
|
37
|
+
messages,
|
|
38
|
+
stream: true,
|
|
39
|
+
...tools.length > 0 && { tools }
|
|
40
|
+
}),
|
|
41
|
+
signal
|
|
42
|
+
}).catch((error) => {
|
|
43
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
44
|
+
throw new ModelError(`Could not reach the model endpoint ${settings.baseUrl}`, { cause: error });
|
|
45
|
+
});
|
|
46
|
+
};
|
|
47
|
+
const addToolCall = (calls, delta) => {
|
|
48
|
+
const call = calls[delta.index] ??= {
|
|
49
|
+
id: "",
|
|
50
|
+
type: "function",
|
|
51
|
+
function: {
|
|
52
|
+
name: "",
|
|
53
|
+
arguments: ""
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
if (delta.id) call.id = delta.id;
|
|
57
|
+
if (delta.function?.name) call.function.name += delta.function.name;
|
|
58
|
+
if (delta.function?.arguments) call.function.arguments += delta.function.arguments;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
|
|
62
|
+
* offering `tools` and calling `onText` with each piece of the answer. Resolves with the whole answer and the tool
|
|
63
|
+
* calls the model made; rejects with a `ModelError`.
|
|
64
|
+
*/
|
|
65
|
+
const streamChatCompletion = async (settings, messages, onText, options = {}) => {
|
|
66
|
+
const response = await send(settings, messages, options.tools ?? [], options.signal);
|
|
67
|
+
if (!response.ok) throw await refusal(response, settings);
|
|
68
|
+
if (!response.body) throw new ModelError(`The model endpoint ${settings.baseUrl} answered without a body`);
|
|
69
|
+
let content = "";
|
|
70
|
+
const toolCalls = [];
|
|
71
|
+
await readEventStream(response.body, (data) => {
|
|
72
|
+
if (data === "[DONE]") return;
|
|
73
|
+
const chunk = JSON.parse(data);
|
|
74
|
+
if (chunk.error) throw new ModelError(`The model endpoint ${settings.baseUrl} stopped: ${chunk.error.message ?? "unknown error"}`);
|
|
75
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
76
|
+
for (const call of delta?.tool_calls ?? []) addToolCall(toolCalls, call);
|
|
77
|
+
if (!delta?.content) return;
|
|
78
|
+
content += delta.content;
|
|
79
|
+
onText(delta.content);
|
|
80
|
+
});
|
|
81
|
+
return {
|
|
82
|
+
content,
|
|
83
|
+
toolCalls: toolCalls.filter(Boolean)
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
//#endregion
|
|
87
|
+
export { ModelError, streamChatCompletion };
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { Session } from "../types.js";
|
|
2
|
+
import { ChatMessage } from "./chat-completions.js";
|
|
3
|
+
import { AssistantEvent, AssistantState } from "@protobase/schema";
|
|
4
|
+
//#region src/assistant/conversations.d.ts
|
|
5
|
+
export type Conversation = {
|
|
6
|
+
state: () => AssistantState;
|
|
7
|
+
apply: (event: AssistantEvent) => void;
|
|
8
|
+
/** Calls `listener` with every later event until the returned function is called. */
|
|
9
|
+
subscribe: (listener: (event: AssistantEvent) => void) => () => void;
|
|
10
|
+
/** The chat as the model reads it, tool calls and their results included; turns add to it. */
|
|
11
|
+
transcript: ChatMessage[];
|
|
12
|
+
/** Calls `handler` with the action's id when a button of part `partId` is clicked, until the returned function is called. */
|
|
13
|
+
onAction: (partId: string, handler: (actionId: string) => void) => () => void;
|
|
14
|
+
/** Hands a click to the part's handler; false when no handler waits for that part. */
|
|
15
|
+
act: (partId: string, actionId: string) => boolean;
|
|
16
|
+
};
|
|
17
|
+
/** One conversation per user and tenant. */
|
|
18
|
+
export declare const conversationKey: (session: Session) => string;
|
|
19
|
+
/**
|
|
20
|
+
* Conversations in this process's memory, by key (see `conversationKey`): shared by a user's open tabs, gone on a
|
|
21
|
+
* restart, each cut to its last `keep` messages.
|
|
22
|
+
*/
|
|
23
|
+
export declare const createConversations: (keep?: number) => (key: string) => Conversation;
|
|
24
|
+
//#endregion
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { applyAssistantEvent, emptyAssistantState } from "@protobase/schema";
|
|
2
|
+
//#region src/assistant/conversations.ts
|
|
3
|
+
const createConversation = (keep) => {
|
|
4
|
+
let state = emptyAssistantState;
|
|
5
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
6
|
+
const actions = /* @__PURE__ */ new Map();
|
|
7
|
+
return {
|
|
8
|
+
state: () => state,
|
|
9
|
+
apply: (event) => {
|
|
10
|
+
const next = applyAssistantEvent(state, event);
|
|
11
|
+
state = next.messages.length > keep ? {
|
|
12
|
+
...next,
|
|
13
|
+
messages: next.messages.slice(-keep)
|
|
14
|
+
} : next;
|
|
15
|
+
for (const listener of listeners) listener(event);
|
|
16
|
+
},
|
|
17
|
+
subscribe: (listener) => {
|
|
18
|
+
listeners.add(listener);
|
|
19
|
+
return () => listeners.delete(listener);
|
|
20
|
+
},
|
|
21
|
+
transcript: [],
|
|
22
|
+
onAction: (partId, handler) => {
|
|
23
|
+
actions.set(partId, handler);
|
|
24
|
+
return () => actions.delete(partId);
|
|
25
|
+
},
|
|
26
|
+
act: (partId, actionId) => {
|
|
27
|
+
const handler = actions.get(partId);
|
|
28
|
+
handler?.(actionId);
|
|
29
|
+
return handler !== void 0;
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
};
|
|
33
|
+
/** One conversation per user and tenant. */
|
|
34
|
+
const conversationKey = (session) => JSON.stringify([session.tenant ?? null, session.user.id]);
|
|
35
|
+
/**
|
|
36
|
+
* Conversations in this process's memory, by key (see `conversationKey`): shared by a user's open tabs, gone on a
|
|
37
|
+
* restart, each cut to its last `keep` messages.
|
|
38
|
+
*/
|
|
39
|
+
const createConversations = (keep = 50) => {
|
|
40
|
+
const byKey = /* @__PURE__ */ new Map();
|
|
41
|
+
return (key) => {
|
|
42
|
+
const existing = byKey.get(key);
|
|
43
|
+
if (existing) return existing;
|
|
44
|
+
const conversation = createConversation(keep);
|
|
45
|
+
byKey.set(key, conversation);
|
|
46
|
+
return conversation;
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
//#endregion
|
|
50
|
+
export { conversationKey, createConversations };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { Authenticator, Session } from "../types.js";
|
|
2
|
+
import { Conversation } from "./conversations.js";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
//#region src/assistant/protocol-routes.d.ts
|
|
5
|
+
export type AssistantBackend = {
|
|
6
|
+
/** Resolves the caller from the request's bearer token, or throws a 401 problem. */
|
|
7
|
+
authenticate: Authenticator;
|
|
8
|
+
/** Whether the caller may use the assistant; others get 403. */
|
|
9
|
+
allows: (session: Session) => boolean;
|
|
10
|
+
conversationOf: (session: Session) => Conversation;
|
|
11
|
+
/** Answers a message of the user with events on the conversation; called only while the backend is not replying. */
|
|
12
|
+
onMessage: (text: string, session: Session, conversation: Conversation) => Promise<void>;
|
|
13
|
+
/** Gets errors of `onMessage` and of requests that become a 500. */
|
|
14
|
+
report?: (error: unknown) => void;
|
|
15
|
+
/** The longest message accepted. Default 4000. */
|
|
16
|
+
maxMessageLength?: number;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The assistant protocol's requests as a Hono app: `GET /events` (the event stream, a `state` event first),
|
|
20
|
+
* `POST /messages` and `POST /actions`. Mount it where `/api/meta` says the backend is.
|
|
21
|
+
*/
|
|
22
|
+
export declare const assistantProtocolRoutes: (backend: AssistantBackend) => Hono<{
|
|
23
|
+
Variables: {
|
|
24
|
+
session: Session;
|
|
25
|
+
};
|
|
26
|
+
}, import("hono/types").BlankSchema, "/">;
|
|
27
|
+
//#endregion
|
|
@@ -0,0 +1,73 @@
|
|
|
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 textOf = (body, max) => {
|
|
8
|
+
const text = isObject(body) ? body.text : void 0;
|
|
9
|
+
if (typeof text !== "string" || !text.trim()) throw badRequest("invalid-message", "The body must be { \"text\": \"...\" }");
|
|
10
|
+
if (text.length > max) throw badRequest("invalid-message", `A message is at most ${max} characters`);
|
|
11
|
+
return text.trim();
|
|
12
|
+
};
|
|
13
|
+
const actionOf = (body) => {
|
|
14
|
+
const { partId, actionId } = isObject(body) ? body : {};
|
|
15
|
+
if (typeof partId !== "string" || typeof actionId !== "string") throw badRequest("invalid-action", "The body must be { \"partId\": \"...\", \"actionId\": \"...\" }");
|
|
16
|
+
return {
|
|
17
|
+
partId,
|
|
18
|
+
actionId
|
|
19
|
+
};
|
|
20
|
+
};
|
|
21
|
+
/**
|
|
22
|
+
* The assistant protocol's requests as a Hono app: `GET /events` (the event stream, a `state` event first),
|
|
23
|
+
* `POST /messages` and `POST /actions`. Mount it where `/api/meta` says the backend is.
|
|
24
|
+
*/
|
|
25
|
+
const assistantProtocolRoutes = (backend) => {
|
|
26
|
+
const app = new Hono();
|
|
27
|
+
app.onError(createErrorHandler(backend.report));
|
|
28
|
+
app.use("*", async (c, next) => {
|
|
29
|
+
const session = await backend.authenticate(c.req.raw);
|
|
30
|
+
if (!backend.allows(session)) throw forbidden("assistant-forbidden", "The assistant needs the admin or ai role");
|
|
31
|
+
c.set("session", session);
|
|
32
|
+
await next();
|
|
33
|
+
});
|
|
34
|
+
app.get("/events", (c) => {
|
|
35
|
+
const conversation = backend.conversationOf(c.get("session"));
|
|
36
|
+
return streamSSE(c, async (stream) => {
|
|
37
|
+
const queue = [{
|
|
38
|
+
type: "state",
|
|
39
|
+
state: conversation.state()
|
|
40
|
+
}];
|
|
41
|
+
let wake = () => {};
|
|
42
|
+
const unsubscribe = conversation.subscribe((event) => {
|
|
43
|
+
queue.push(event);
|
|
44
|
+
wake();
|
|
45
|
+
});
|
|
46
|
+
stream.onAbort(() => {
|
|
47
|
+
unsubscribe();
|
|
48
|
+
wake();
|
|
49
|
+
});
|
|
50
|
+
while (!stream.aborted) {
|
|
51
|
+
const event = queue.shift();
|
|
52
|
+
if (event) await stream.writeSSE({ data: JSON.stringify(event) });
|
|
53
|
+
else await new Promise((resolve) => wake = resolve);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
app.post("/messages", async (c) => {
|
|
58
|
+
const session = c.get("session");
|
|
59
|
+
const text = textOf(await c.req.json(), backend.maxMessageLength ?? 4e3);
|
|
60
|
+
const conversation = backend.conversationOf(session);
|
|
61
|
+
if (conversation.state().replying) throw new HttpProblem(409, "assistant-replying", "Conflict", "The assistant is still answering");
|
|
62
|
+
backend.onMessage(text, session, conversation).catch((error) => backend.report?.(error));
|
|
63
|
+
return c.body(null, 202);
|
|
64
|
+
});
|
|
65
|
+
app.post("/actions", async (c) => {
|
|
66
|
+
const { partId, actionId } = actionOf(await c.req.json());
|
|
67
|
+
if (!backend.conversationOf(c.get("session")).act(partId, actionId)) throw notFound("No card waits for that action");
|
|
68
|
+
return c.body(null, 202);
|
|
69
|
+
});
|
|
70
|
+
return app;
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
73
|
+
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,26 @@
|
|
|
1
|
+
import { ModelSettings } from "./assistant-settings.js";
|
|
2
|
+
import { Session } from "../types.js";
|
|
3
|
+
import { Conversation } from "./conversations.js";
|
|
4
|
+
import { AssistantTool } from "./tools.js";
|
|
5
|
+
//#region src/assistant/turn.d.ts
|
|
6
|
+
export type TurnOptions = {
|
|
7
|
+
model: ModelSettings;
|
|
8
|
+
conversation: Conversation;
|
|
9
|
+
session: Session;
|
|
10
|
+
/** The system prompt. */
|
|
11
|
+
system: string;
|
|
12
|
+
tools?: AssistantTool[];
|
|
13
|
+
/** Model calls in one turn before it stops. Default 8. */
|
|
14
|
+
maxSteps?: number;
|
|
15
|
+
/** Gets errors other than the model endpoint's refusals, which the user only sees as a failure. */
|
|
16
|
+
report?: (error: unknown) => void;
|
|
17
|
+
/** Transcript messages kept for the next turns. Default 40. */
|
|
18
|
+
keep?: number;
|
|
19
|
+
};
|
|
20
|
+
/**
|
|
21
|
+
* One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
|
|
22
|
+
* tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
|
|
23
|
+
* one assistant message, beside the parts tools show; a failure ends the turn with a danger card.
|
|
24
|
+
*/
|
|
25
|
+
export declare const runTurn: (options: TurnOptions, text: string) => Promise<void>;
|
|
26
|
+
//#endregion
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { requestApproval } from "./approval.js";
|
|
2
|
+
import { ModelError, streamChatCompletion } from "./chat-completions.js";
|
|
3
|
+
//#region src/assistant/turn.ts
|
|
4
|
+
const toolResult = async (call, tools, context) => {
|
|
5
|
+
const tool = tools.find((entry) => entry.name === call.function.name);
|
|
6
|
+
if (!tool) return `There is no tool named "${call.function.name}".`;
|
|
7
|
+
const args = call.function.arguments ? JSON.parse(call.function.arguments) : {};
|
|
8
|
+
if (typeof args !== "object" || args === null || Array.isArray(args)) return "The arguments must be a JSON object.";
|
|
9
|
+
return tool.run(args, context);
|
|
10
|
+
};
|
|
11
|
+
const trimmed = (transcript, keep) => {
|
|
12
|
+
if (transcript.length <= keep) return transcript;
|
|
13
|
+
const start = transcript.findIndex((message, index) => index >= transcript.length - keep && message.role === "user");
|
|
14
|
+
return start === -1 ? transcript.slice(-1) : transcript.slice(start);
|
|
15
|
+
};
|
|
16
|
+
const failure = (error, report) => {
|
|
17
|
+
if (error instanceof ModelError) return error.message;
|
|
18
|
+
if (error instanceof SyntaxError) return "The model answered with JSON that could not be read.";
|
|
19
|
+
report?.(error);
|
|
20
|
+
return "The server failed to answer.";
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* One turn of the chat: adds the user's message, then calls the model with the transcript and the tools, runs the
|
|
24
|
+
* tools it calls and calls it again with their results, until it answers without a tool call. Its text streams into
|
|
25
|
+
* one assistant message, beside the parts tools show; a failure ends the turn with a danger card.
|
|
26
|
+
*/
|
|
27
|
+
const runTurn = async (options, text) => {
|
|
28
|
+
const { model, conversation, session, system, tools = [], maxSteps = 8 } = options;
|
|
29
|
+
const messageId = crypto.randomUUID();
|
|
30
|
+
const show = (part) => conversation.apply({
|
|
31
|
+
type: "part",
|
|
32
|
+
messageId,
|
|
33
|
+
part
|
|
34
|
+
});
|
|
35
|
+
const context = {
|
|
36
|
+
session,
|
|
37
|
+
conversation,
|
|
38
|
+
show,
|
|
39
|
+
approve: (request) => requestApproval(conversation, messageId, request)
|
|
40
|
+
};
|
|
41
|
+
const specs = tools.map((tool) => ({
|
|
42
|
+
type: "function",
|
|
43
|
+
function: {
|
|
44
|
+
name: tool.name,
|
|
45
|
+
description: tool.description,
|
|
46
|
+
parameters: tool.parameters
|
|
47
|
+
}
|
|
48
|
+
}));
|
|
49
|
+
conversation.apply({
|
|
50
|
+
type: "message",
|
|
51
|
+
message: {
|
|
52
|
+
id: crypto.randomUUID(),
|
|
53
|
+
from: "user",
|
|
54
|
+
parts: [{
|
|
55
|
+
type: "text",
|
|
56
|
+
id: crypto.randomUUID(),
|
|
57
|
+
text
|
|
58
|
+
}]
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
conversation.apply({
|
|
62
|
+
type: "patch",
|
|
63
|
+
replying: true
|
|
64
|
+
});
|
|
65
|
+
conversation.transcript = trimmed([...conversation.transcript, {
|
|
66
|
+
role: "user",
|
|
67
|
+
content: text
|
|
68
|
+
}], options.keep ?? 40);
|
|
69
|
+
const before = conversation.transcript.length;
|
|
70
|
+
const steps = async () => {
|
|
71
|
+
for (let step = 0; step < maxSteps; step++) {
|
|
72
|
+
const part = {
|
|
73
|
+
type: "text",
|
|
74
|
+
id: `${messageId}-${step}`,
|
|
75
|
+
text: ""
|
|
76
|
+
};
|
|
77
|
+
const { content, toolCalls } = await streamChatCompletion(model, [{
|
|
78
|
+
role: "system",
|
|
79
|
+
content: system
|
|
80
|
+
}, ...conversation.transcript], (piece) => {
|
|
81
|
+
part.text += piece;
|
|
82
|
+
show({ ...part });
|
|
83
|
+
}, { tools: specs });
|
|
84
|
+
conversation.transcript.push({
|
|
85
|
+
role: "assistant",
|
|
86
|
+
content: content || null,
|
|
87
|
+
...toolCalls.length > 0 && { tool_calls: toolCalls }
|
|
88
|
+
});
|
|
89
|
+
if (toolCalls.length === 0) return;
|
|
90
|
+
for (const call of toolCalls) conversation.transcript.push({
|
|
91
|
+
role: "tool",
|
|
92
|
+
tool_call_id: call.id,
|
|
93
|
+
content: await toolResult(call, tools, context)
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
show({
|
|
97
|
+
type: "text",
|
|
98
|
+
id: `${messageId}-stopped`,
|
|
99
|
+
text: `Stopped after ${maxSteps} steps.`
|
|
100
|
+
});
|
|
101
|
+
};
|
|
102
|
+
await steps().catch((error) => {
|
|
103
|
+
conversation.transcript.splice(before);
|
|
104
|
+
show({
|
|
105
|
+
type: "card",
|
|
106
|
+
id: `${messageId}-error`,
|
|
107
|
+
title: "The assistant could not answer",
|
|
108
|
+
tone: "danger",
|
|
109
|
+
body: failure(error, options.report)
|
|
110
|
+
});
|
|
111
|
+
}).finally(() => conversation.apply({
|
|
112
|
+
type: "patch",
|
|
113
|
+
replying: false
|
|
114
|
+
}));
|
|
115
|
+
};
|
|
116
|
+
//#endregion
|
|
117
|
+
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,13 @@ 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
|
+
db,
|
|
87
|
+
meta,
|
|
88
|
+
authenticate,
|
|
89
|
+
report: options.onUnhandledError
|
|
90
|
+
}));
|
|
79
91
|
app.route("/", batchRoute(deps, authenticate, meta));
|
|
80
92
|
app.route(basePath, api);
|
|
81
93
|
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,12 @@ 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, Completion, ModelError, ToolCall, ToolSpec, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
13
|
+
import { Conversation, conversationKey, createConversations } from "./assistant/conversations.js";
|
|
14
|
+
import { AssistantBackend, assistantProtocolRoutes } from "./assistant/protocol-routes.js";
|
|
15
|
+
import { ApprovalAnswer, ApprovalRequest, requestApproval } from "./assistant/approval.js";
|
|
16
|
+
import { AssistantTool, ToolContext, defineTool } from "./assistant/tools.js";
|
|
17
|
+
import { TurnOptions, runTurn } from "./assistant/turn.js";
|
|
18
|
+
import { QueryOptions, QueryResult, runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
|
|
19
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
|
|
20
|
+
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 Completion, type Conversation, type CreateAdminInput, type CreateAuthOptions, HttpProblem, type IssueTokenOptions, type JwtOptions, ModelError, type ModelSettings, type NewUser, type PipelineHook, type QueryOptions, type QueryResult, 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, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized };
|
package/dist/index.js
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import { assistantRoles, assistantSettings, assistantVariables, seesAssistant } from "./assistant/assistant-settings.js";
|
|
2
|
+
import { conversationKey, createConversations } from "./assistant/conversations.js";
|
|
3
|
+
import { HttpProblem, badRequest, forbidden, notFound, unauthorized } from "./problem.js";
|
|
4
|
+
import { assistantProtocolRoutes } from "./assistant/protocol-routes.js";
|
|
5
|
+
import { runReadOnlyQuery, runReadWriteQuery } from "./assistant/run-query.js";
|
|
6
|
+
import { defineTool } from "./assistant/tools.js";
|
|
7
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./assistant/query-tools.js";
|
|
8
|
+
import { requestApproval } from "./assistant/approval.js";
|
|
9
|
+
import { ModelError, streamChatCompletion } from "./assistant/chat-completions.js";
|
|
10
|
+
import { runTurn } from "./assistant/turn.js";
|
|
1
11
|
import { consoleAuditQueue } from "./audit/console-queue.js";
|
|
2
12
|
import { createAuth, normalizeRoles } from "./better-auth/create-auth.js";
|
|
3
13
|
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
14
|
import { createAdmin } from "./create-admin.js";
|
|
6
15
|
import { configExports } from "./resource-source.js";
|
|
7
16
|
import { jwtAuthenticator } from "./auth/jwt.js";
|
|
8
17
|
import { betterAuthAuthenticator } from "./better-auth/authenticator.js";
|
|
9
18
|
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 };
|
|
19
|
+
export { HttpProblem, ModelError, assistantProtocolRoutes, assistantRoles, assistantSettings, assistantVariables, badRequest, betterAuthAuthenticator, configExports, consoleAuditQueue, conversationKey, createAdmin, createAuth, createConversations, createUser, defineTool, deleteUser, forbidden, hasUsers, issueToken, jwtAuthenticator, listUsers, maxTokenTtlSeconds, normalizeRoles, notFound, parseRoles, readOnlyQueryTool, readWriteQueryTool, requestApproval, roleChoices, runReadOnlyQuery, runReadWriteQuery, runTurn, seesAssistant, setUserBanned, setUserRole, streamChatCompletion, unauthorized };
|
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;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -32,9 +32,9 @@
|
|
|
32
32
|
"kysely": "^0.29.6",
|
|
33
33
|
"nodemailer": "^10.0.16",
|
|
34
34
|
"zod": "^4.6.5",
|
|
35
|
-
"@protobase/
|
|
36
|
-
"@protobase/
|
|
37
|
-
"@protobase/schema": "0.
|
|
35
|
+
"@protobase/layout": "0.2.0",
|
|
36
|
+
"@protobase/query": "0.2.0",
|
|
37
|
+
"@protobase/schema": "0.2.0"
|
|
38
38
|
},
|
|
39
39
|
"scripts": {
|
|
40
40
|
"build": "tsdown"
|