@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
|
@@ -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,47 @@
|
|
|
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
|
+
* `chats`: the directory the built-in assistant keeps conversations in, one file each.
|
|
13
|
+
* Each falls back to its environment variable; `fetch` replaces the model endpoint, for tests.
|
|
14
|
+
*/
|
|
15
|
+
export type AssistantOptions = {
|
|
16
|
+
url?: string;
|
|
17
|
+
apiKey?: string;
|
|
18
|
+
model?: string;
|
|
19
|
+
baseUrl?: string;
|
|
20
|
+
chats?: string;
|
|
21
|
+
fetch?: typeof fetch;
|
|
22
|
+
};
|
|
23
|
+
export type AssistantSettings = {
|
|
24
|
+
kind: 'external';
|
|
25
|
+
url: string;
|
|
26
|
+
} | {
|
|
27
|
+
kind: 'built-in';
|
|
28
|
+
model: ModelSettings;
|
|
29
|
+
chats: string;
|
|
30
|
+
};
|
|
31
|
+
export declare const assistantVariables: {
|
|
32
|
+
readonly url: "PROTOBASE_ASSISTANT_URL";
|
|
33
|
+
readonly apiKey: "PROTOBASE_ASSISTANT_API_KEY";
|
|
34
|
+
readonly model: "PROTOBASE_ASSISTANT_MODEL";
|
|
35
|
+
readonly baseUrl: "PROTOBASE_ASSISTANT_BASE_URL";
|
|
36
|
+
readonly chats: "PROTOBASE_ASSISTANT_CHATS";
|
|
37
|
+
};
|
|
38
|
+
/** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
|
|
39
|
+
export declare const assistantRoles: readonly ["admin", "ai"];
|
|
40
|
+
export declare const seesAssistant: (roles: readonly string[]) => boolean;
|
|
41
|
+
/**
|
|
42
|
+
* Which assistant the app has: the backend at `url` when one is set, otherwise the built-in one when an API key is
|
|
43
|
+
* set, otherwise none (also when the option is `false`). The built-in one calls OpenRouter unless `baseUrl` names
|
|
44
|
+
* another OpenAI-compatible endpoint, which then needs a `model`. A value that cannot work stops the server at startup.
|
|
45
|
+
*/
|
|
46
|
+
export declare const assistantSettings: (option: AssistantOptions | false | undefined, env: Record<string, string | undefined>) => AssistantSettings | undefined;
|
|
47
|
+
//#endregion
|
|
@@ -0,0 +1,55 @@
|
|
|
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
|
+
chats: "PROTOBASE_ASSISTANT_CHATS"
|
|
8
|
+
};
|
|
9
|
+
/** Where the built-in assistant keeps conversations without `chats`: relative to the working directory. */
|
|
10
|
+
const defaultChatsDirectory = ".protobase/chats";
|
|
11
|
+
const openRouterUrl = "https://openrouter.ai/api/v1";
|
|
12
|
+
const openRouterModel = "openrouter/auto";
|
|
13
|
+
/** The roles that see the assistant: `/meta` names it, and the built-in one answers, only for a caller with one of them. */
|
|
14
|
+
const assistantRoles = ["admin", "ai"];
|
|
15
|
+
const seesAssistant = (roles) => assistantRoles.some((role) => roles.includes(role));
|
|
16
|
+
const checkedUrl = (url, source) => {
|
|
17
|
+
if (!URL.canParse(url)) throw new Error(`${source} is not a URL; use https://...`);
|
|
18
|
+
const { protocol } = new URL(url);
|
|
19
|
+
if (protocol !== "https:" && protocol !== "http:") throw new Error(`${source} must start with https:// or http://, not ${protocol}//`);
|
|
20
|
+
return url.replace(/\/+$/, "");
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Which assistant the app has: the backend at `url` when one is set, otherwise the built-in one when an API key is
|
|
24
|
+
* set, otherwise none (also when the option is `false`). The built-in one calls OpenRouter unless `baseUrl` names
|
|
25
|
+
* another OpenAI-compatible endpoint, which then needs a `model`. A value that cannot work stops the server at startup.
|
|
26
|
+
*/
|
|
27
|
+
const assistantSettings = (option, env) => {
|
|
28
|
+
if (option === false) return void 0;
|
|
29
|
+
const value = (key) => {
|
|
30
|
+
return (option?.[key] ?? env[assistantVariables[key]])?.trim() || void 0;
|
|
31
|
+
};
|
|
32
|
+
const source = (key) => option?.[key] === void 0 ? assistantVariables[key] : `options.assistant.${key}`;
|
|
33
|
+
const url = value("url");
|
|
34
|
+
if (url) return {
|
|
35
|
+
kind: "external",
|
|
36
|
+
url: checkedUrl(url, source("url"))
|
|
37
|
+
};
|
|
38
|
+
const apiKey = value("apiKey");
|
|
39
|
+
if (!apiKey) return void 0;
|
|
40
|
+
const baseUrl = checkedUrl(value("baseUrl") ?? "https://openrouter.ai/api/v1", source("baseUrl"));
|
|
41
|
+
const model = value("model") ?? (baseUrl === "https://openrouter.ai/api/v1" ? "openrouter/auto" : void 0);
|
|
42
|
+
if (!model) throw new Error(`${assistantVariables.model} (or options.assistant.model) is required with an endpoint other than OpenRouter's`);
|
|
43
|
+
return {
|
|
44
|
+
kind: "built-in",
|
|
45
|
+
model: {
|
|
46
|
+
baseUrl,
|
|
47
|
+
apiKey,
|
|
48
|
+
model,
|
|
49
|
+
...option && option.fetch && { fetch: option.fetch }
|
|
50
|
+
},
|
|
51
|
+
chats: value("chats") ?? ".protobase/chats"
|
|
52
|
+
};
|
|
53
|
+
};
|
|
54
|
+
//#endregion
|
|
55
|
+
export { assistantRoles, assistantSettings, assistantVariables, defaultChatsDirectory, openRouterModel, openRouterUrl, seesAssistant };
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { openRouterUrl, seesAssistant } from "./assistant-settings.js";
|
|
2
|
+
import { appContext } from "./app-context.js";
|
|
3
|
+
import { conversationKey, createConversations } from "./conversations.js";
|
|
4
|
+
import { fileConversationStore } from "./file-conversation-store.js";
|
|
5
|
+
import { assistantProtocolRoutes } from "./protocol-routes.js";
|
|
6
|
+
import { readOnlyQueryTool, readWriteQueryTool } from "./query-tools.js";
|
|
7
|
+
import { runTurn } from "./turn.js";
|
|
8
|
+
//#region src/assistant/built-in-assistant.ts
|
|
9
|
+
const pageContext = (page) => page && `The user is on the page ${page} of the app (/<resource> lists records, /<resource>/<key> shows one).`;
|
|
10
|
+
/**
|
|
11
|
+
* Protobase's own assistant: answers questions about the app, reads its data with read-only queries and proposes
|
|
12
|
+
* changes the user approves, for callers with the `admin` or `ai` role. Made only of the exported building blocks and
|
|
13
|
+
* its prompt, which describes what the caller may see of the app. Conversations are files in the `chats` directory.
|
|
14
|
+
* On OpenRouter it caches the prompt and asks for little reasoning.
|
|
15
|
+
*/
|
|
16
|
+
const builtInAssistant = ({ model, chats, db, meta, authenticate, report }) => {
|
|
17
|
+
const conversations = createConversations({ store: fileConversationStore(chats) });
|
|
18
|
+
const tools = [readOnlyQueryTool(db), readWriteQueryTool(db)];
|
|
19
|
+
const onOpenRouter = model.baseUrl === openRouterUrl;
|
|
20
|
+
return assistantProtocolRoutes({
|
|
21
|
+
authenticate,
|
|
22
|
+
allows: (session) => seesAssistant(session.user.roles),
|
|
23
|
+
conversationOf: (session) => conversations(conversationKey(session)),
|
|
24
|
+
report,
|
|
25
|
+
onMessage: async ({ text, page }, session, conversation) => {
|
|
26
|
+
const { body } = await meta.forCaller(session);
|
|
27
|
+
await runTurn({
|
|
28
|
+
model,
|
|
29
|
+
conversation,
|
|
30
|
+
session,
|
|
31
|
+
system: appContext(body.resources, body.views),
|
|
32
|
+
tools,
|
|
33
|
+
report,
|
|
34
|
+
...page && { context: pageContext(page) },
|
|
35
|
+
...onOpenRouter && {
|
|
36
|
+
cache: true,
|
|
37
|
+
reasoning: "low"
|
|
38
|
+
}
|
|
39
|
+
}, text);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
//#endregion
|
|
44
|
+
export { builtInAssistant };
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { ChatMessage } from "./chat-completions.js";
|
|
2
|
+
//#region src/assistant/cache-breakpoints.d.ts
|
|
3
|
+
/**
|
|
4
|
+
* The messages with a prompt-cache breakpoint (`cache_control`, as OpenRouter and Anthropic read it) at the end of each
|
|
5
|
+
* message at `indexes`: the endpoint caches everything up to it, tools included. A message without text, such as an
|
|
6
|
+
* assistant message of only tool calls, gets none.
|
|
7
|
+
*/
|
|
8
|
+
export declare const withCacheBreakpoints: (messages: ChatMessage[], indexes: number[]) => ChatMessage[];
|
|
9
|
+
//#endregion
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
//#region src/assistant/cache-breakpoints.ts
|
|
2
|
+
const breakpoint = { type: "ephemeral" };
|
|
3
|
+
const marked = (message) => {
|
|
4
|
+
if (!message.content) return message;
|
|
5
|
+
const parts = typeof message.content === "string" ? [{
|
|
6
|
+
type: "text",
|
|
7
|
+
text: message.content
|
|
8
|
+
}] : message.content;
|
|
9
|
+
const content = parts.map((part, index) => index === parts.length - 1 ? {
|
|
10
|
+
...part,
|
|
11
|
+
cache_control: breakpoint
|
|
12
|
+
} : part);
|
|
13
|
+
return {
|
|
14
|
+
...message,
|
|
15
|
+
content
|
|
16
|
+
};
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* The messages with a prompt-cache breakpoint (`cache_control`, as OpenRouter and Anthropic read it) at the end of each
|
|
20
|
+
* message at `indexes`: the endpoint caches everything up to it, tools included. A message without text, such as an
|
|
21
|
+
* assistant message of only tool calls, gets none.
|
|
22
|
+
*/
|
|
23
|
+
const withCacheBreakpoints = (messages, indexes) => messages.map((message, index) => indexes.includes(index) ? marked(message) : message);
|
|
24
|
+
//#endregion
|
|
25
|
+
export { withCacheBreakpoints };
|
|
@@ -0,0 +1,74 @@
|
|
|
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 piece of a message's text; `cache_control` marks a prompt-cache breakpoint after it (OpenRouter, Anthropic). */
|
|
12
|
+
export type ChatTextPart = {
|
|
13
|
+
type: 'text';
|
|
14
|
+
text: string;
|
|
15
|
+
cache_control?: {
|
|
16
|
+
type: 'ephemeral';
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* A message of a chat completion: what the model reads, and the tool calls it made with their results. An assistant
|
|
21
|
+
* message keeps the model's `reasoning_details` (OpenRouter), which a reasoning model needs back with its tool calls.
|
|
22
|
+
*/
|
|
23
|
+
export type ChatMessage = {
|
|
24
|
+
role: 'system' | 'user';
|
|
25
|
+
content: string | ChatTextPart[];
|
|
26
|
+
} | {
|
|
27
|
+
role: 'assistant';
|
|
28
|
+
content: string | ChatTextPart[] | null;
|
|
29
|
+
tool_calls?: ToolCall[];
|
|
30
|
+
reasoning_details?: ReasoningDetail[];
|
|
31
|
+
} | {
|
|
32
|
+
role: 'tool';
|
|
33
|
+
tool_call_id: string;
|
|
34
|
+
content: string | ChatTextPart[];
|
|
35
|
+
};
|
|
36
|
+
/** One block of a model's reasoning, as OpenRouter streams it: passed back unchanged, never shown. */
|
|
37
|
+
export type ReasoningDetail = {
|
|
38
|
+
type: string;
|
|
39
|
+
index?: number;
|
|
40
|
+
[field: string]: unknown;
|
|
41
|
+
};
|
|
42
|
+
/** How much a reasoning model thinks before it answers, as OpenRouter's `reasoning.effort`. */
|
|
43
|
+
export type ReasoningEffort = 'none' | 'minimal' | 'low' | 'medium' | 'high' | 'xhigh' | 'max';
|
|
44
|
+
/** A tool as the endpoint's `tools` field describes it: a function with JSON Schema parameters. */
|
|
45
|
+
export type ToolSpec = {
|
|
46
|
+
type: 'function';
|
|
47
|
+
function: {
|
|
48
|
+
name: string;
|
|
49
|
+
description: string;
|
|
50
|
+
parameters: Record<string, unknown>;
|
|
51
|
+
};
|
|
52
|
+
};
|
|
53
|
+
export type Completion = {
|
|
54
|
+
content: string;
|
|
55
|
+
toolCalls: ToolCall[];
|
|
56
|
+
reasoningDetails: ReasoningDetail[];
|
|
57
|
+
};
|
|
58
|
+
export type CompletionOptions = {
|
|
59
|
+
tools?: ToolSpec[];
|
|
60
|
+
reasoning?: ReasoningEffort;
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
};
|
|
63
|
+
/** A failed call to the model endpoint, with a message an admin can act on. */
|
|
64
|
+
export declare class ModelError extends Error {
|
|
65
|
+
name: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
|
|
69
|
+
* offering `tools` and calling `onText` with each piece of the answer. `reasoning` sets OpenRouter's reasoning effort,
|
|
70
|
+
* which other endpoints may refuse. Resolves with the whole answer, the tool calls the model made and its reasoning
|
|
71
|
+
* details; rejects with a `ModelError`.
|
|
72
|
+
*/
|
|
73
|
+
export declare const streamChatCompletion: (settings: ModelSettings, messages: ChatMessage[], onText: (text: string) => void, options?: CompletionOptions) => Promise<Completion>;
|
|
74
|
+
//#endregion
|
|
@@ -0,0 +1,101 @@
|
|
|
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 = [], reasoning, 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
|
+
...reasoning && { reasoning: { effort: reasoning } }
|
|
41
|
+
}),
|
|
42
|
+
signal
|
|
43
|
+
}).catch((error) => {
|
|
44
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
45
|
+
throw new ModelError(`Could not reach the model endpoint ${settings.baseUrl}`, { cause: error });
|
|
46
|
+
});
|
|
47
|
+
};
|
|
48
|
+
const addToolCall = (calls, delta) => {
|
|
49
|
+
const call = calls[delta.index] ??= {
|
|
50
|
+
id: "",
|
|
51
|
+
type: "function",
|
|
52
|
+
function: {
|
|
53
|
+
name: "",
|
|
54
|
+
arguments: ""
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
if (delta.id) call.id = delta.id;
|
|
58
|
+
if (delta.function?.name) call.function.name += delta.function.name;
|
|
59
|
+
if (delta.function?.arguments) call.function.arguments += delta.function.arguments;
|
|
60
|
+
};
|
|
61
|
+
const addReasoning = (details, piece) => {
|
|
62
|
+
const at = piece.index ?? details.length;
|
|
63
|
+
const detail = details[at];
|
|
64
|
+
if (!detail) {
|
|
65
|
+
details[at] = { ...piece };
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
for (const [field, value] of Object.entries(piece)) detail[field] = (field === "text" || field === "summary" || field === "data") && typeof value === "string" && typeof detail[field] === "string" ? `${String(detail[field])}${value}` : value;
|
|
69
|
+
};
|
|
70
|
+
/**
|
|
71
|
+
* Streams a chat completion from an OpenAI-compatible endpoint (`POST <baseUrl>/chat/completions` with `stream: true`),
|
|
72
|
+
* offering `tools` and calling `onText` with each piece of the answer. `reasoning` sets OpenRouter's reasoning effort,
|
|
73
|
+
* which other endpoints may refuse. Resolves with the whole answer, the tool calls the model made and its reasoning
|
|
74
|
+
* details; rejects with a `ModelError`.
|
|
75
|
+
*/
|
|
76
|
+
const streamChatCompletion = async (settings, messages, onText, options = {}) => {
|
|
77
|
+
const response = await send(settings, messages, options);
|
|
78
|
+
if (!response.ok) throw await refusal(response, settings);
|
|
79
|
+
if (!response.body) throw new ModelError(`The model endpoint ${settings.baseUrl} answered without a body`);
|
|
80
|
+
let content = "";
|
|
81
|
+
const toolCalls = [];
|
|
82
|
+
const reasoningDetails = [];
|
|
83
|
+
await readEventStream(response.body, (data) => {
|
|
84
|
+
if (data === "[DONE]") return;
|
|
85
|
+
const chunk = JSON.parse(data);
|
|
86
|
+
if (chunk.error) throw new ModelError(`The model endpoint ${settings.baseUrl} stopped: ${chunk.error.message ?? "unknown error"}`);
|
|
87
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
88
|
+
for (const call of delta?.tool_calls ?? []) addToolCall(toolCalls, call);
|
|
89
|
+
for (const piece of delta?.reasoning_details ?? []) addReasoning(reasoningDetails, piece);
|
|
90
|
+
if (!delta?.content) return;
|
|
91
|
+
content += delta.content;
|
|
92
|
+
onText(delta.content);
|
|
93
|
+
});
|
|
94
|
+
return {
|
|
95
|
+
content,
|
|
96
|
+
toolCalls: toolCalls.filter(Boolean),
|
|
97
|
+
reasoningDetails: reasoningDetails.filter(Boolean)
|
|
98
|
+
};
|
|
99
|
+
};
|
|
100
|
+
//#endregion
|
|
101
|
+
export { ModelError, streamChatCompletion };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { ChatMessage } from "./chat-completions.js";
|
|
2
|
+
import { AssistantEvent } from "@protobase/schema";
|
|
3
|
+
//#region src/assistant/conversation-store.d.ts
|
|
4
|
+
/**
|
|
5
|
+
* What a conversation keeps, appended in order: the chat the user sees as `state` and `message` events, and the
|
|
6
|
+
* transcript the model read, one `transcript` record per message, exactly as it was sent.
|
|
7
|
+
*/
|
|
8
|
+
export type ConversationRecord = Extract<AssistantEvent, {
|
|
9
|
+
type: 'state' | 'message';
|
|
10
|
+
}> | {
|
|
11
|
+
type: 'transcript';
|
|
12
|
+
message: ChatMessage;
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* Where conversations live between requests and restarts, by owner (one user, see `conversationKey`) and id. The
|
|
16
|
+
* built-in assistant keeps them in files (`fileConversationStore`); a hosted backend can keep them in its database.
|
|
17
|
+
*/
|
|
18
|
+
export type ConversationStore = {
|
|
19
|
+
/** The conversation's records in the order they were appended; none for a conversation not started. */
|
|
20
|
+
load: (owner: string, id: string) => Promise<ConversationRecord[]>;
|
|
21
|
+
append: (owner: string, id: string, records: ConversationRecord[]) => Promise<void>;
|
|
22
|
+
/** The owner's conversations, the latest changed first. */
|
|
23
|
+
list: (owner: string) => Promise<Array<{
|
|
24
|
+
id: string;
|
|
25
|
+
updatedAt: Date;
|
|
26
|
+
}>>;
|
|
27
|
+
};
|
|
28
|
+
//#endregion
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { Session } from "../types.js";
|
|
2
|
+
import { ChatMessage } from "./chat-completions.js";
|
|
3
|
+
import { ConversationStore } from "./conversation-store.js";
|
|
4
|
+
import { AssistantEvent, AssistantState } from "@protobase/schema";
|
|
5
|
+
//#region src/assistant/conversations.d.ts
|
|
6
|
+
export type Conversation = {
|
|
7
|
+
state: () => AssistantState;
|
|
8
|
+
apply: (event: AssistantEvent) => void;
|
|
9
|
+
/** Calls `listener` with every later event until the returned function is called. */
|
|
10
|
+
subscribe: (listener: (event: AssistantEvent) => void) => () => void;
|
|
11
|
+
/** The chat as the model read it, tool calls and their results included, oldest first. */
|
|
12
|
+
transcript: () => readonly ChatMessage[];
|
|
13
|
+
/** Adds messages to the transcript, as they were sent to the model or came from it. */
|
|
14
|
+
remember: (...messages: ChatMessage[]) => void;
|
|
15
|
+
/** Appends what changed since the last save to the store: the chat's changed messages and the transcript's new ones. */
|
|
16
|
+
save: () => Promise<void>;
|
|
17
|
+
/** Calls `handler` with the action's id when a button of part `partId` is clicked, until the returned function is called. */
|
|
18
|
+
onAction: (partId: string, handler: (actionId: string) => void) => () => void;
|
|
19
|
+
/** Hands a click to the part's handler; false when no handler waits for that part. */
|
|
20
|
+
act: (partId: string, actionId: string) => boolean;
|
|
21
|
+
};
|
|
22
|
+
export type ConversationsOptions = {
|
|
23
|
+
/** Where conversations are kept; without one they live in this process's memory only. */
|
|
24
|
+
store?: ConversationStore;
|
|
25
|
+
/** Chat messages the user sees. Default 50. */
|
|
26
|
+
keepMessages?: number;
|
|
27
|
+
/**
|
|
28
|
+
* Transcript messages the model reads. Past it, the transcript drops its older half at the next user message, so
|
|
29
|
+
* the prefix a model endpoint caches stays the same for many turns. Default 40.
|
|
30
|
+
*/
|
|
31
|
+
keepTranscript?: number;
|
|
32
|
+
};
|
|
33
|
+
/** The owner of a user's conversations, per tenant. */
|
|
34
|
+
export declare const conversationKey: (session: Session) => string;
|
|
35
|
+
/**
|
|
36
|
+
* Conversations by owner (see `conversationKey`) and id, each loaded from the store on first use and then kept in this
|
|
37
|
+
* process's memory, shared by the owner's open tabs. A deployment of several instances needs each owner's requests on
|
|
38
|
+
* one instance, since a turn and its approvals run in the instance that started it.
|
|
39
|
+
*/
|
|
40
|
+
export declare const createConversations: ({ store, keepMessages, keepTranscript }?: ConversationsOptions) => (owner: string, id?: string) => Promise<Conversation>;
|
|
41
|
+
//#endregion
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { applyAssistantEvent, emptyAssistantState } from "@protobase/schema";
|
|
2
|
+
//#region src/assistant/conversations.ts
|
|
3
|
+
const remembered = (transcript, message, keep) => {
|
|
4
|
+
const next = [...transcript, message];
|
|
5
|
+
if (message.role !== "user" || next.length <= keep) return next;
|
|
6
|
+
return next.slice(next.findIndex((entry, index) => index >= next.length - keep / 2 && entry.role === "user"));
|
|
7
|
+
};
|
|
8
|
+
const createConversation = (records, keepMessages, keepTranscript, write) => {
|
|
9
|
+
let state = emptyAssistantState;
|
|
10
|
+
let transcript = [];
|
|
11
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
12
|
+
const actions = /* @__PURE__ */ new Map();
|
|
13
|
+
let replaced = false;
|
|
14
|
+
const changed = /* @__PURE__ */ new Set();
|
|
15
|
+
let unsaved = [];
|
|
16
|
+
let saving = Promise.resolve();
|
|
17
|
+
const fold = (event) => {
|
|
18
|
+
const next = applyAssistantEvent(state, event);
|
|
19
|
+
state = next.messages.length > keepMessages ? {
|
|
20
|
+
...next,
|
|
21
|
+
messages: next.messages.slice(-keepMessages)
|
|
22
|
+
} : next;
|
|
23
|
+
};
|
|
24
|
+
const remember = (message) => {
|
|
25
|
+
transcript = remembered(transcript, message, keepTranscript);
|
|
26
|
+
};
|
|
27
|
+
for (const record of records) if (record.type === "transcript") remember(record.message);
|
|
28
|
+
else fold(record);
|
|
29
|
+
state = {
|
|
30
|
+
...state,
|
|
31
|
+
replying: false
|
|
32
|
+
};
|
|
33
|
+
const pending = () => {
|
|
34
|
+
const records = [...replaced ? [{
|
|
35
|
+
type: "state",
|
|
36
|
+
state: {
|
|
37
|
+
messages: state.messages,
|
|
38
|
+
replying: false
|
|
39
|
+
}
|
|
40
|
+
}] : state.messages.filter((message) => changed.has(message.id)).map((message) => ({
|
|
41
|
+
type: "message",
|
|
42
|
+
message
|
|
43
|
+
})), ...unsaved.map((message) => ({
|
|
44
|
+
type: "transcript",
|
|
45
|
+
message
|
|
46
|
+
}))];
|
|
47
|
+
replaced = false;
|
|
48
|
+
changed.clear();
|
|
49
|
+
unsaved = [];
|
|
50
|
+
return records;
|
|
51
|
+
};
|
|
52
|
+
return {
|
|
53
|
+
state: () => state,
|
|
54
|
+
apply: (event) => {
|
|
55
|
+
fold(event);
|
|
56
|
+
if (event.type === "state") replaced = true;
|
|
57
|
+
if (event.type === "message") changed.add(event.message.id);
|
|
58
|
+
if (event.type === "part") changed.add(event.messageId);
|
|
59
|
+
for (const listener of listeners) listener(event);
|
|
60
|
+
},
|
|
61
|
+
subscribe: (listener) => {
|
|
62
|
+
listeners.add(listener);
|
|
63
|
+
return () => listeners.delete(listener);
|
|
64
|
+
},
|
|
65
|
+
transcript: () => transcript,
|
|
66
|
+
remember: (...messages) => {
|
|
67
|
+
for (const message of messages) remember(message);
|
|
68
|
+
unsaved.push(...messages);
|
|
69
|
+
},
|
|
70
|
+
save: () => {
|
|
71
|
+
const records = pending();
|
|
72
|
+
const next = () => records.length > 0 ? write(records) : Promise.resolve();
|
|
73
|
+
saving = saving.then(next, next);
|
|
74
|
+
return saving;
|
|
75
|
+
},
|
|
76
|
+
onAction: (partId, handler) => {
|
|
77
|
+
actions.set(partId, handler);
|
|
78
|
+
return () => actions.delete(partId);
|
|
79
|
+
},
|
|
80
|
+
act: (partId, actionId) => {
|
|
81
|
+
const handler = actions.get(partId);
|
|
82
|
+
handler?.(actionId);
|
|
83
|
+
return handler !== void 0;
|
|
84
|
+
}
|
|
85
|
+
};
|
|
86
|
+
};
|
|
87
|
+
/** The owner of a user's conversations, per tenant. */
|
|
88
|
+
const conversationKey = (session) => JSON.stringify([session.tenant ?? null, session.user.id]);
|
|
89
|
+
/**
|
|
90
|
+
* Conversations by owner (see `conversationKey`) and id, each loaded from the store on first use and then kept in this
|
|
91
|
+
* process's memory, shared by the owner's open tabs. A deployment of several instances needs each owner's requests on
|
|
92
|
+
* one instance, since a turn and its approvals run in the instance that started it.
|
|
93
|
+
*/
|
|
94
|
+
const createConversations = ({ store, keepMessages = 50, keepTranscript = 40 } = {}) => {
|
|
95
|
+
const loaded = /* @__PURE__ */ new Map();
|
|
96
|
+
return (owner, id = "chat") => {
|
|
97
|
+
const key = JSON.stringify([owner, id]);
|
|
98
|
+
const existing = loaded.get(key);
|
|
99
|
+
if (existing) return existing;
|
|
100
|
+
const write = (records) => store?.append(owner, id, records) ?? Promise.resolve();
|
|
101
|
+
const conversation = (store?.load(owner, id) ?? Promise.resolve([])).then((records) => createConversation(records, keepMessages, keepTranscript, write));
|
|
102
|
+
conversation.catch(() => loaded.delete(key));
|
|
103
|
+
loaded.set(key, conversation);
|
|
104
|
+
return conversation;
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
//#endregion
|
|
108
|
+
export { conversationKey, createConversations };
|