@xano-sdk/chatbot 1.0.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/AGENTS.md +437 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/dist/index.d.ts +1811 -0
- package/dist/index.js +1128 -0
- package/llms.txt +487 -0
- package/package.json +88 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1128 @@
|
|
|
1
|
+
// src/options.ts
|
|
2
|
+
var DEFAULT_RATE_LIMIT_MAX = 20;
|
|
3
|
+
var DEFAULT_RATE_LIMIT_TTL = 60;
|
|
4
|
+
var DEFAULT_SYSTEM_PROMPT = "You are a helpful, concise assistant. Answer the user's questions directly. If you do not know something, say so rather than guessing. Format replies as light Markdown \u2014 emphasis, bullet lists, and fenced code blocks where they genuinely help. Keep it minimal: no headings or tables in a short answer, and never wrap an ordinary sentence in formatting. Do not emit raw HTML.";
|
|
5
|
+
var TOOLS_SYSTEM_PROMPT = " You have tools available. When a question needs information you do not have, call the appropriate tool rather than guessing or saying you do not know. Use what a tool returns to answer in your own words \u2014 never paste the raw tool response or its wrapper into your reply.";
|
|
6
|
+
var DEFAULT_HISTORY_LIMIT = 20;
|
|
7
|
+
var DEFAULT_LIST_LIMIT = 100;
|
|
8
|
+
var DEFAULT_TRANSCRIPT_LIMIT = 200;
|
|
9
|
+
var DEFAULT_ROUTE_PREFIX = "chat";
|
|
10
|
+
var DEFAULT_NAMES = {
|
|
11
|
+
conversation: "conversation",
|
|
12
|
+
message: "conversation_message",
|
|
13
|
+
replyFn: "chatbot/generate_reply",
|
|
14
|
+
agent: "chatbot_agent",
|
|
15
|
+
apiGroup: "Chatbot"
|
|
16
|
+
};
|
|
17
|
+
var CANONICAL_PATTERN = /^[A-Za-z0-9_-]+$/;
|
|
18
|
+
function isToolWrapper(entry) {
|
|
19
|
+
const ref4 = entry;
|
|
20
|
+
return ref4.tool !== void 0 || ref4.id !== void 0;
|
|
21
|
+
}
|
|
22
|
+
function applyDefaultToolAuth(tools, authTable) {
|
|
23
|
+
return tools.map((entry) => {
|
|
24
|
+
if (typeof entry === "object" && entry !== null && isToolWrapper(entry)) {
|
|
25
|
+
return "auth" in entry && entry.auth !== void 0 ? entry : { ...entry, auth: authTable };
|
|
26
|
+
}
|
|
27
|
+
return { tool: entry, auth: authTable };
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
var isPublicToolEntry = (entry) => typeof entry === "object" && entry !== null && isToolWrapper(entry) && entry.auth === false;
|
|
31
|
+
var show = (v) => typeof v === "number" ? String(v) : JSON.stringify(v);
|
|
32
|
+
function resolveOptions(opts = {}) {
|
|
33
|
+
const authenticated = opts.authenticated ?? true;
|
|
34
|
+
const guest = opts.guest ?? false;
|
|
35
|
+
if (!authenticated && !guest) {
|
|
36
|
+
throw new Error(
|
|
37
|
+
"createChatbot: both endpoint families are disabled ({ authenticated: false, guest: false }), which would register tables and an agent with no way to reach them. Enable at least one."
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (authenticated && opts.authTable === void 0) {
|
|
41
|
+
if ("authTable" in opts) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
"createChatbot: `authTable` was passed but is undefined at the time registerChatbot/createChatbot ran. The table handle you passed has not been initialized yet \u2014 usually a circular import (the file defining the table imports, directly or indirectly, the file calling registerChatbot), or an import of a name the module does not export. Define the table in a module that does not import the chatbot wiring, or pass its name as a string."
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
throw new Error(
|
|
47
|
+
"createChatbot: `authTable` is required when the authenticated endpoint family is enabled. Pass the table conversations belong to \u2014 a table() handle (preferred) or its name: `registerChatbot(xano, { authTable: userTable })`. For an anonymous-only bot pass { authenticated: false, guest: true } instead, which needs no auth table."
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const userIdType = opts.userIdType ?? "int";
|
|
51
|
+
if (userIdType !== "int" && userIdType !== "uuid") {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`createChatbot: userIdType ${show(userIdType)} is not valid \u2014 pass "int" (the default) or "uuid". It must match the primary-key type of \`authTable\`; Xano allows no other primary-key type.`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
const limits = {
|
|
57
|
+
historyLimit: {
|
|
58
|
+
value: opts.historyLimit ?? DEFAULT_HISTORY_LIMIT,
|
|
59
|
+
note: "It caps how many prior messages are replayed into the model on each send."
|
|
60
|
+
},
|
|
61
|
+
listLimit: {
|
|
62
|
+
value: opts.listLimit ?? DEFAULT_LIST_LIMIT,
|
|
63
|
+
note: "It caps how many conversations the list endpoint returns."
|
|
64
|
+
},
|
|
65
|
+
transcriptLimit: {
|
|
66
|
+
value: opts.transcriptLimit ?? DEFAULT_TRANSCRIPT_LIMIT,
|
|
67
|
+
note: "It caps how many messages a transcript endpoint returns."
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
for (const [name, { value, note }] of Object.entries(limits)) {
|
|
71
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`createChatbot: ${name} ${show(value)} is not valid \u2014 pass a positive integer. ${note}`
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
let rateLimit = false;
|
|
78
|
+
if (opts.rateLimit !== false) {
|
|
79
|
+
const rl = opts.rateLimit ?? {};
|
|
80
|
+
if (typeof rl !== "object" || rl === null) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`createChatbot: rateLimit ${show(rl)} is not valid \u2014 pass an object ({ max?, ttl?, error? }) or \`false\` to disable it.`
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
const max = rl.max ?? DEFAULT_RATE_LIMIT_MAX;
|
|
86
|
+
const ttl = rl.ttl ?? DEFAULT_RATE_LIMIT_TTL;
|
|
87
|
+
for (const [name, value] of Object.entries({ max, ttl })) {
|
|
88
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`createChatbot: rateLimit.${name} ${show(value)} is not valid \u2014 pass a positive integer. It bounds the model-invoking endpoints per caller; pass \`rateLimit: false\` to remove the limit.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (rl.error !== void 0 && (typeof rl.error !== "string" || rl.error.length === 0)) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
`createChatbot: rateLimit.error ${show(rl.error)} is not valid \u2014 pass a non-empty string.`
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
rateLimit = { max, ttl, error: rl.error ?? "Too many messages. Please wait a moment and try again." };
|
|
100
|
+
}
|
|
101
|
+
const authoredTools = opts.tools ?? [];
|
|
102
|
+
if (!Array.isArray(authoredTools)) {
|
|
103
|
+
throw new Error(
|
|
104
|
+
`createChatbot: tools ${show(opts.tools)} is not valid \u2014 pass an array of tool() handles, tool names, or { tool, enabled?, auth? } entries.`
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
for (const [i, entry] of authoredTools.entries()) {
|
|
108
|
+
const ok = typeof entry === "string" && entry.length > 0 || typeof entry === "object" && entry !== null;
|
|
109
|
+
if (!ok) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`createChatbot: tools[${i}] ${show(entry)} is not valid \u2014 pass a tool() handle, a non-empty tool name, or a { tool, enabled?, auth? } entry.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const scopedAgainst = authenticated ? opts.authTable : void 0;
|
|
116
|
+
const tools = scopedAgainst !== void 0 ? applyDefaultToolAuth(authoredTools, scopedAgainst) : authoredTools;
|
|
117
|
+
if (scopedAgainst !== void 0 && guest && tools.some((entry) => !isPublicToolEntry(entry))) {
|
|
118
|
+
console.warn(
|
|
119
|
+
"xanosdk: chatbot \u2014 both endpoint families are enabled and the agent's tools are scoped to the auth table, so a tool binds the caller on the authenticated endpoints and has no identity to bind on the PUBLIC guest ones. One agent serves both families and an entry carries one `auth`, so this cannot be resolved for you. Give a tool the model may call for a guest `{ tool, auth: false }` and keep `auth()` out of it, or register the guest bot as its own chatbot (own `routePrefix` and `names`) with its own tools."
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
if (opts.canonical !== void 0) {
|
|
123
|
+
if (typeof opts.canonical !== "string" || !CANONICAL_PATTERN.test(opts.canonical)) {
|
|
124
|
+
throw new Error(
|
|
125
|
+
`createChatbot: canonical ${show(opts.canonical)} is not a valid URL segment \u2014 it must be a non-empty string matching [A-Za-z0-9_-]+ (the alphabet Xano mints). It becomes the "<canonical>" in /api:<canonical>/chat/conversations.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const routePrefix = opts.routePrefix ?? DEFAULT_ROUTE_PREFIX;
|
|
130
|
+
if (typeof routePrefix !== "string" || !/^[A-Za-z0-9_\-/]+$/.test(routePrefix) || routePrefix.startsWith("/") || routePrefix.endsWith("/")) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`createChatbot: routePrefix ${show(routePrefix)} is not valid \u2014 pass a non-empty path segment matching [A-Za-z0-9_-/]+ with no leading or trailing slash (e.g. "chat" or "support/chat"). It becomes the leading segment of every endpoint, as in <prefix>/conversations/{id}/send. A \`{param}\` marker is rejected: a prefix is not a place for a path param.`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const history = opts.history ?? false;
|
|
136
|
+
if (!(typeof history === "boolean" || history === "all" || typeof history === "number" && Number.isInteger(history) && history > 0)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`createChatbot: history ${show(history)} is not a valid setting \u2014 pass \`false\` (off, the default), \`true\` (on at the engine's default capture depth), a positive integer (capture depth), or "all" (unlimited depth). The depth caps statements captured per record, not retention.`
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
const llmIn = opts.llm ?? {};
|
|
142
|
+
for (const field of ["prompt", "messages"]) {
|
|
143
|
+
if (field in llmIn && llmIn[field] !== void 0) {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`createChatbot: llm.${field} is not configurable \u2014 this package owns the agent's run prompt, because that is how the conversation transcript reaches the model. Xano stores ONE prompt behind a prompt_type discriminator, so a supplied \`${field}\` would replace the transcript rather than add to it. Put your instructions in \`llm.systemPrompt\`, which is passed through.`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
const basePrompt = llmIn.systemPrompt ?? DEFAULT_SYSTEM_PROMPT;
|
|
150
|
+
const systemPrompt = tools.length > 0 ? basePrompt + TOOLS_SYSTEM_PROMPT : basePrompt;
|
|
151
|
+
const llm = {
|
|
152
|
+
type: "xano-free",
|
|
153
|
+
...llmIn,
|
|
154
|
+
systemPrompt,
|
|
155
|
+
// Set last: the transcript delivery mechanism, verified against a live
|
|
156
|
+
// instance. See `src/agent/chat-agent.ts` for why `messages` and not `prompt`.
|
|
157
|
+
messages: MESSAGES_TEMPLATE
|
|
158
|
+
};
|
|
159
|
+
return {
|
|
160
|
+
authTable: opts.authTable,
|
|
161
|
+
userIdType,
|
|
162
|
+
authenticated,
|
|
163
|
+
guest,
|
|
164
|
+
llm,
|
|
165
|
+
historyLimit: limits.historyLimit.value,
|
|
166
|
+
listLimit: limits.listLimit.value,
|
|
167
|
+
rateLimit,
|
|
168
|
+
tools,
|
|
169
|
+
transcriptLimit: limits.transcriptLimit.value,
|
|
170
|
+
canonical: opts.canonical,
|
|
171
|
+
history,
|
|
172
|
+
routePrefix,
|
|
173
|
+
names: { ...DEFAULT_NAMES, ...opts.names },
|
|
174
|
+
tags: opts.tags ?? ["xano:chatbot"]
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
var MESSAGES_TEMPLATE = "{{ $args.messages }}";
|
|
178
|
+
|
|
179
|
+
// src/tables/conversation.ts
|
|
180
|
+
import { table, f } from "@xano/sdk";
|
|
181
|
+
function conversationTable(opts) {
|
|
182
|
+
return table({
|
|
183
|
+
name: opts.names.conversation,
|
|
184
|
+
description: "A single chat thread. Owned by a user (authenticated) and/or reachable with a session token (guest).",
|
|
185
|
+
auth: false,
|
|
186
|
+
// Pinned rather than inherited: a table with no explicit `useXdo` takes the
|
|
187
|
+
// CONSUMER workspace's `use_xdo` at export, which would silently change this
|
|
188
|
+
// table's storage mode — and therefore its emitted bytes — depending on who
|
|
189
|
+
// installed it. `true` keeps the non-key columns in the internal `xdo` JSON
|
|
190
|
+
// column (and auto-prepends the engine's `gin(xdo)` index in canonical order).
|
|
191
|
+
useXdo: true,
|
|
192
|
+
tags: opts.tags,
|
|
193
|
+
schema: {
|
|
194
|
+
// Present only when the authenticated family is on. A guest-only install
|
|
195
|
+
// has no auth table to point at, and a nullable FK to nothing is worse
|
|
196
|
+
// than no column: it would still emit an `@` method carrying a guid that
|
|
197
|
+
// resolves to no registered table, which fails the export.
|
|
198
|
+
...opts.authenticated ? {
|
|
199
|
+
user_id: f.tableRef(opts.authTable, {
|
|
200
|
+
type: opts.userIdType,
|
|
201
|
+
// Nullable exactly when guests are also enabled: a guest thread has
|
|
202
|
+
// no owner until it is claimed. With guests off, every conversation
|
|
203
|
+
// is created by an authenticated endpoint that always fills this in,
|
|
204
|
+
// so the tighter column is the honest one.
|
|
205
|
+
...opts.guest ? { nullable: true } : {},
|
|
206
|
+
description: "The user this conversation belongs to. Null until a guest thread is claimed."
|
|
207
|
+
})
|
|
208
|
+
} : {},
|
|
209
|
+
// Present only when guests are enabled. This is a BEARER CAPABILITY: it is
|
|
210
|
+
// the whole of a guest's authorization, so it is minted by
|
|
211
|
+
// `security.create_uuid` (not derived from anything guessable) and its
|
|
212
|
+
// column is `internal` so a stray `db.get` without an explicit `output`
|
|
213
|
+
// list cannot leak another thread's token into a response.
|
|
214
|
+
...opts.guest ? {
|
|
215
|
+
session_token: f.text({
|
|
216
|
+
access: "internal",
|
|
217
|
+
description: "Opaque bearer capability for guest access to this thread. Treat it like a password."
|
|
218
|
+
})
|
|
219
|
+
} : {},
|
|
220
|
+
title: f.text({
|
|
221
|
+
methods: ["trim"],
|
|
222
|
+
description: "Human-readable thread title. Seeded from the first user message."
|
|
223
|
+
}),
|
|
224
|
+
// Maintained by the reply function so a conversation list can sort by
|
|
225
|
+
// recency without joining the message table.
|
|
226
|
+
last_message_at: f.timestamp({
|
|
227
|
+
nullable: true,
|
|
228
|
+
description: "When the most recent message was appended. Null until the first send."
|
|
229
|
+
})
|
|
230
|
+
},
|
|
231
|
+
index: [
|
|
232
|
+
// The authenticated list endpoint's access path: every user's threads,
|
|
233
|
+
// newest first. Without it that endpoint is a full scan of every
|
|
234
|
+
// conversation in the workspace.
|
|
235
|
+
...opts.authenticated ? [{ type: "btree", fields: [{ name: "user_id", op: "asc" }] }] : [],
|
|
236
|
+
// Unique, not merely indexed: a guest's entire authorization is "I hold
|
|
237
|
+
// this token", so two rows sharing one would make a single token address
|
|
238
|
+
// two threads. Uniqueness is what makes the lookup unambiguous.
|
|
239
|
+
...opts.guest ? [{ type: "btree|unique", fields: [{ name: "session_token", op: "asc" }] }] : []
|
|
240
|
+
]
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
var PUBLIC_CONVERSATION_FIELDS = [
|
|
244
|
+
"id",
|
|
245
|
+
"created_at",
|
|
246
|
+
"title",
|
|
247
|
+
"last_message_at"
|
|
248
|
+
];
|
|
249
|
+
|
|
250
|
+
// src/tables/message.ts
|
|
251
|
+
import { table as table2, f as f2 } from "@xano/sdk";
|
|
252
|
+
var MESSAGE_ROLES = ["user", "assistant", "system"];
|
|
253
|
+
function messageTable(opts, conversation) {
|
|
254
|
+
return table2({
|
|
255
|
+
name: opts.names.message,
|
|
256
|
+
description: "One turn in a conversation. Replayed to the agent as an LLM message.",
|
|
257
|
+
auth: false,
|
|
258
|
+
// Pinned for the same reason as `conversation` — see that module.
|
|
259
|
+
useXdo: true,
|
|
260
|
+
tags: opts.tags,
|
|
261
|
+
schema: {
|
|
262
|
+
conversation_id: f2.tableRef(conversation, {
|
|
263
|
+
required: true,
|
|
264
|
+
description: "The thread this message belongs to."
|
|
265
|
+
}),
|
|
266
|
+
// An enum, NOT free text. An unrecognized role is a fatal agent error on
|
|
267
|
+
// the NEXT send, so the database is the right place to make it impossible.
|
|
268
|
+
role: f2.enum(MESSAGE_ROLES, {
|
|
269
|
+
required: true,
|
|
270
|
+
description: "Who produced this turn. The engine rejects any other value when replaying it."
|
|
271
|
+
}),
|
|
272
|
+
// `min:1` is the guard against the confabulation described in the header:
|
|
273
|
+
// an empty turn does not fail loudly, it produces a plausible fabrication.
|
|
274
|
+
content: f2.text({
|
|
275
|
+
required: true,
|
|
276
|
+
methods: ["min:1"],
|
|
277
|
+
description: "The message text. Must be non-empty \u2014 a blank turn makes the model confabulate."
|
|
278
|
+
})
|
|
279
|
+
},
|
|
280
|
+
index: [
|
|
281
|
+
// The access path for both the transcript endpoint and the reply
|
|
282
|
+
// function's history window: one thread's turns in order. A composite with
|
|
283
|
+
// `created_at` so the sort is served by the index rather than a filesort.
|
|
284
|
+
{
|
|
285
|
+
type: "btree",
|
|
286
|
+
fields: [
|
|
287
|
+
{ name: "conversation_id", op: "asc" },
|
|
288
|
+
{ name: "created_at", op: "asc" }
|
|
289
|
+
]
|
|
290
|
+
}
|
|
291
|
+
]
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
var PUBLIC_MESSAGE_FIELDS = [
|
|
295
|
+
"id",
|
|
296
|
+
"created_at",
|
|
297
|
+
"conversation_id",
|
|
298
|
+
"role",
|
|
299
|
+
"content"
|
|
300
|
+
];
|
|
301
|
+
|
|
302
|
+
// src/agent/chat-agent.ts
|
|
303
|
+
import { agent } from "@xano/sdk";
|
|
304
|
+
function chatAgent(opts) {
|
|
305
|
+
return agent({
|
|
306
|
+
name: opts.names.agent,
|
|
307
|
+
description: "Conversational assistant. Receives the thread's recent turns as decoded LLM messages and returns the next one.",
|
|
308
|
+
tags: opts.tags,
|
|
309
|
+
tools: opts.tools,
|
|
310
|
+
llm: opts.llm
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// src/functions/generate-reply.ts
|
|
315
|
+
import { defineFunction, input, s, c, inp, ref, expr, obj, col, withFilters, fl } from "@xano/sdk";
|
|
316
|
+
var TITLE_LENGTH = 60;
|
|
317
|
+
function generateReplyFn(opts, conversation, message, chatAgent2) {
|
|
318
|
+
return defineFunction({
|
|
319
|
+
name: opts.names.replyFn,
|
|
320
|
+
description: "Appends a user message, runs the chat agent over recent history, appends the reply, and returns it. Performs no authorization.",
|
|
321
|
+
tags: opts.tags,
|
|
322
|
+
input: {
|
|
323
|
+
conversation_id: input.int({
|
|
324
|
+
required: true,
|
|
325
|
+
description: "The thread to append to. NOT authorized here \u2014 the caller must have checked ownership."
|
|
326
|
+
}),
|
|
327
|
+
content: input.text({
|
|
328
|
+
required: true,
|
|
329
|
+
description: "The user's message text. Must be non-empty."
|
|
330
|
+
})
|
|
331
|
+
},
|
|
332
|
+
stack: [
|
|
333
|
+
// Defence in depth. Every endpoint in this package already rejects a blank
|
|
334
|
+
// message, so reaching this is a direct-caller mistake — but the failure it
|
|
335
|
+
// prevents is silent rather than loud, which is what earns it a second
|
|
336
|
+
// check here. An empty turn does not error at the provider; the model
|
|
337
|
+
// receives a blank message and fabricates a plausible prior conversation
|
|
338
|
+
// (verified — see AGENTS.md). A confabulated reply written into the
|
|
339
|
+
// transcript then poisons every subsequent turn's context.
|
|
340
|
+
// Compared TRIMMED: the endpoints trim at the input, but a direct
|
|
341
|
+
// `s.function.run` caller supplies `content` straight into this function
|
|
342
|
+
// and no input method runs for them. Comparing the raw value would let
|
|
343
|
+
// " " through on that path.
|
|
344
|
+
s.precondition({
|
|
345
|
+
expr: expr(withFilters(inp("content"), fl.trim()), "!=", c.text("")),
|
|
346
|
+
error_type: "badrequest",
|
|
347
|
+
error: c.text("Message content cannot be empty.")
|
|
348
|
+
}),
|
|
349
|
+
// 1. The user's turn. Written first so the history read below includes it —
|
|
350
|
+
// see the ordering note in the module header.
|
|
351
|
+
s.db.add({
|
|
352
|
+
table: message,
|
|
353
|
+
as: "user_message",
|
|
354
|
+
data: [
|
|
355
|
+
{ name: "created_at", value: c.text("now") },
|
|
356
|
+
{ name: "conversation_id", value: inp("conversation_id") },
|
|
357
|
+
{ name: "role", value: c.text("user") },
|
|
358
|
+
{ name: "content", value: inp("content") }
|
|
359
|
+
]
|
|
360
|
+
}),
|
|
361
|
+
// 2. The context window: the most recent `historyLimit` turns of this
|
|
362
|
+
// thread. Sorted DESC and capped in the database so a long conversation
|
|
363
|
+
// costs a bounded read — then reversed below, because the model needs
|
|
364
|
+
// them oldest-first. `id` breaks a `created_at` tie: two messages written
|
|
365
|
+
// in the same second must still order deterministically, or a turn can
|
|
366
|
+
// appear to precede the one it answered.
|
|
367
|
+
s.db.query({
|
|
368
|
+
table: message,
|
|
369
|
+
where: expr(col("conversation_id"), "=", inp("conversation_id")),
|
|
370
|
+
output: ["role", "content"],
|
|
371
|
+
sort: [
|
|
372
|
+
{ sortBy: "created_at", dir: "desc" },
|
|
373
|
+
{ sortBy: "id", dir: "desc" }
|
|
374
|
+
],
|
|
375
|
+
// `metadata: false` keeps this a bare array rather than the engine's
|
|
376
|
+
// paging envelope — `fl.reverse` and the map below both want the array.
|
|
377
|
+
paging: { per_page: opts.historyLimit, metadata: false },
|
|
378
|
+
as: "recent"
|
|
379
|
+
}),
|
|
380
|
+
// 3. Build exactly `[{ role, content }]`, oldest first.
|
|
381
|
+
//
|
|
382
|
+
// The projection is explicit rather than relying on the `output` list
|
|
383
|
+
// above. Extra keys are tolerated by the engine (verified), so this is
|
|
384
|
+
// not load-bearing today — it is load-bearing the moment someone adds a
|
|
385
|
+
// column to the message table, at which point the shape the provider
|
|
386
|
+
// sees stops depending on an `output` list edited in a different file.
|
|
387
|
+
s.array.map({
|
|
388
|
+
source: withFilters(ref("recent"), fl.reverse()),
|
|
389
|
+
transform: { role: ref("$this.role"), content: ref("$this.content") },
|
|
390
|
+
as: "turns"
|
|
391
|
+
}),
|
|
392
|
+
// 4. Render to JSON text. `messages` is a Twig-templated STRING, so the
|
|
393
|
+
// array has to arrive as its JSON serialization; the engine decodes it
|
|
394
|
+
// back into real message roles on the other side (verified — AGENTS.md).
|
|
395
|
+
s.set_var("messages_json", withFilters(ref("turns"), fl.json_encode())),
|
|
396
|
+
// 5. Run the agent. `args.messages` is what `{{ $args.messages }}` in the
|
|
397
|
+
// agent's `messages` template resolves to.
|
|
398
|
+
s.ai.agent.run({
|
|
399
|
+
agent: chatAgent2,
|
|
400
|
+
args: obj({ messages: ref("messages_json") }),
|
|
401
|
+
// Attaching tools to the agent is NOT enough — the RUN has to permit
|
|
402
|
+
// executing them. Without this the model is told the tools exist and can
|
|
403
|
+
// never call one, so it answers "I don't have access to that" and the
|
|
404
|
+
// whole feature silently does nothing (observed live before this line
|
|
405
|
+
// existed). Emitted only when tools are configured, so a tool-less
|
|
406
|
+
// install's bundle is unchanged.
|
|
407
|
+
...opts.tools.length > 0 ? { allowToolExecution: c.bool(true) } : {},
|
|
408
|
+
as: "run"
|
|
409
|
+
}),
|
|
410
|
+
// 5b. The names of the tools this run actually executed — the only
|
|
411
|
+
// observable a client has that a tool ran at all. It exists because a
|
|
412
|
+
// live debugging session cost three deploy cycles when `tool_calls`
|
|
413
|
+
// came back null whether a tool had run, thrown, or never been called
|
|
414
|
+
// (see AGENTS.md).
|
|
415
|
+
//
|
|
416
|
+
// ⚠ WHERE the calls live was settled against a live instance, because
|
|
417
|
+
// the two obvious answers are both wrong. This package used to read
|
|
418
|
+
// `run.tool_calls`, a key the envelope does not carry. Core types the
|
|
419
|
+
// envelope with a top-level `toolCalls` — which IS present and is
|
|
420
|
+
// always `[]` on a live run, even one whose tool wrote a row. The
|
|
421
|
+
// calls are in
|
|
422
|
+
// `steps[].content[]`: one entry of `type: "tool-call"` carrying
|
|
423
|
+
// `toolName`, followed by a `tool-result` entry carrying the tool's
|
|
424
|
+
// whole return value.
|
|
425
|
+
//
|
|
426
|
+
// NAMES only, never the entries. A tool-call carries the arguments the
|
|
427
|
+
// model produced and a tool-result carries what the tool returned;
|
|
428
|
+
// both can hold data the caller never asked for. The name is the whole
|
|
429
|
+
// of what a client needs to know a tool ran.
|
|
430
|
+
//
|
|
431
|
+
// A loop rather than the one-line `fl.map` lambda that also works
|
|
432
|
+
// (verified). Core is explicit that a lambda is an escape hatch drawing
|
|
433
|
+
// on a workspace-wide worker pool, and every send in every install
|
|
434
|
+
// would pay it — for work four ordinary statements express.
|
|
435
|
+
//
|
|
436
|
+
// Emitted only when tools are configured, so a tool-less install's
|
|
437
|
+
// bundle is unchanged — the same rule as `allowToolExecution` above.
|
|
438
|
+
...opts.tools.length > 0 ? [
|
|
439
|
+
// `fl.get` with a default rather than `ref(..., { safe: true })`:
|
|
440
|
+
// the safe form defaults to NULL, and looping over null is not a
|
|
441
|
+
// shape worth relying on. No steps means no names.
|
|
442
|
+
s.set_var("run_steps", withFilters(ref("run"), fl.get(c.text("steps"), c.array([])))),
|
|
443
|
+
s.set_var("tool_call_names", c.array([])),
|
|
444
|
+
s.foreach({
|
|
445
|
+
as: "step",
|
|
446
|
+
list: ref("run_steps"),
|
|
447
|
+
body: [
|
|
448
|
+
// `index_by` groups the step's parts by `type` and `get` takes
|
|
449
|
+
// one group — which is how the tool-CALLS are kept apart from
|
|
450
|
+
// the tool-RESULTS. Both carry `toolName`, so skipping this
|
|
451
|
+
// would report every call twice.
|
|
452
|
+
s.set_var(
|
|
453
|
+
"step_calls",
|
|
454
|
+
withFilters(
|
|
455
|
+
ref("step.content", { safe: true }),
|
|
456
|
+
fl.safe_array(),
|
|
457
|
+
fl.index_by(c.text("type")),
|
|
458
|
+
fl.get(c.text("tool-call"), c.array([]))
|
|
459
|
+
)
|
|
460
|
+
),
|
|
461
|
+
s.array.map({
|
|
462
|
+
source: ref("step_calls"),
|
|
463
|
+
as: "step_names",
|
|
464
|
+
// The entry's key for the name is not a contract this package
|
|
465
|
+
// owns, so all three spellings are tried and the first
|
|
466
|
+
// non-null wins. Safe refs throughout: a missing key must
|
|
467
|
+
// resolve to null, not take the request down.
|
|
468
|
+
transform: withFilters(
|
|
469
|
+
ref("$this.toolName", { safe: true }),
|
|
470
|
+
fl.first_notnull(ref("$this.tool_name", { safe: true })),
|
|
471
|
+
fl.first_notnull(ref("$this.name", { safe: true }))
|
|
472
|
+
)
|
|
473
|
+
}),
|
|
474
|
+
// Append, so a tool called twice is reported twice and the order
|
|
475
|
+
// is the order the model called them in.
|
|
476
|
+
s.set_var(
|
|
477
|
+
"tool_call_names",
|
|
478
|
+
withFilters(ref("tool_call_names"), fl.array_merge(ref("step_names")))
|
|
479
|
+
)
|
|
480
|
+
]
|
|
481
|
+
})
|
|
482
|
+
] : [],
|
|
483
|
+
// 6. A blank completion would fail the message table's `min:1` check as an
|
|
484
|
+
// opaque column-validation error naming a column the caller never sent.
|
|
485
|
+
// Report it as what it is instead: the model returned nothing.
|
|
486
|
+
s.precondition({
|
|
487
|
+
expr: expr(ref("run.result"), "!=", c.text("")),
|
|
488
|
+
error_type: "standard",
|
|
489
|
+
error: c.text("The assistant returned an empty reply. Try again.")
|
|
490
|
+
}),
|
|
491
|
+
// 7. The assistant's turn.
|
|
492
|
+
s.db.add({
|
|
493
|
+
table: message,
|
|
494
|
+
as: "assistant_message",
|
|
495
|
+
data: [
|
|
496
|
+
{ name: "created_at", value: c.text("now") },
|
|
497
|
+
{ name: "conversation_id", value: inp("conversation_id") },
|
|
498
|
+
{ name: "role", value: c.text("assistant") },
|
|
499
|
+
{ name: "content", value: ref("run.result") }
|
|
500
|
+
]
|
|
501
|
+
}),
|
|
502
|
+
// 8. Touch the thread so a conversation list can sort by recency without
|
|
503
|
+
// joining the message table. Read first, because the title is seeded
|
|
504
|
+
// from the first message only when it is still blank.
|
|
505
|
+
s.db.get({
|
|
506
|
+
table: conversation,
|
|
507
|
+
fieldName: "id",
|
|
508
|
+
fieldValue: inp("conversation_id"),
|
|
509
|
+
output: ["id", "title"],
|
|
510
|
+
as: "conversation"
|
|
511
|
+
}),
|
|
512
|
+
s.conditional({
|
|
513
|
+
when: expr(ref("conversation.title"), "=", c.text("")),
|
|
514
|
+
then: [
|
|
515
|
+
s.db.edit({
|
|
516
|
+
table: conversation,
|
|
517
|
+
fieldName: "id",
|
|
518
|
+
fieldValue: inp("conversation_id"),
|
|
519
|
+
data: [
|
|
520
|
+
{ name: "last_message_at", value: c.text("now") },
|
|
521
|
+
// First message wins the title, truncated. `substr` rather than a
|
|
522
|
+
// model-generated summary on purpose: titling is not worth a second
|
|
523
|
+
// LLM call on the request path, and a deterministic prefix cannot
|
|
524
|
+
// fail, cost tokens, or return something unsafe to display.
|
|
525
|
+
{
|
|
526
|
+
name: "title",
|
|
527
|
+
value: withFilters(inp("content"), fl.substr(c.int(0), c.int(TITLE_LENGTH)))
|
|
528
|
+
}
|
|
529
|
+
]
|
|
530
|
+
})
|
|
531
|
+
],
|
|
532
|
+
else: [
|
|
533
|
+
s.db.edit({
|
|
534
|
+
table: conversation,
|
|
535
|
+
fieldName: "id",
|
|
536
|
+
fieldValue: inp("conversation_id"),
|
|
537
|
+
data: [{ name: "last_message_at", value: c.text("now") }]
|
|
538
|
+
})
|
|
539
|
+
]
|
|
540
|
+
})
|
|
541
|
+
],
|
|
542
|
+
response: {
|
|
543
|
+
conversation_id: inp("conversation_id"),
|
|
544
|
+
reply: ref("run.result"),
|
|
545
|
+
message_id: ref("assistant_message.id"),
|
|
546
|
+
// Always an ARRAY of tool names, never null — see step 5b. A tool-less
|
|
547
|
+
// install returns the empty array as a constant: no tool can have run, and
|
|
548
|
+
// a client's `tool_calls.length` should not have to branch on how the bot
|
|
549
|
+
// was configured. `filter_null` drops any record whose name could not be
|
|
550
|
+
// read, so the field never reports a call it cannot name.
|
|
551
|
+
tool_calls: opts.tools.length > 0 ? withFilters(ref("tool_call_names"), fl.filter_null()) : c.array([])
|
|
552
|
+
},
|
|
553
|
+
// Declared for the same reason the send endpoints declare it (see
|
|
554
|
+
// `api/types.ts`), and load-bearing for the build: a `function.run` of this
|
|
555
|
+
// def carries `InferResponse` of it, and the derived shape
|
|
556
|
+
// is too deep for the dts emit of either send endpoint (TS2589).
|
|
557
|
+
responseShape: {}
|
|
558
|
+
});
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/api/group.ts
|
|
562
|
+
import { apiGroup } from "@xano/sdk";
|
|
563
|
+
function chatbotGroup(opts) {
|
|
564
|
+
return apiGroup({
|
|
565
|
+
name: opts.names.apiGroup,
|
|
566
|
+
description: "Conversational AI endpoints: create and list conversations, read a transcript, and send a message to the agent.",
|
|
567
|
+
tags: opts.tags,
|
|
568
|
+
// Only set when pinned — leaving it undefined is what defers identity to the
|
|
569
|
+
// consumer's `xano.lock` (or to a random segment assigned at import).
|
|
570
|
+
...opts.canonical !== void 0 ? { canonical: opts.canonical } : {},
|
|
571
|
+
history: opts.history
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// src/api/authenticated.ts
|
|
576
|
+
import { query, input as input2, s as s2, c as c2, inp as inp2, ref as ref2, expr as expr2, col as col2, auth, withFilters as withFilters2, fl as fl2, statements, setVar } from "@xano/sdk";
|
|
577
|
+
var ownershipGuard = (conversation) => statements(
|
|
578
|
+
s2.db.get({
|
|
579
|
+
table: conversation,
|
|
580
|
+
fieldName: "id",
|
|
581
|
+
fieldValue: inp2("conversation_id"),
|
|
582
|
+
output: ["id", "user_id"],
|
|
583
|
+
as: "conversation"
|
|
584
|
+
}),
|
|
585
|
+
s2.precondition({
|
|
586
|
+
expr: expr2(ref2("conversation.user_id", { safe: true }), "=", auth("id")),
|
|
587
|
+
error_type: "notfound",
|
|
588
|
+
error: c2.text("Conversation not found.")
|
|
589
|
+
})
|
|
590
|
+
);
|
|
591
|
+
function authenticatedQueries(opts, group, conversation, message, replyFn) {
|
|
592
|
+
const base = { apiGroup: group, auth: opts.authTable, tags: opts.tags };
|
|
593
|
+
const p = opts.routePrefix;
|
|
594
|
+
const mintSessionToken = s2.security.create_uuid({ as: "session_token" });
|
|
595
|
+
const addConversation = (withToken) => s2.db.add({
|
|
596
|
+
table: conversation,
|
|
597
|
+
as: "conversation",
|
|
598
|
+
output: PUBLIC_CONVERSATION_FIELDS,
|
|
599
|
+
data: [
|
|
600
|
+
{ name: "created_at", value: c2.text("now") },
|
|
601
|
+
{ name: "user_id", value: auth("id") },
|
|
602
|
+
// Truncated to the same length the reply function's auto-title uses, so
|
|
603
|
+
// a caller-supplied title and a generated one cannot differ in bound.
|
|
604
|
+
{ name: "title", value: withFilters2(inp2("title"), fl2.substr(c2.int(0), c2.int(TITLE_LENGTH))) },
|
|
605
|
+
// Never returned: absent from PUBLIC_CONVERSATION_FIELDS, and the column
|
|
606
|
+
// is `internal`.
|
|
607
|
+
...withToken ? [{ name: "session_token", value: ref2("session_token") }] : []
|
|
608
|
+
]
|
|
609
|
+
});
|
|
610
|
+
const createConversation = query({
|
|
611
|
+
...base,
|
|
612
|
+
name: `${p}/conversations/create`,
|
|
613
|
+
verb: "POST",
|
|
614
|
+
description: "Create a new conversation owned by the authenticated user",
|
|
615
|
+
input: {
|
|
616
|
+
title: input2.text({
|
|
617
|
+
methods: ["trim"],
|
|
618
|
+
description: "Optional thread title. Left blank, the first message's opening words become the title."
|
|
619
|
+
})
|
|
620
|
+
},
|
|
621
|
+
// Two `statements(...)` branches rather than one array with a
|
|
622
|
+
// `...(opts.guest ? [x] : [])` spread. That spread is not a fixed tuple, so
|
|
623
|
+
// it widens the stack and resolves this endpoint's `ref("conversation")` —
|
|
624
|
+
// and therefore its whole response type — to `unknown` in every consumer.
|
|
625
|
+
// Same trap as a `Statement[]` helper; see `ownershipGuard` above.
|
|
626
|
+
//
|
|
627
|
+
// The `data` array below keeps its conditional spread: it is a statement
|
|
628
|
+
// FIELD, not the stack, so no `as`/`ref()` inference depends on its tuple.
|
|
629
|
+
stack: opts.guest ? statements(mintSessionToken, addConversation(true)) : statements(addConversation(false)),
|
|
630
|
+
response: ref2("conversation")
|
|
631
|
+
});
|
|
632
|
+
const listConversations = query({
|
|
633
|
+
...base,
|
|
634
|
+
name: `${p}/conversations`,
|
|
635
|
+
verb: "GET",
|
|
636
|
+
description: "List the authenticated user's conversations, most recently active first",
|
|
637
|
+
input: {},
|
|
638
|
+
stack: [
|
|
639
|
+
s2.db.query({
|
|
640
|
+
table: conversation,
|
|
641
|
+
where: expr2(col2("user_id"), "=", auth("id")),
|
|
642
|
+
output: PUBLIC_CONVERSATION_FIELDS,
|
|
643
|
+
// `last_message_at` is null until the first send, so a brand-new empty
|
|
644
|
+
// thread sorts last under `desc`. `id desc` is the tiebreak that keeps it
|
|
645
|
+
// visible at a stable position rather than shuffling between requests.
|
|
646
|
+
sort: [
|
|
647
|
+
{ sortBy: "last_message_at", dir: "desc" },
|
|
648
|
+
{ sortBy: "id", dir: "desc" }
|
|
649
|
+
],
|
|
650
|
+
paging: { per_page: opts.listLimit, metadata: false },
|
|
651
|
+
as: "conversations"
|
|
652
|
+
})
|
|
653
|
+
],
|
|
654
|
+
response: ref2("conversations")
|
|
655
|
+
});
|
|
656
|
+
const listMessages = query({
|
|
657
|
+
...base,
|
|
658
|
+
name: `${p}/conversations/{conversation_id}/messages`,
|
|
659
|
+
verb: "GET",
|
|
660
|
+
description: "Read a conversation's transcript, oldest message first",
|
|
661
|
+
input: { conversation_id: input2.int({ required: true }) },
|
|
662
|
+
stack: [
|
|
663
|
+
...ownershipGuard(conversation),
|
|
664
|
+
// Newest-N in the database, then reversed for display — the same shape the
|
|
665
|
+
// reply function uses, and for the same reason: a capped read. A thread
|
|
666
|
+
// longer than the cap loses its OLDEST messages from this response, which
|
|
667
|
+
// is the right end to drop for a chat UI that renders from the bottom.
|
|
668
|
+
s2.db.query({
|
|
669
|
+
table: message,
|
|
670
|
+
where: expr2(col2("conversation_id"), "=", inp2("conversation_id")),
|
|
671
|
+
output: PUBLIC_MESSAGE_FIELDS,
|
|
672
|
+
sort: [
|
|
673
|
+
{ sortBy: "created_at", dir: "desc" },
|
|
674
|
+
{ sortBy: "id", dir: "desc" }
|
|
675
|
+
],
|
|
676
|
+
paging: { per_page: opts.transcriptLimit, metadata: false },
|
|
677
|
+
as: "recent"
|
|
678
|
+
}),
|
|
679
|
+
s2.set_var("messages", withFilters2(ref2("recent"), fl2.reverse()))
|
|
680
|
+
],
|
|
681
|
+
response: ref2("messages"),
|
|
682
|
+
// Declared, not derived — the same exception `sendMessage` makes, for the
|
|
683
|
+
// same reason. The response is a `set_var` bound to an UNTYPED filter
|
|
684
|
+
// (`fl.reverse` is `typed: false`, result `<T>[]`), and core's static walk
|
|
685
|
+
// resolves both a `set_var` output and an untyped filter's result to
|
|
686
|
+
// `unknown`. Without this the transcript endpoint hands every consumer
|
|
687
|
+
// `unknown` and the frontend loses the projection entirely.
|
|
688
|
+
//
|
|
689
|
+
// The shape is not a guess: `output: PUBLIC_MESSAGE_FIELDS` is what the
|
|
690
|
+
// db.query above projects, and `PublicMessage` is derived from that same
|
|
691
|
+
// array — so editing the array still moves this type.
|
|
692
|
+
responseShape: []
|
|
693
|
+
});
|
|
694
|
+
const sendRateLimit = opts.rateLimit ? statements(
|
|
695
|
+
s2.redis.ratelimit({
|
|
696
|
+
key: withFilters2(c2.text(`${p}:send:`), fl2.concat(auth("id"))),
|
|
697
|
+
max: c2.int(opts.rateLimit.max),
|
|
698
|
+
ttl: c2.int(opts.rateLimit.ttl),
|
|
699
|
+
error: c2.text(opts.rateLimit.error)
|
|
700
|
+
})
|
|
701
|
+
) : statements();
|
|
702
|
+
const sendMessage = query({
|
|
703
|
+
...base,
|
|
704
|
+
name: `${p}/conversations/{conversation_id}/send`,
|
|
705
|
+
verb: "POST",
|
|
706
|
+
description: "Append a message to the conversation and return the agent's reply",
|
|
707
|
+
input: {
|
|
708
|
+
conversation_id: input2.int({ required: true }),
|
|
709
|
+
// `trim` is load-bearing, not cosmetic. Without it a whitespace-only
|
|
710
|
+
// message (" ") passes `required` (length 3) AND passes the reply
|
|
711
|
+
// function's `content != ""` guard, so a blank turn reaches the model,
|
|
712
|
+
// which confabulates — verified live: it replied "I can't respond to an
|
|
713
|
+
// empty message" and BOTH turns were written to the transcript, poisoning
|
|
714
|
+
// every later turn's context. Trimmed, " " becomes "" and the engine
|
|
715
|
+
// refuses it up front as a missing param, like any other empty content.
|
|
716
|
+
content: input2.text({
|
|
717
|
+
required: false,
|
|
718
|
+
methods: ["trim"],
|
|
719
|
+
description: "The user's message. Must be non-empty after trimming."
|
|
720
|
+
}),
|
|
721
|
+
message: input2.text({
|
|
722
|
+
required: false,
|
|
723
|
+
methods: ["trim"],
|
|
724
|
+
description: "Alias for `content`."
|
|
725
|
+
}),
|
|
726
|
+
prompt: input2.text({
|
|
727
|
+
required: false,
|
|
728
|
+
methods: ["trim"],
|
|
729
|
+
description: "Alias for `content`."
|
|
730
|
+
})
|
|
731
|
+
},
|
|
732
|
+
stack: [
|
|
733
|
+
...sendRateLimit,
|
|
734
|
+
...ownershipGuard(conversation),
|
|
735
|
+
setVar(
|
|
736
|
+
"resolved_turn_content",
|
|
737
|
+
withFilters2(inp2("content"), [
|
|
738
|
+
fl2.first_notempty(inp2("message")),
|
|
739
|
+
fl2.first_notempty(inp2("prompt"))
|
|
740
|
+
])
|
|
741
|
+
),
|
|
742
|
+
s2.precondition({
|
|
743
|
+
expr: expr2(ref2("resolved_turn_content"), "!=", c2.text("")),
|
|
744
|
+
error_type: "badrequest",
|
|
745
|
+
error: c2.text("send: non-empty message content must be provided via `content`, `message`, or `prompt`.")
|
|
746
|
+
}),
|
|
747
|
+
// Everything past the guard is shared with the guest family — see
|
|
748
|
+
// `functions/generate-reply.ts`.
|
|
749
|
+
s2.function.run({
|
|
750
|
+
fn: replyFn,
|
|
751
|
+
as: "reply",
|
|
752
|
+
input: { conversation_id: inp2("conversation_id"), content: ref2("resolved_turn_content") }
|
|
753
|
+
})
|
|
754
|
+
],
|
|
755
|
+
response: ref2("reply"),
|
|
756
|
+
// Declared, not derived: `reply` is the agent run's `.result`, which core
|
|
757
|
+
// resolves to `unknown` because the agent carries no structured-output
|
|
758
|
+
// schema. See `api/types.ts`.
|
|
759
|
+
responseShape: {}
|
|
760
|
+
});
|
|
761
|
+
const deleteConversation = query({
|
|
762
|
+
...base,
|
|
763
|
+
name: `${p}/conversations/{conversation_id}`,
|
|
764
|
+
verb: "DELETE",
|
|
765
|
+
description: "Delete a conversation and every message in it",
|
|
766
|
+
input: { conversation_id: input2.int({ required: true }) },
|
|
767
|
+
stack: [
|
|
768
|
+
...ownershipGuard(conversation),
|
|
769
|
+
// Messages first. The other order would leave orphaned message rows behind
|
|
770
|
+
// if the second delete failed, and nothing would ever collect them — the FK
|
|
771
|
+
// is a reference, not a cascade.
|
|
772
|
+
s2.db.bulk.delete({
|
|
773
|
+
table: message,
|
|
774
|
+
where: expr2(col2("conversation_id"), "=", inp2("conversation_id"))
|
|
775
|
+
}),
|
|
776
|
+
s2.db.del({ table: conversation, fieldName: "id", fieldValue: inp2("conversation_id") })
|
|
777
|
+
],
|
|
778
|
+
// No body. The thread is gone; there is nothing truthful to return about it.
|
|
779
|
+
response: c2.null()
|
|
780
|
+
});
|
|
781
|
+
const claimConversation = opts.guest ? query({
|
|
782
|
+
...base,
|
|
783
|
+
name: `${p}/conversations/{conversation_id}/claim`,
|
|
784
|
+
verb: "POST",
|
|
785
|
+
description: "Attach an unclaimed guest conversation to the authenticated user",
|
|
786
|
+
input: {
|
|
787
|
+
conversation_id: input2.int({ required: true }),
|
|
788
|
+
session_token: input2.text({
|
|
789
|
+
required: true,
|
|
790
|
+
description: "The guest session token the thread was created with."
|
|
791
|
+
})
|
|
792
|
+
},
|
|
793
|
+
stack: [
|
|
794
|
+
s2.db.get({
|
|
795
|
+
table: conversation,
|
|
796
|
+
fieldName: "id",
|
|
797
|
+
fieldValue: inp2("conversation_id"),
|
|
798
|
+
// `session_token` is an `internal` column, so it must be named
|
|
799
|
+
// explicitly — an `output` list overrides column visibility. It stays
|
|
800
|
+
// inside this stack and is never returned.
|
|
801
|
+
output: ["id", "user_id", "session_token"],
|
|
802
|
+
as: "conversation"
|
|
803
|
+
}),
|
|
804
|
+
// The token must match. Same `notfound` as everywhere else, so a wrong
|
|
805
|
+
// token cannot be distinguished from a wrong id.
|
|
806
|
+
s2.precondition({
|
|
807
|
+
expr: expr2(ref2("conversation.session_token", { safe: true }), "=", inp2("session_token")),
|
|
808
|
+
error_type: "notfound",
|
|
809
|
+
error: c2.text("Conversation not found.")
|
|
810
|
+
}),
|
|
811
|
+
// And it must be UNCLAIMED. Without this, anyone who ever held the guest
|
|
812
|
+
// token could re-claim the thread away from its current owner — the
|
|
813
|
+
// token outlives the guest phase, so this is what ends its authority.
|
|
814
|
+
s2.precondition({
|
|
815
|
+
expr: expr2(ref2("conversation.user_id", { safe: true }), "=", c2.null()),
|
|
816
|
+
error_type: "accessdenied",
|
|
817
|
+
error: c2.text("This conversation has already been claimed.")
|
|
818
|
+
}),
|
|
819
|
+
s2.db.edit({
|
|
820
|
+
table: conversation,
|
|
821
|
+
fieldName: "id",
|
|
822
|
+
fieldValue: inp2("conversation_id"),
|
|
823
|
+
output: PUBLIC_CONVERSATION_FIELDS,
|
|
824
|
+
data: [{ name: "user_id", value: auth("id") }],
|
|
825
|
+
as: "claimed"
|
|
826
|
+
})
|
|
827
|
+
],
|
|
828
|
+
response: ref2("claimed")
|
|
829
|
+
}) : void 0;
|
|
830
|
+
return {
|
|
831
|
+
createConversation,
|
|
832
|
+
listConversations,
|
|
833
|
+
listMessages,
|
|
834
|
+
sendMessage,
|
|
835
|
+
deleteConversation,
|
|
836
|
+
/** `undefined` unless the guest family is also enabled — see above. */
|
|
837
|
+
claimConversation,
|
|
838
|
+
/**
|
|
839
|
+
* Every def above that actually exists, ready to register. Built as one array
|
|
840
|
+
* literal rather than a `push`, so the element type is the union of the
|
|
841
|
+
* handles rather than the first one's — each `query()` handle carries its own
|
|
842
|
+
* literal `name` in its type, so a mutable array would fix that type to
|
|
843
|
+
* whichever def happened to be first.
|
|
844
|
+
*/
|
|
845
|
+
all: [
|
|
846
|
+
createConversation,
|
|
847
|
+
listConversations,
|
|
848
|
+
listMessages,
|
|
849
|
+
sendMessage,
|
|
850
|
+
deleteConversation,
|
|
851
|
+
...claimConversation ? [claimConversation] : []
|
|
852
|
+
]
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
// src/api/guest.ts
|
|
857
|
+
import { query as query2, input as input3, s as s3, c as c3, inp as inp3, ref as ref3, expr as expr3, col as col3, withFilters as withFilters3, fl as fl3, statements as statements2, sys, setVar as setVar2 } from "@xano/sdk";
|
|
858
|
+
var capabilityGuard = (conversation, authenticated) => {
|
|
859
|
+
const nonEmptyToken = s3.precondition({
|
|
860
|
+
expr: expr3(inp3("session_token"), "!=", c3.text("")),
|
|
861
|
+
error_type: "notfound",
|
|
862
|
+
error: c3.text("Conversation not found.")
|
|
863
|
+
});
|
|
864
|
+
const load = s3.db.get({
|
|
865
|
+
table: conversation,
|
|
866
|
+
fieldName: "id",
|
|
867
|
+
fieldValue: inp3("conversation_id"),
|
|
868
|
+
// `session_token` is `internal`; an explicit `output` list is what makes it
|
|
869
|
+
// readable inside the stack. It is never placed in a response.
|
|
870
|
+
output: authenticated ? ["id", "user_id", "session_token"] : ["id", "session_token"],
|
|
871
|
+
as: "conversation"
|
|
872
|
+
});
|
|
873
|
+
const tokenMatches = s3.precondition({
|
|
874
|
+
expr: expr3(ref3("conversation.session_token", { safe: true }), "=", inp3("session_token")),
|
|
875
|
+
error_type: "notfound",
|
|
876
|
+
error: c3.text("Conversation not found.")
|
|
877
|
+
});
|
|
878
|
+
const stillUnclaimed = s3.precondition({
|
|
879
|
+
expr: expr3(ref3("conversation.user_id", { safe: true }), "=", c3.null()),
|
|
880
|
+
error_type: "accessdenied",
|
|
881
|
+
error: c3.text("This conversation now belongs to an account. Sign in to continue it.")
|
|
882
|
+
});
|
|
883
|
+
return authenticated ? statements2(nonEmptyToken, load, tokenMatches, stillUnclaimed) : statements2(nonEmptyToken, load, tokenMatches);
|
|
884
|
+
};
|
|
885
|
+
function guestQueries(opts, group, conversation, message, replyFn) {
|
|
886
|
+
const base = { apiGroup: group, tags: opts.tags };
|
|
887
|
+
const guard = capabilityGuard(conversation, opts.authenticated);
|
|
888
|
+
const p = opts.routePrefix;
|
|
889
|
+
const limit = (scope) => opts.rateLimit ? statements2(
|
|
890
|
+
s3.redis.ratelimit({
|
|
891
|
+
key: withFilters3(c3.text(`${p}:guest:${scope}:`), fl3.concat(sys.remoteIp())),
|
|
892
|
+
max: c3.int(opts.rateLimit.max),
|
|
893
|
+
ttl: c3.int(opts.rateLimit.ttl),
|
|
894
|
+
error: c3.text(opts.rateLimit.error)
|
|
895
|
+
})
|
|
896
|
+
) : statements2();
|
|
897
|
+
const createConversation = query2({
|
|
898
|
+
...base,
|
|
899
|
+
name: `${p}/guest/conversations/create`,
|
|
900
|
+
verb: "POST",
|
|
901
|
+
description: "Create an anonymous conversation and return its session token",
|
|
902
|
+
input: {
|
|
903
|
+
title: input3.text({
|
|
904
|
+
methods: ["trim"],
|
|
905
|
+
description: "Optional thread title. Left blank, the first message's opening words become the title."
|
|
906
|
+
})
|
|
907
|
+
},
|
|
908
|
+
stack: [
|
|
909
|
+
...limit("create"),
|
|
910
|
+
s3.security.create_uuid({ as: "session_token" }),
|
|
911
|
+
s3.db.add({
|
|
912
|
+
table: conversation,
|
|
913
|
+
as: "conversation",
|
|
914
|
+
output: PUBLIC_CONVERSATION_FIELDS,
|
|
915
|
+
data: [
|
|
916
|
+
{ name: "created_at", value: c3.text("now") },
|
|
917
|
+
{ name: "session_token", value: ref3("session_token") },
|
|
918
|
+
{ name: "title", value: withFilters3(inp3("title"), fl3.substr(c3.int(0), c3.int(TITLE_LENGTH))) }
|
|
919
|
+
// `user_id` is deliberately not written. It stays null, which is what
|
|
920
|
+
// marks the thread claimable.
|
|
921
|
+
]
|
|
922
|
+
})
|
|
923
|
+
],
|
|
924
|
+
// Spread the conversation projection, then add the token beside it. The token
|
|
925
|
+
// comes from `ref("session_token")` — the minted value — NOT from the written
|
|
926
|
+
// row, so the `internal` column never has to be read back to serve it.
|
|
927
|
+
response: {
|
|
928
|
+
id: ref3("conversation.id"),
|
|
929
|
+
created_at: ref3("conversation.created_at"),
|
|
930
|
+
title: ref3("conversation.title"),
|
|
931
|
+
last_message_at: ref3("conversation.last_message_at"),
|
|
932
|
+
session_token: ref3("session_token")
|
|
933
|
+
}
|
|
934
|
+
});
|
|
935
|
+
const listMessages = query2({
|
|
936
|
+
...base,
|
|
937
|
+
name: `${p}/guest/conversations/{conversation_id}/messages`,
|
|
938
|
+
verb: "GET",
|
|
939
|
+
description: "Read a guest conversation's transcript, oldest message first",
|
|
940
|
+
input: {
|
|
941
|
+
conversation_id: input3.int({ required: true }),
|
|
942
|
+
session_token: input3.text({
|
|
943
|
+
required: true,
|
|
944
|
+
description: "The token returned when the conversation was created."
|
|
945
|
+
})
|
|
946
|
+
},
|
|
947
|
+
stack: [
|
|
948
|
+
...guard,
|
|
949
|
+
s3.db.query({
|
|
950
|
+
table: message,
|
|
951
|
+
where: expr3(col3("conversation_id"), "=", inp3("conversation_id")),
|
|
952
|
+
output: PUBLIC_MESSAGE_FIELDS,
|
|
953
|
+
sort: [
|
|
954
|
+
{ sortBy: "created_at", dir: "desc" },
|
|
955
|
+
{ sortBy: "id", dir: "desc" }
|
|
956
|
+
],
|
|
957
|
+
paging: { per_page: opts.transcriptLimit, metadata: false },
|
|
958
|
+
as: "recent"
|
|
959
|
+
}),
|
|
960
|
+
s3.set_var("messages", withFilters3(ref3("recent"), fl3.reverse()))
|
|
961
|
+
],
|
|
962
|
+
response: ref3("messages"),
|
|
963
|
+
// Declared, not derived — the same exception `sendMessage` makes, for the
|
|
964
|
+
// same reason. The response is a `set_var` bound to an UNTYPED filter
|
|
965
|
+
// (`fl.reverse` is `typed: false`, result `<T>[]`), and core's static walk
|
|
966
|
+
// resolves both a `set_var` output and an untyped filter's result to
|
|
967
|
+
// `unknown`. Without this the transcript endpoint hands every consumer
|
|
968
|
+
// `unknown` and the frontend loses the projection entirely.
|
|
969
|
+
//
|
|
970
|
+
// The shape is not a guess: `output: PUBLIC_MESSAGE_FIELDS` is what the
|
|
971
|
+
// db.query above projects, and `PublicMessage` is derived from that same
|
|
972
|
+
// array — so editing the array still moves this type.
|
|
973
|
+
responseShape: []
|
|
974
|
+
});
|
|
975
|
+
const sendMessage = query2({
|
|
976
|
+
...base,
|
|
977
|
+
name: `${p}/guest/conversations/{conversation_id}/send`,
|
|
978
|
+
verb: "POST",
|
|
979
|
+
description: "Append a message to a guest conversation and return the agent's reply",
|
|
980
|
+
input: {
|
|
981
|
+
conversation_id: input3.int({ required: true }),
|
|
982
|
+
session_token: input3.text({ required: true }),
|
|
983
|
+
// `trim` is load-bearing, not cosmetic. Without it a whitespace-only
|
|
984
|
+
// message (" ") passes `required` (length 3) AND passes the reply
|
|
985
|
+
// function's `content != ""` guard, so a blank turn reaches the model,
|
|
986
|
+
// which confabulates — verified live: it replied "I can't respond to an
|
|
987
|
+
// empty message" and BOTH turns were written to the transcript, poisoning
|
|
988
|
+
// every later turn's context. Trimmed, " " becomes "" and the engine
|
|
989
|
+
// refuses it up front as a missing param, like any other empty content.
|
|
990
|
+
content: input3.text({
|
|
991
|
+
required: false,
|
|
992
|
+
methods: ["trim"],
|
|
993
|
+
description: "The user's message. Must be non-empty after trimming."
|
|
994
|
+
}),
|
|
995
|
+
message: input3.text({
|
|
996
|
+
required: false,
|
|
997
|
+
methods: ["trim"],
|
|
998
|
+
description: "Alias for `content`."
|
|
999
|
+
}),
|
|
1000
|
+
prompt: input3.text({
|
|
1001
|
+
required: false,
|
|
1002
|
+
methods: ["trim"],
|
|
1003
|
+
description: "Alias for `content`."
|
|
1004
|
+
})
|
|
1005
|
+
},
|
|
1006
|
+
stack: [
|
|
1007
|
+
...limit("send"),
|
|
1008
|
+
...guard,
|
|
1009
|
+
setVar2(
|
|
1010
|
+
"resolved_turn_content",
|
|
1011
|
+
withFilters3(inp3("content"), [
|
|
1012
|
+
fl3.first_notempty(inp3("message")),
|
|
1013
|
+
fl3.first_notempty(inp3("prompt"))
|
|
1014
|
+
])
|
|
1015
|
+
),
|
|
1016
|
+
s3.precondition({
|
|
1017
|
+
expr: expr3(ref3("resolved_turn_content"), "!=", c3.text("")),
|
|
1018
|
+
error_type: "badrequest",
|
|
1019
|
+
error: c3.text("send: non-empty message content must be provided via `content`, `message`, or `prompt`.")
|
|
1020
|
+
}),
|
|
1021
|
+
s3.function.run({
|
|
1022
|
+
fn: replyFn,
|
|
1023
|
+
as: "reply",
|
|
1024
|
+
input: { conversation_id: inp3("conversation_id"), content: ref3("resolved_turn_content") }
|
|
1025
|
+
})
|
|
1026
|
+
],
|
|
1027
|
+
response: ref3("reply"),
|
|
1028
|
+
// Declared, not derived: `reply` is the agent run's `.result`, which core
|
|
1029
|
+
// resolves to `unknown` because the agent carries no structured-output
|
|
1030
|
+
// schema. See `api/types.ts`.
|
|
1031
|
+
responseShape: {}
|
|
1032
|
+
});
|
|
1033
|
+
const deleteConversation = query2({
|
|
1034
|
+
...base,
|
|
1035
|
+
// A POST, not a DELETE. The token is the credential and it would have to
|
|
1036
|
+
// travel as a query string on a DELETE — into access logs, proxies and
|
|
1037
|
+
// `Referer` headers. A body keeps it out of the URL. Deliberate deviation
|
|
1038
|
+
// from the authenticated family, whose DELETE carries no secret in the URL.
|
|
1039
|
+
name: `${p}/guest/conversations/{conversation_id}/delete`,
|
|
1040
|
+
verb: "POST",
|
|
1041
|
+
description: "Delete a guest conversation and every message in it",
|
|
1042
|
+
input: {
|
|
1043
|
+
conversation_id: input3.int({ required: true }),
|
|
1044
|
+
session_token: input3.text({ required: true })
|
|
1045
|
+
},
|
|
1046
|
+
stack: [
|
|
1047
|
+
...guard,
|
|
1048
|
+
s3.db.bulk.delete({
|
|
1049
|
+
table: message,
|
|
1050
|
+
where: expr3(col3("conversation_id"), "=", inp3("conversation_id"))
|
|
1051
|
+
}),
|
|
1052
|
+
s3.db.del({ table: conversation, fieldName: "id", fieldValue: inp3("conversation_id") })
|
|
1053
|
+
],
|
|
1054
|
+
response: c3.null()
|
|
1055
|
+
});
|
|
1056
|
+
return {
|
|
1057
|
+
createConversation,
|
|
1058
|
+
listMessages,
|
|
1059
|
+
sendMessage,
|
|
1060
|
+
deleteConversation,
|
|
1061
|
+
all: [createConversation, listMessages, sendMessage, deleteConversation]
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// src/register.ts
|
|
1066
|
+
var installed = /* @__PURE__ */ new WeakSet();
|
|
1067
|
+
function createChatbot(opts = {}) {
|
|
1068
|
+
const options = resolveOptions(opts);
|
|
1069
|
+
const conversation = conversationTable(options);
|
|
1070
|
+
const message = messageTable(options, conversation);
|
|
1071
|
+
const agent2 = chatAgent(options);
|
|
1072
|
+
const replyFn = generateReplyFn(options, conversation, message, agent2);
|
|
1073
|
+
const group = chatbotGroup(options);
|
|
1074
|
+
const authenticated = options.authenticated ? authenticatedQueries(options, group, conversation, message, replyFn) : void 0;
|
|
1075
|
+
const guest = options.guest ? guestQueries(options, group, conversation, message, replyFn) : void 0;
|
|
1076
|
+
return {
|
|
1077
|
+
options,
|
|
1078
|
+
conversation,
|
|
1079
|
+
message,
|
|
1080
|
+
agent: agent2,
|
|
1081
|
+
replyFn,
|
|
1082
|
+
group,
|
|
1083
|
+
authenticated,
|
|
1084
|
+
guest,
|
|
1085
|
+
queries: [...authenticated?.all ?? [], ...guest?.all ?? []]
|
|
1086
|
+
// The conditional families are decided by `AuthenticatedOf`/`GuestOf` from
|
|
1087
|
+
// the OPTIONS type, while the values above are decided by the RESOLVED
|
|
1088
|
+
// options at runtime. The two agree by construction — `resolveOptions`
|
|
1089
|
+
// applies exactly the defaults those conditionals encode — but the compiler
|
|
1090
|
+
// cannot see that a resolved boolean came from a literal, so the bridge is
|
|
1091
|
+
// asserted once, here, rather than by every caller writing `!`.
|
|
1092
|
+
};
|
|
1093
|
+
}
|
|
1094
|
+
function registerChatbot(xano, opts = {}) {
|
|
1095
|
+
if (installed.has(xano)) {
|
|
1096
|
+
throw new Error(
|
|
1097
|
+
"registerChatbot: already called on this Xano instance. Register the chatbot set once \u2014 a second registration duplicates every def, which core cannot catch (the two sets are distinct objects sharing names) and which surfaces at export() as \"Duplicate object guid \u2026 shared by \\\"dbo/conversation\\\" and \\\"dbo/conversation\\\"\". To run TWO chatbots in one workspace, give the second one its own `routePrefix` AND `names` \u2014 the prefix is what keeps the ENDPOINT guids apart (a query's identity derives from its route name), and `names` keeps the tables, agent, function and group apart: registerChatbot(xano, { routePrefix: 'support', names: { conversation: 'support_conversation', message: 'support_message', agent: 'support_agent', apiGroup: 'Support', replyFn: 'support/reply' } })."
|
|
1098
|
+
);
|
|
1099
|
+
}
|
|
1100
|
+
const bot = createChatbot(opts);
|
|
1101
|
+
xano.registerTables([bot.conversation, bot.message]).registerAgents([bot.agent]).registerFunctions([bot.replyFn]).registerApiGroups([bot.group]).registerQueries(bot.queries);
|
|
1102
|
+
installed.add(xano);
|
|
1103
|
+
return { ...bot, xano };
|
|
1104
|
+
}
|
|
1105
|
+
export {
|
|
1106
|
+
DEFAULT_HISTORY_LIMIT,
|
|
1107
|
+
DEFAULT_LIST_LIMIT,
|
|
1108
|
+
DEFAULT_NAMES,
|
|
1109
|
+
DEFAULT_SYSTEM_PROMPT,
|
|
1110
|
+
DEFAULT_TRANSCRIPT_LIMIT,
|
|
1111
|
+
MESSAGES_TEMPLATE,
|
|
1112
|
+
MESSAGE_ROLES,
|
|
1113
|
+
PUBLIC_CONVERSATION_FIELDS,
|
|
1114
|
+
PUBLIC_MESSAGE_FIELDS,
|
|
1115
|
+
TITLE_LENGTH,
|
|
1116
|
+
TOOLS_SYSTEM_PROMPT,
|
|
1117
|
+
authenticatedQueries,
|
|
1118
|
+
chatAgent,
|
|
1119
|
+
chatbotGroup,
|
|
1120
|
+
conversationTable,
|
|
1121
|
+
createChatbot,
|
|
1122
|
+
generateReplyFn,
|
|
1123
|
+
guestQueries,
|
|
1124
|
+
messageTable,
|
|
1125
|
+
registerChatbot,
|
|
1126
|
+
resolveOptions
|
|
1127
|
+
};
|
|
1128
|
+
//# sourceMappingURL=index.js.map
|