@perxel/spotlight 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +174 -2
- package/dist/build/index.d.ts +32 -0
- package/dist/build/index.js +68 -0
- package/dist/chat/index.d.ts +3 -0
- package/dist/chat/index.js +5 -0
- package/dist/chat/parts.d.ts +57 -0
- package/dist/chat/parts.js +69 -0
- package/dist/chat/useAskChat.d.ts +67 -0
- package/dist/chat/useAskChat.js +200 -0
- package/dist/core/copy.d.ts +4 -0
- package/dist/core/copy.js +40 -0
- package/dist/core/fold.d.ts +10 -0
- package/dist/core/fold.js +23 -0
- package/dist/core/messages.d.ts +24 -0
- package/dist/core/messages.js +72 -0
- package/dist/core/search.d.ts +73 -0
- package/dist/core/search.js +210 -0
- package/dist/core/storage.d.ts +17 -0
- package/dist/core/storage.js +48 -0
- package/dist/gate/index.d.ts +2 -0
- package/dist/gate/index.js +5 -0
- package/dist/gate/parts.d.ts +179 -0
- package/dist/gate/parts.js +282 -0
- package/dist/gate/useSearchAsk.d.ts +90 -0
- package/dist/gate/useSearchAsk.js +122 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +8 -0
- package/dist/liquid/context.d.ts +26 -0
- package/dist/liquid/context.js +6 -0
- package/dist/liquid/gate.d.ts +8 -0
- package/dist/liquid/gate.js +52 -0
- package/dist/liquid/icons.d.ts +12 -0
- package/dist/liquid/icons.js +15 -0
- package/dist/liquid/index.d.ts +9 -0
- package/dist/liquid/index.js +19 -0
- package/dist/liquid/liquid.css +1007 -0
- package/dist/liquid/panel.d.ts +8 -0
- package/dist/liquid/panel.js +186 -0
- package/dist/liquid/util.d.ts +6 -0
- package/dist/liquid/util.js +16 -0
- package/dist/react/index.d.ts +6 -0
- package/dist/react/index.js +10 -0
- package/dist/react/provider.d.ts +52 -0
- package/dist/react/provider.js +218 -0
- package/dist/react/turnstile.d.ts +16 -0
- package/dist/react/turnstile.js +100 -0
- package/dist/server/handler.d.ts +102 -0
- package/dist/server/handler.js +274 -0
- package/dist/server/index.d.ts +4 -0
- package/dist/server/index.js +5 -0
- package/dist/server/sources.d.ts +79 -0
- package/dist/server/sources.js +315 -0
- package/dist/server/turnstile.d.ts +15 -0
- package/dist/server/turnstile.js +29 -0
- package/dist/server/types.d.ts +34 -0
- package/dist/server/types.js +1 -0
- package/dist/types.d.ts +207 -0
- package/dist/types.js +7 -0
- package/package.json +102 -4
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import { jsonSchema, tool } from "ai";
|
|
2
|
+
import { buildIndex, fold, search } from "../core/search.js";
|
|
3
|
+
// ---------------------------------------------------------------------------------------------
|
|
4
|
+
// toolSource: the site's own functions
|
|
5
|
+
/**
|
|
6
|
+
* The site's own AI SDK tools (live numbers, filters over structured data, an outside API), run
|
|
7
|
+
* in the same route. A function gets the request context (locale, say) and returns the tools.
|
|
8
|
+
*/
|
|
9
|
+
export function toolSource(tools, { name = "site" } = {}) {
|
|
10
|
+
return { name, tools: (ctx) => (typeof tools === "function" ? tools(ctx) : tools) };
|
|
11
|
+
}
|
|
12
|
+
const STOP = new Set(["the", "and", "for", "you", "your", "what", "which", "how", "does", "did", "are", "was", "with", "about", "from", "this", "that", "have", "has", "can", "any", "all", "who", "why", "when", "where", "much", "many"]);
|
|
13
|
+
/** The query's words worth matching in body text: folded, 3+ characters, common English words out. */
|
|
14
|
+
function terms(query) {
|
|
15
|
+
return [...new Set(fold(query).split(" ").filter((t) => t.length >= 3 && !STOP.has(t)))];
|
|
16
|
+
}
|
|
17
|
+
/** Up to `count` paragraphs of `body` holding the most terms, each cut around its first match. */
|
|
18
|
+
export function passagesOf(body, words, count, length) {
|
|
19
|
+
if (!words.length || !count)
|
|
20
|
+
return [];
|
|
21
|
+
const paragraphs = body.split(/\n\s*\n/).map((p) => p.replace(/\s+/g, " ").trim()).filter((p) => p.length > 40 && !p.startsWith("#"));
|
|
22
|
+
const scored = paragraphs
|
|
23
|
+
.map((p, i) => {
|
|
24
|
+
const f = fold(p);
|
|
25
|
+
return { p, i, f, n: words.filter((w) => f.includes(w)).length };
|
|
26
|
+
})
|
|
27
|
+
.filter((x) => x.n > 0)
|
|
28
|
+
.sort((a, b) => b.n - a.n || a.i - b.i)
|
|
29
|
+
.slice(0, count)
|
|
30
|
+
.sort((a, b) => a.i - b.i);
|
|
31
|
+
return scored.map(({ p, f }) => {
|
|
32
|
+
if (p.length <= length)
|
|
33
|
+
return p;
|
|
34
|
+
const at = Math.min(...words.map((w) => f.indexOf(w)).filter((i) => i >= 0));
|
|
35
|
+
// Folding keeps lengths close enough for a window; cut on spaces.
|
|
36
|
+
let start = Math.max(0, at - Math.floor(length * 0.3));
|
|
37
|
+
if (start > 0)
|
|
38
|
+
start = p.indexOf(" ", start) + 1 || start;
|
|
39
|
+
let end = Math.min(p.length, start + length);
|
|
40
|
+
if (end < p.length)
|
|
41
|
+
end = p.lastIndexOf(" ", end) > start ? p.lastIndexOf(" ", end) : end;
|
|
42
|
+
return (start > 0 ? "… " : "") + p.slice(start, end).trim() + (end < p.length ? " …" : "");
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** A plain-text heading: a short line with no closing punctuation, alone in its paragraph. */
|
|
46
|
+
function plainHeading(lines, i) {
|
|
47
|
+
const l = lines[i].trim();
|
|
48
|
+
return l.length > 0 && l.length <= 80 && !/[.:;,!?)]$/.test(l) && !lines[i - 1]?.trim() && !lines[i + 1]?.trim();
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The section of a body under the heading that matches `section`: a markdown heading (until the
|
|
52
|
+
* next one of its level or higher), else, for plain text where headings became their own lines,
|
|
53
|
+
* a short heading-like line (until the next one).
|
|
54
|
+
*/
|
|
55
|
+
export function sectionOf(body, section) {
|
|
56
|
+
const lines = body.split("\n");
|
|
57
|
+
const want = fold(section);
|
|
58
|
+
const at = lines.findIndex((l) => /^#{1,6}\s/.test(l) && fold(l.replace(/^#+\s*/, "")).includes(want));
|
|
59
|
+
if (at >= 0) {
|
|
60
|
+
const level = lines[at].match(/^#+/)[0].length;
|
|
61
|
+
let end = lines.findIndex((l, i) => i > at && /^#{1,6}\s/.test(l) && l.match(/^#+/)[0].length <= level);
|
|
62
|
+
if (end < 0)
|
|
63
|
+
end = lines.length;
|
|
64
|
+
return lines.slice(at, end).join("\n").trim();
|
|
65
|
+
}
|
|
66
|
+
const plain = lines.findIndex((l, i) => plainHeading(lines, i) && fold(l).includes(want));
|
|
67
|
+
if (plain < 0)
|
|
68
|
+
return null;
|
|
69
|
+
let end = lines.findIndex((_, i) => i > plain && plainHeading(lines, i));
|
|
70
|
+
if (end < 0)
|
|
71
|
+
end = lines.length;
|
|
72
|
+
return lines.slice(plain, end).join("\n").trim();
|
|
73
|
+
}
|
|
74
|
+
function headingsOf(body) {
|
|
75
|
+
const lines = body.split("\n");
|
|
76
|
+
const md = lines.filter((l) => /^#{2,3}\s/.test(l)).map((l) => l.replace(/^#+\s*/, "").trim());
|
|
77
|
+
return md.length ? md : lines.filter((_, i) => plainHeading(lines, i)).map((l) => l.trim());
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Search and read the site's own pages, with no MCP: two tools built from the same documents as
|
|
81
|
+
* the gate's search plus their full text. Answers stay cheap: a search returns a few short
|
|
82
|
+
* passages per hit (most answers need nothing more), a page is capped or read one section at a
|
|
83
|
+
* time.
|
|
84
|
+
*/
|
|
85
|
+
export function contentSource(options) {
|
|
86
|
+
const { baseUrl, maxHits = 5, passages = 2, passageLength = 320, pageLength = 6000, about = "this website's pages and posts" } = options;
|
|
87
|
+
const searchName = options.names?.search ?? "search_site";
|
|
88
|
+
const pageName = options.names?.page ?? "get_page";
|
|
89
|
+
const abs = (url) => (baseUrl && url.startsWith("/") ? baseUrl.replace(/\/$/, "") + url : url);
|
|
90
|
+
// Docs and their index are built once per list (per isolate when the list is a constant).
|
|
91
|
+
const cache = new WeakMap();
|
|
92
|
+
return {
|
|
93
|
+
name: options.name ?? "content",
|
|
94
|
+
async tools(ctx) {
|
|
95
|
+
const docs = (typeof options.docs === "function" ? await options.docs(ctx) : options.docs).filter((d) => d.url);
|
|
96
|
+
const bodies = typeof options.bodies === "function" ? await options.bodies(ctx) : options.bodies ?? {};
|
|
97
|
+
const bodyOf = (d) => d.body ?? bodies[d.id] ?? "";
|
|
98
|
+
let index = cache.get(docs);
|
|
99
|
+
if (!index) {
|
|
100
|
+
index = buildIndex(docs);
|
|
101
|
+
cache.set(docs, index);
|
|
102
|
+
}
|
|
103
|
+
const idx = index;
|
|
104
|
+
const byUrl = (url) => {
|
|
105
|
+
const path = url.replace(/^https?:\/\/[^/]+/, "").replace(/\/$/, "") || "/";
|
|
106
|
+
return docs.find((d) => d.url === url || (d.url.replace(/^https?:\/\/[^/]+/, "").replace(/\/$/, "") || "/") === path);
|
|
107
|
+
};
|
|
108
|
+
const searchTool = tool({
|
|
109
|
+
description: `Searches ${about}: titles, descriptions and full text. Returns the best hits (title, url, kind, excerpt) ` +
|
|
110
|
+
`with the passages of each page that match the query. Use ${pageName} only when the passages do not answer.`,
|
|
111
|
+
inputSchema: jsonSchema({
|
|
112
|
+
type: "object",
|
|
113
|
+
properties: { query: { type: "string", description: "Keywords to look for", minLength: 1 } },
|
|
114
|
+
required: ["query"],
|
|
115
|
+
additionalProperties: false,
|
|
116
|
+
}),
|
|
117
|
+
execute: async ({ query }) => {
|
|
118
|
+
const words = terms(query);
|
|
119
|
+
// Title and keyword matches from the search engine, then full-text matches.
|
|
120
|
+
const fromIndex = search(idx, query, { limit: 20 });
|
|
121
|
+
const scores = new Map();
|
|
122
|
+
fromIndex.forEach((r) => scores.set(r.doc, r.score));
|
|
123
|
+
if (words.length) {
|
|
124
|
+
for (const d of docs) {
|
|
125
|
+
const body = bodyOf(d);
|
|
126
|
+
if (!body)
|
|
127
|
+
continue;
|
|
128
|
+
const f = fold(body);
|
|
129
|
+
const n = words.filter((w) => f.includes(w)).length;
|
|
130
|
+
if (n)
|
|
131
|
+
scores.set(d, (scores.get(d) ?? 0) + n * 3);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
const hits = [...scores].sort((a, b) => b[1] - a[1]).slice(0, maxHits);
|
|
135
|
+
return {
|
|
136
|
+
results: hits.map(([d], i) => {
|
|
137
|
+
const excerpt = d.snippets?.at(-1) ?? d.subtitle ?? "";
|
|
138
|
+
const p = i < 3 ? passagesOf(bodyOf(d), words, passages, passageLength) : [];
|
|
139
|
+
return {
|
|
140
|
+
title: d.title,
|
|
141
|
+
url: abs(d.url),
|
|
142
|
+
kind: d.kind,
|
|
143
|
+
...(excerpt ? { excerpt: excerpt.slice(0, 300) } : {}),
|
|
144
|
+
...(p.length ? { passages: p } : {}),
|
|
145
|
+
};
|
|
146
|
+
}),
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
const pageTool = tool({
|
|
151
|
+
description: `Reads one page of ${about} by its url (from ${searchName}): its text, capped; pass a section heading to read only that section.`,
|
|
152
|
+
inputSchema: jsonSchema({
|
|
153
|
+
type: "object",
|
|
154
|
+
properties: {
|
|
155
|
+
url: { type: "string", description: "The page's url, as returned by search" },
|
|
156
|
+
section: { type: "string", description: "Optional: a section heading to read only that part" },
|
|
157
|
+
},
|
|
158
|
+
required: ["url"],
|
|
159
|
+
additionalProperties: false,
|
|
160
|
+
}),
|
|
161
|
+
execute: async ({ url, section }) => {
|
|
162
|
+
const d = byUrl(url);
|
|
163
|
+
if (!d)
|
|
164
|
+
return { error: "No page with that url." };
|
|
165
|
+
const body = bodyOf(d) || [d.subtitle, ...(d.snippets ?? [])].filter(Boolean).join("\n\n");
|
|
166
|
+
const part = section ? sectionOf(body, section) : null;
|
|
167
|
+
const text = part ?? body;
|
|
168
|
+
const truncated = text.length > pageLength;
|
|
169
|
+
return {
|
|
170
|
+
title: d.title,
|
|
171
|
+
url: abs(d.url),
|
|
172
|
+
text: truncated ? text.slice(0, pageLength) + " …" : text,
|
|
173
|
+
...(truncated ? { truncated: true, sections: headingsOf(body) } : {}),
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
return { [searchName]: searchTool, [pageName]: pageTool };
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
let rpcId = 0;
|
|
182
|
+
const lists = new Map();
|
|
183
|
+
const sessions = new Map();
|
|
184
|
+
/** One JSON-RPC call over streamable HTTP; the answer may be JSON or an SSE stream. */
|
|
185
|
+
async function rpc(url, method, params, headers, timeoutMs, fetcher) {
|
|
186
|
+
const id = ++rpcId;
|
|
187
|
+
const session = sessions.get(url);
|
|
188
|
+
const res = await fetcher(url, {
|
|
189
|
+
method: "POST",
|
|
190
|
+
headers: {
|
|
191
|
+
"content-type": "application/json",
|
|
192
|
+
accept: "application/json, text/event-stream",
|
|
193
|
+
...(session ? { "mcp-session-id": session } : {}),
|
|
194
|
+
...headers,
|
|
195
|
+
},
|
|
196
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, ...(params === undefined ? {} : { params }) }),
|
|
197
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
198
|
+
});
|
|
199
|
+
const sid = res.headers.get("mcp-session-id");
|
|
200
|
+
if (sid)
|
|
201
|
+
sessions.set(url, sid);
|
|
202
|
+
if (!res.ok)
|
|
203
|
+
throw new McpHttpError(res.status, await res.text().catch(() => ""));
|
|
204
|
+
let msg;
|
|
205
|
+
if ((res.headers.get("content-type") ?? "").includes("text/event-stream")) {
|
|
206
|
+
const text = await res.text();
|
|
207
|
+
for (const block of text.split(/\n\n/)) {
|
|
208
|
+
const data = block.split("\n").filter((l) => l.startsWith("data:")).map((l) => l.slice(5).trim()).join("\n");
|
|
209
|
+
if (!data)
|
|
210
|
+
continue;
|
|
211
|
+
try {
|
|
212
|
+
const m = JSON.parse(data);
|
|
213
|
+
if (m.id === id || (m.id === undefined && (m.result || m.error)))
|
|
214
|
+
msg = m;
|
|
215
|
+
}
|
|
216
|
+
catch {
|
|
217
|
+
// Not JSON: skip.
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else
|
|
222
|
+
msg = (await res.json());
|
|
223
|
+
if (!msg)
|
|
224
|
+
throw new Error(`MCP ${method}: no response`);
|
|
225
|
+
if (msg.error)
|
|
226
|
+
throw new Error(`MCP ${method}: ${msg.error.message ?? "error"}`);
|
|
227
|
+
return msg.result;
|
|
228
|
+
}
|
|
229
|
+
class McpHttpError extends Error {
|
|
230
|
+
status;
|
|
231
|
+
constructor(status, body) {
|
|
232
|
+
super(`MCP HTTP ${status}: ${body.slice(0, 200)}`);
|
|
233
|
+
this.status = status;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Calls `method`; a server that needs a session gets `initialize` first, then the call again. */
|
|
237
|
+
async function call(url, method, params, headers, timeoutMs, fetcher) {
|
|
238
|
+
try {
|
|
239
|
+
return await rpc(url, method, params, headers, timeoutMs, fetcher);
|
|
240
|
+
}
|
|
241
|
+
catch (err) {
|
|
242
|
+
if (!(err instanceof McpHttpError) || ![400, 404].includes(err.status))
|
|
243
|
+
throw err;
|
|
244
|
+
sessions.delete(url);
|
|
245
|
+
await rpc(url, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "@perxel/spotlight", version: "0" } }, headers, timeoutMs, fetcher);
|
|
246
|
+
await fetcher(url, {
|
|
247
|
+
method: "POST",
|
|
248
|
+
headers: { "content-type": "application/json", accept: "application/json, text/event-stream", ...(sessions.get(url) ? { "mcp-session-id": sessions.get(url) } : {}), ...headers },
|
|
249
|
+
body: JSON.stringify({ jsonrpc: "2.0", method: "notifications/initialized" }),
|
|
250
|
+
}).catch(() => undefined);
|
|
251
|
+
return rpc(url, method, params, headers, timeoutMs, fetcher);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
/** A tool result's data: `structuredContent`, else its text parsed as JSON, else the text. */
|
|
255
|
+
function resultData(result) {
|
|
256
|
+
const r = result;
|
|
257
|
+
if (r?.structuredContent !== undefined)
|
|
258
|
+
return r.structuredContent;
|
|
259
|
+
const text = (r?.content ?? []).filter((c) => c.type === "text").map((c) => c.text ?? "").join("\n");
|
|
260
|
+
const data = (() => {
|
|
261
|
+
try {
|
|
262
|
+
return JSON.parse(text);
|
|
263
|
+
}
|
|
264
|
+
catch {
|
|
265
|
+
return text;
|
|
266
|
+
}
|
|
267
|
+
})();
|
|
268
|
+
return r?.isError ? { error: data } : data;
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Tools on a remote MCP server, called over streamable HTTP with no SDK (works on any runtime
|
|
272
|
+
* with `fetch`). The tool list is fetched once and kept for `listTtlMs`. Use `tools` to allowlist:
|
|
273
|
+
* an anonymous chat must only reach tools meant for the public.
|
|
274
|
+
*/
|
|
275
|
+
export function mcpSource(options) {
|
|
276
|
+
const { url, listTtlMs = 5 * 60 * 1000, timeoutMs = 15_000, fetcher = fetch } = options;
|
|
277
|
+
return {
|
|
278
|
+
name: options.name ?? "mcp",
|
|
279
|
+
async tools(ctx) {
|
|
280
|
+
const headers = typeof options.headers === "function" ? await options.headers(ctx) : options.headers ?? {};
|
|
281
|
+
let cached = lists.get(url);
|
|
282
|
+
if (!cached || Date.now() - cached.at > listTtlMs) {
|
|
283
|
+
const result = (await call(url, "tools/list", {}, headers, timeoutMs, fetcher));
|
|
284
|
+
cached = { at: Date.now(), tools: result.tools ?? [] };
|
|
285
|
+
lists.set(url, cached);
|
|
286
|
+
}
|
|
287
|
+
const fixed = options.fixed?.(ctx) ?? {};
|
|
288
|
+
const out = {};
|
|
289
|
+
for (const def of cached.tools) {
|
|
290
|
+
if (options.tools && !options.tools.includes(def.name))
|
|
291
|
+
continue;
|
|
292
|
+
const schema = { type: "object", properties: {}, ...def.inputSchema };
|
|
293
|
+
// Fixed arguments are the site's, not the model's: out of the schema the model sees.
|
|
294
|
+
const hidden = Object.keys(fixed).filter((k) => k in (schema.properties ?? {}));
|
|
295
|
+
const properties = Object.fromEntries(Object.entries(schema.properties ?? {}).filter(([k]) => !hidden.includes(k)));
|
|
296
|
+
const required = (schema.required ?? []).filter((k) => !hidden.includes(k));
|
|
297
|
+
const { $schema: _drop, ...rest } = schema;
|
|
298
|
+
out[def.name] = tool({
|
|
299
|
+
description: def.description ?? def.name,
|
|
300
|
+
inputSchema: jsonSchema({ ...rest, properties, required }),
|
|
301
|
+
execute: async (input) => {
|
|
302
|
+
const args = { ...input, ...Object.fromEntries(hidden.map((k) => [k, fixed[k]])) };
|
|
303
|
+
return resultData(await call(url, "tools/call", { name: def.name, arguments: args }, headers, timeoutMs, fetcher));
|
|
304
|
+
},
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
return out;
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/** Test hook: forget the cached tool lists and sessions. */
|
|
312
|
+
export function resetMcpCache() {
|
|
313
|
+
lists.clear();
|
|
314
|
+
sessions.clear();
|
|
315
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AskContext } from "./types.js";
|
|
2
|
+
export interface TurnstileOptions {
|
|
3
|
+
secret: string | undefined;
|
|
4
|
+
/** The body field holding the token (default `turnstileToken`). */
|
|
5
|
+
field?: string;
|
|
6
|
+
/** Accept only tokens issued on these hostnames (default: any the site key allows). */
|
|
7
|
+
hostnames?: string[];
|
|
8
|
+
fetcher?: typeof fetch;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* A `verify` check: Cloudflare Turnstile. The client sends a fresh token with every question (a
|
|
12
|
+
* token is single use; the client widget makes the next one in the background). No secret: every
|
|
13
|
+
* request is refused, so a missing environment variable never opens the endpoint.
|
|
14
|
+
*/
|
|
15
|
+
export declare function turnstile({ secret, field, hostnames, fetcher }: TurnstileOptions): (ctx: AskContext) => Promise<boolean>;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
const SITEVERIFY = "https://challenges.cloudflare.com/turnstile/v0/siteverify";
|
|
2
|
+
/**
|
|
3
|
+
* A `verify` check: Cloudflare Turnstile. The client sends a fresh token with every question (a
|
|
4
|
+
* token is single use; the client widget makes the next one in the background). No secret: every
|
|
5
|
+
* request is refused, so a missing environment variable never opens the endpoint.
|
|
6
|
+
*/
|
|
7
|
+
export function turnstile({ secret, field = "turnstileToken", hostnames, fetcher = fetch }) {
|
|
8
|
+
return async (ctx) => {
|
|
9
|
+
const token = ctx.body[field];
|
|
10
|
+
if (!secret || typeof token !== "string" || !token || token.length > 2048)
|
|
11
|
+
return false;
|
|
12
|
+
const form = new FormData();
|
|
13
|
+
form.set("secret", secret);
|
|
14
|
+
form.set("response", token);
|
|
15
|
+
const ip = ctx.request.headers.get("cf-connecting-ip") ?? ctx.request.headers.get("x-forwarded-for")?.split(",")[0]?.trim();
|
|
16
|
+
if (ip)
|
|
17
|
+
form.set("remoteip", ip);
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetcher(SITEVERIFY, { method: "POST", body: form });
|
|
20
|
+
const out = (await res.json());
|
|
21
|
+
if (!out.success)
|
|
22
|
+
return false;
|
|
23
|
+
return !hostnames || (!!out.hostname && hostnames.includes(out.hostname));
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
};
|
|
29
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type { ToolSet } from "ai";
|
|
2
|
+
/** What every check, source and hook gets about the request. */
|
|
3
|
+
export interface AskContext {
|
|
4
|
+
request: Request;
|
|
5
|
+
/** The request body's own fields, `messages` left out (locale, page, a bot-check token). */
|
|
6
|
+
body: Record<string, unknown>;
|
|
7
|
+
/** The chat id the client sent, if any. */
|
|
8
|
+
id: string | null;
|
|
9
|
+
/** The visitor's question (the last message's text). */
|
|
10
|
+
question: string;
|
|
11
|
+
/** The conversation before it, text only. */
|
|
12
|
+
history: {
|
|
13
|
+
role: "user" | "assistant";
|
|
14
|
+
text: string;
|
|
15
|
+
}[];
|
|
16
|
+
}
|
|
17
|
+
/** A set of tools for the model. `contentSource`, `toolSource` and `mcpSource` make them. */
|
|
18
|
+
export interface AskSource {
|
|
19
|
+
/** Prefixes this source's tools when a name is already taken by an earlier source. */
|
|
20
|
+
name: string;
|
|
21
|
+
tools(ctx: AskContext): ToolSet | Promise<ToolSet>;
|
|
22
|
+
}
|
|
23
|
+
/** A tool run in one turn. */
|
|
24
|
+
export interface ToolRun {
|
|
25
|
+
name: string;
|
|
26
|
+
ms: number;
|
|
27
|
+
ok: boolean;
|
|
28
|
+
input: unknown;
|
|
29
|
+
}
|
|
30
|
+
/** A cited source. */
|
|
31
|
+
export interface Cited {
|
|
32
|
+
url: string;
|
|
33
|
+
title: string;
|
|
34
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import type { ComponentType, ReactNode } from "react";
|
|
2
|
+
import type { SearchDoc } from "./core/search.js";
|
|
3
|
+
export type { SearchDoc, SearchIndex, SearchOptions, SearchResult, Snippet } from "./core/search.js";
|
|
4
|
+
/**
|
|
5
|
+
* A chat message, structurally the AI SDK's `UIMessage` (so `useChat` messages fit), without
|
|
6
|
+
* importing `ai`: the core and the search parts never load the AI SDK.
|
|
7
|
+
*/
|
|
8
|
+
export interface ChatMessage {
|
|
9
|
+
id: string;
|
|
10
|
+
role: "system" | "user" | "assistant";
|
|
11
|
+
parts: ChatPart[];
|
|
12
|
+
metadata?: unknown;
|
|
13
|
+
}
|
|
14
|
+
export type ChatPart = {
|
|
15
|
+
type: string;
|
|
16
|
+
} & Record<string, unknown>;
|
|
17
|
+
export interface SearchConfig {
|
|
18
|
+
/** The documents, when they are already in the page's code (small sets). */
|
|
19
|
+
docs?: SearchDoc[];
|
|
20
|
+
/**
|
|
21
|
+
* Load the documents lazily (recommended): a dynamic `import()` of a generated JSON file, so
|
|
22
|
+
* the index is a chunk of its own, fetched when the gate opens. A module with a `default`
|
|
23
|
+
* export is unwrapped.
|
|
24
|
+
*/
|
|
25
|
+
load?: () => Promise<SearchDoc[] | {
|
|
26
|
+
default: SearchDoc[];
|
|
27
|
+
}>;
|
|
28
|
+
/** At most this many results (default 8). */
|
|
29
|
+
limit?: number;
|
|
30
|
+
/** Result kinds in display order; also breaks score ties. */
|
|
31
|
+
kindOrder?: string[];
|
|
32
|
+
/** A snippet longer than this is cut around its first match (default 80 characters). */
|
|
33
|
+
snippetLength?: number;
|
|
34
|
+
}
|
|
35
|
+
/** Where the conversation is kept between visits. `localStorageAdapter()` is the default. */
|
|
36
|
+
export interface PersistAdapter {
|
|
37
|
+
read(): StoredConversation | null;
|
|
38
|
+
write(value: StoredConversation | null): void;
|
|
39
|
+
}
|
|
40
|
+
export interface StoredConversation {
|
|
41
|
+
id: string;
|
|
42
|
+
messages: ChatMessage[];
|
|
43
|
+
/** When the visitor last sent a message (ms). */
|
|
44
|
+
savedAt: number;
|
|
45
|
+
}
|
|
46
|
+
export interface AskConfig {
|
|
47
|
+
/**
|
|
48
|
+
* The chat endpoint for the default transport: any endpoint that speaks the AI SDK UI message
|
|
49
|
+
* stream and takes the whole conversation (`createAskHandler` from `@perxel/spotlight/server`,
|
|
50
|
+
* or any `useChat`-compatible route).
|
|
51
|
+
*/
|
|
52
|
+
endpoint?: string;
|
|
53
|
+
/**
|
|
54
|
+
* A custom transport instead of `endpoint`: an AI SDK `ChatTransport` (typed loosely here so
|
|
55
|
+
* the contracts do not import `ai`). For backends that keep their own history or sessions.
|
|
56
|
+
*/
|
|
57
|
+
transport?: unknown;
|
|
58
|
+
/** Extra fields sent with every request (locale, page). A function runs per request. */
|
|
59
|
+
body?: Record<string, unknown> | (() => Record<string, unknown> | Promise<Record<string, unknown>>);
|
|
60
|
+
/** Extra headers sent with every request. A function runs per request. */
|
|
61
|
+
headers?: Record<string, string> | (() => Record<string, string> | Promise<Record<string, string>>);
|
|
62
|
+
/**
|
|
63
|
+
* Called when the gate opens (once per opening): warm the endpoint, mount a bot check. Never
|
|
64
|
+
* called during page load.
|
|
65
|
+
*/
|
|
66
|
+
prepare?: () => void | Promise<void>;
|
|
67
|
+
/** Where the conversation is kept: `localStorageAdapter(...)` (default), `false` for nowhere, or your own. */
|
|
68
|
+
persist?: PersistAdapter | false;
|
|
69
|
+
/**
|
|
70
|
+
* A question from the gate continues the kept conversation if its last question is younger
|
|
71
|
+
* than this many hours; older, it starts a new one (default 24).
|
|
72
|
+
*/
|
|
73
|
+
continueHours?: number;
|
|
74
|
+
/** Characters in one question (default 1000). */
|
|
75
|
+
maxInput?: number;
|
|
76
|
+
}
|
|
77
|
+
/** What an action's view gets. */
|
|
78
|
+
export interface ActionViewProps<I = unknown> {
|
|
79
|
+
/** What opened it: the typed text mapped by `fromSearch`, or the tool output mapped by `fromTool`. */
|
|
80
|
+
input: I;
|
|
81
|
+
/** The conversation so far (empty when opened from the gate with none). */
|
|
82
|
+
messages: ChatMessage[];
|
|
83
|
+
/** The conversation id, for a backend that keeps conversations. */
|
|
84
|
+
conversationId: string | null;
|
|
85
|
+
/** Back to the conversation, or to the gate when there is none. */
|
|
86
|
+
back: () => void;
|
|
87
|
+
/** Close the panel. */
|
|
88
|
+
close: () => void;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Something a visitor can do from the gate or from an answer (contact form, newsletter signup).
|
|
92
|
+
* Registered by the site; the package ships none.
|
|
93
|
+
*/
|
|
94
|
+
export interface Action<I = any> {
|
|
95
|
+
/** Unique. A search document with id `action:<id>` opens it too (for indexes built ahead). */
|
|
96
|
+
id: string;
|
|
97
|
+
/** Makes it a search result (kind `"action"` unless set). */
|
|
98
|
+
search?: {
|
|
99
|
+
title: string;
|
|
100
|
+
subtitle?: string;
|
|
101
|
+
keywords?: string[];
|
|
102
|
+
kind?: string;
|
|
103
|
+
};
|
|
104
|
+
/** The input when opened from the gate, from the typed text. Default: `undefined`. */
|
|
105
|
+
fromSearch?: (query: string, doc: SearchDoc) => I;
|
|
106
|
+
/** The tool whose output in a finished answer opens it (e.g. `"prepare_lead"`). */
|
|
107
|
+
tool?: string;
|
|
108
|
+
/** The input when opened from an answer, from the tool's output. Default: the output itself. */
|
|
109
|
+
fromTool?: (output: unknown, messages: ChatMessage[]) => I;
|
|
110
|
+
/** What opens, inside the panel (needs `ask`). */
|
|
111
|
+
view?: ComponentType<ActionViewProps<I>>;
|
|
112
|
+
/** Or: run something instead of opening a view (navigate, open the site's own dialog). */
|
|
113
|
+
run?: (input: I) => void;
|
|
114
|
+
/** The panel title while the view is open, and the label of the row that reopens it under an answer. */
|
|
115
|
+
title?: string;
|
|
116
|
+
label?: string;
|
|
117
|
+
/** Hint by the gate's Enter key while this action is highlighted ("to open the form"). */
|
|
118
|
+
hint?: string;
|
|
119
|
+
}
|
|
120
|
+
/** Identity helper, for inference: `defineAction({...})`. */
|
|
121
|
+
export declare function defineAction<I>(action: Action<I>): Action<I>;
|
|
122
|
+
/** What the gate hands the panel. `fresh`: the kept conversation is stale, start a new one. */
|
|
123
|
+
export type SpotlightRequest = {
|
|
124
|
+
kind: "ask";
|
|
125
|
+
text: string;
|
|
126
|
+
fresh: boolean;
|
|
127
|
+
} | {
|
|
128
|
+
kind: "action";
|
|
129
|
+
id: string;
|
|
130
|
+
input: unknown;
|
|
131
|
+
} | {
|
|
132
|
+
kind: "show";
|
|
133
|
+
};
|
|
134
|
+
/** Which halves are on: `search` given, `ask` given, both, or neither. */
|
|
135
|
+
export type SpotlightMode = "both" | "search" | "ask" | "none";
|
|
136
|
+
export interface SpotlightCopy {
|
|
137
|
+
/** The gate's accessible title. */
|
|
138
|
+
gateTitle: string;
|
|
139
|
+
/** The gate's field, by mode. */
|
|
140
|
+
placeholder: string;
|
|
141
|
+
placeholderSearch: string;
|
|
142
|
+
placeholderAsk: string;
|
|
143
|
+
/** Hint by Enter: ask, open the highlighted page. */
|
|
144
|
+
hintAsk: string;
|
|
145
|
+
hintOpen: string;
|
|
146
|
+
/** Result kind labels (`page`, `post`…); a kind without one shows as is. */
|
|
147
|
+
kinds: Record<string, string>;
|
|
148
|
+
/** Live region: the number of results. */
|
|
149
|
+
results: (count: number) => string;
|
|
150
|
+
/** The panel. */
|
|
151
|
+
panelTitle: string;
|
|
152
|
+
newChat: string;
|
|
153
|
+
close: string;
|
|
154
|
+
back: string;
|
|
155
|
+
send: string;
|
|
156
|
+
stop: string;
|
|
157
|
+
thinking: string;
|
|
158
|
+
sources: string;
|
|
159
|
+
/** The button back to the newest message. */
|
|
160
|
+
scrollToEnd: string;
|
|
161
|
+
/** Error messages by code (the `code` field of a JSON error response, or `generic`). */
|
|
162
|
+
errors: Record<string, string>;
|
|
163
|
+
}
|
|
164
|
+
export interface NavigateOptions {
|
|
165
|
+
/** Open in a new tab (modified click). */
|
|
166
|
+
newTab?: boolean;
|
|
167
|
+
/** Where it came from: a search result, a link in an answer or a source. */
|
|
168
|
+
from: "search" | "answer";
|
|
169
|
+
}
|
|
170
|
+
/** Default: `location.assign` (or `window.open` for a new tab). */
|
|
171
|
+
export type Navigate = (url: string, options: NavigateOptions) => void;
|
|
172
|
+
/** What the gate and the panel modules receive from the provider. */
|
|
173
|
+
export interface GateProps {
|
|
174
|
+
open: boolean;
|
|
175
|
+
onOpenChange: (open: boolean) => void;
|
|
176
|
+
}
|
|
177
|
+
export interface PanelProps {
|
|
178
|
+
open: boolean;
|
|
179
|
+
onOpenChange: (open: boolean) => void;
|
|
180
|
+
}
|
|
181
|
+
export type Loader<P> = () => Promise<{
|
|
182
|
+
default: ComponentType<P>;
|
|
183
|
+
}>;
|
|
184
|
+
export interface SpotlightProps {
|
|
185
|
+
/** Search: documents or a loader. Omit for an ask-only prompt box. */
|
|
186
|
+
search?: SearchConfig;
|
|
187
|
+
/** Ask: an endpoint or a transport. Omit for plain search; the chat code is then never loaded. */
|
|
188
|
+
ask?: AskConfig;
|
|
189
|
+
actions?: Action[];
|
|
190
|
+
/** The gate component's module, loaded lazily (a skin passes its own). */
|
|
191
|
+
gate: Loader<GateProps>;
|
|
192
|
+
/** The panel component's module, loaded lazily; needed with `ask`. */
|
|
193
|
+
panel?: Loader<PanelProps>;
|
|
194
|
+
navigate?: Navigate;
|
|
195
|
+
/** The keyboard shortcut: `"mod+k"` (default: ⌘K on Apple keyboards, Ctrl K elsewhere), another `mod+<key>`, or `false`. */
|
|
196
|
+
shortcut?: string | false;
|
|
197
|
+
/** Off: nothing renders and the shortcut is unbound (e.g. a page without Ask). Default on. */
|
|
198
|
+
enabled?: boolean;
|
|
199
|
+
copy?: Partial<SpotlightCopy>;
|
|
200
|
+
/**
|
|
201
|
+
* Fetch the gate's code once the page is idle, so the first open is instant. `true` (default):
|
|
202
|
+
* 1.5 s after load or on the first input; a number: that many ms after load; `false`: only on a
|
|
203
|
+
* trigger's `prefetch` (pointer enter, focus).
|
|
204
|
+
*/
|
|
205
|
+
prefetch?: boolean | number;
|
|
206
|
+
children?: ReactNode;
|
|
207
|
+
}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The contracts every part codes against: the search config, the Ask config, actions, the
|
|
2
|
+
// request the gate hands the panel, the copy dictionary, persistence. No React values here,
|
|
3
|
+
// only types (and `defineAction`, an identity helper), so any entry point may import it.
|
|
4
|
+
/** Identity helper, for inference: `defineAction({...})`. */
|
|
5
|
+
export function defineAction(action) {
|
|
6
|
+
return action;
|
|
7
|
+
}
|