@perxel/spotlight 0.1.0 → 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/README.md +8 -1
- package/dist/core/copy.js +1 -0
- package/dist/server/handler.d.ts +8 -2
- package/dist/server/handler.js +35 -4
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/package.json +10 -8
package/README.md
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
Search or ask, like Spotlight. Press ⌘K: the box searches your site as you type, in the browser, with no request and no model call. Press Enter with nothing highlighted and the question goes to an AI chat that answers from your site.
|
|
4
4
|
|
|
5
|
+

|
|
6
|
+
|
|
5
7
|
- **Search and Ask are independent.** Give it documents, an endpoint, or both. Whatever you leave out is never loaded.
|
|
6
8
|
- **Headless first.** Behaviour, keys, focus, ARIA and state (`data-*`) in React parts you style yourself. A **liquid glass skin** ships as the default, themed with CSS variables.
|
|
7
9
|
- **No platform assumptions.** No Next.js import in the client (navigation is a prop), a Web-standard server helper (`Request` → `Response`: Next route handlers, Workers, Hono, Node), any [AI SDK](https://ai-sdk.dev) model, MCP optional.
|
|
@@ -9,6 +11,10 @@ Search or ask, like Spotlight. Press ⌘K: the box searches your site as you typ
|
|
|
9
11
|
|
|
10
12
|
Built for [perxel.com](https://perxel.com) (liquid skin, answers through an MCP server) and [phucbm.com](https://phucbm.com) (its own "research paper" styling on the headless parts, answers from its pages and live data, no MCP).
|
|
11
13
|
|
|
14
|
+
| perxel.com: the liquid skin's chat panel | phucbm.com: the headless parts, its own styling |
|
|
15
|
+
|---|---|
|
|
16
|
+
|  |  |
|
|
17
|
+
|
|
12
18
|
| Search only | Ask only | Both |
|
|
13
19
|
|---|---|---|
|
|
14
20
|
| Plain site search; Enter opens the first result; the chat code never loads | A prompt box; the search code and index never load | Results as you type; Enter with nothing highlighted asks |
|
|
@@ -99,7 +105,8 @@ export const POST = createAskHandler({
|
|
|
99
105
|
- **Stateless**: the client sends the history; only its text reaches the model (tool results and sources from the client are dropped).
|
|
100
106
|
- **Three kinds of source**, mixed freely. `contentSource` searches and reads your pages with short passages per hit and capped pages (no MCP needed). `toolSource` runs your own AI SDK tools in the route (live numbers, structured data). `mcpSource` calls a remote MCP server over streamable HTTP with no SDK, any runtime with `fetch`; `fixed` sets arguments the model never sees (a locale), `tools` allowlists: an anonymous chat must never reach a private tool.
|
|
101
107
|
- **Sources cited**: `{url, title}` pairs in tool results become `source-url` parts (4 at most, 2 per result; `cite: false` turns it off).
|
|
102
|
-
- **Refusals** are `{code}` JSON (`verify_failed`, `rate_limited`, `conversation_limit`, `budget`, `bad_request`, `origin
|
|
108
|
+
- **Refusals** are `{code}` JSON (`verify_failed`, `rate_limited`, `conversation_limit`, `budget`, `bad_request`, `origin`); an answer that fails while streaming ends with `config` (no API key, or the provider refused it: fix the route, not a retry) or `upstream` (the provider failed). The panel shows `copy.errors[code]`; the server logs the cause through `onError`.
|
|
109
|
+
- **API keys**: the model is created with the key from wherever the platform keeps secrets (`process.env` on Node and Next, the Worker's env on Cloudflare: with OpenNext, `getCloudflareContext().env`, and `initOpenNextCloudflareForDev()` in `next.config` so `next dev` reads `.dev.vars`). An empty key only fails when a question is asked, so check it in a `model` function (`throw` when it is missing: the handler refuses with `config` before any call).
|
|
103
110
|
- **Limits**: `maxInputChars` (1000), `maxQuestions` (20), `maxHistory` (20), `maxSteps` (4), `maxOutputTokens` (700). CORS only when `origins` is set.
|
|
104
111
|
- On Workers, pass `waitUntil` so `onFinish` outlives the response.
|
|
105
112
|
|
package/dist/core/copy.js
CHANGED
|
@@ -23,6 +23,7 @@ export const defaultCopy = {
|
|
|
23
23
|
verify_failed: "We could not check that you are not a bot. Please reload and try again.",
|
|
24
24
|
conversation_limit: "This conversation is at its limit. Start a new chat.",
|
|
25
25
|
budget: "Ask is resting for today. Please try again tomorrow.",
|
|
26
|
+
config: "Ask is not available right now.",
|
|
26
27
|
upstream: "The answer could not be written. Please try again.",
|
|
27
28
|
bad_request: "That message could not be sent.",
|
|
28
29
|
},
|
package/dist/server/handler.d.ts
CHANGED
|
@@ -11,7 +11,13 @@ import type { AskContext, AskSource, Cited, ToolRun } from "./types.js";
|
|
|
11
11
|
* → `verify` (bot check) → `rateLimit` → the model call. A refusal is `{code}` JSON with an HTTP
|
|
12
12
|
* status; the panel maps the code to its copy.
|
|
13
13
|
*/
|
|
14
|
-
export type AskErrorCode = "origin" | "bad_request" | "verify_failed" | "rate_limited" | "conversation_limit" | "budget" | "upstream";
|
|
14
|
+
export type AskErrorCode = "origin" | "bad_request" | "verify_failed" | "rate_limited" | "conversation_limit" | "budget" | "config" | "upstream";
|
|
15
|
+
/**
|
|
16
|
+
* A model error's code: `config` when the route is set up wrong (no API key, or the provider
|
|
17
|
+
* refused it: 401/403), so the visitor is not told to try again and the log says what to fix;
|
|
18
|
+
* `upstream` for the rest (provider down, timeout).
|
|
19
|
+
*/
|
|
20
|
+
export declare function modelErrorCode(err: unknown): "config" | "upstream";
|
|
15
21
|
/** One answered turn, for `onFinish` (log it, store it, count its cost). */
|
|
16
22
|
export interface AskTurn {
|
|
17
23
|
/** The chat id the client sent (`useChat`'s id), if any. */
|
|
@@ -86,7 +92,7 @@ export interface AskHandlerOptions {
|
|
|
86
92
|
maxOutputTokens?: number;
|
|
87
93
|
/** Request body bytes (default 256 KB). */
|
|
88
94
|
maxBodyBytes?: number;
|
|
89
|
-
/** Called with errors (model, tool, `onFinish`); default `console.error
|
|
95
|
+
/** Called with errors (model, tool, `onFinish`); default `console.error`, with a hint when the model has no key or it was refused. */
|
|
90
96
|
onError?: (error: unknown, where: "model" | "tool" | "finish") => void;
|
|
91
97
|
/** Extra `streamText` settings (temperature, providerOptions…). */
|
|
92
98
|
settings?: Record<string, unknown>;
|
package/dist/server/handler.js
CHANGED
|
@@ -6,8 +6,31 @@ const STATUS = {
|
|
|
6
6
|
rate_limited: 429,
|
|
7
7
|
conversation_limit: 429,
|
|
8
8
|
budget: 503,
|
|
9
|
+
config: 503,
|
|
9
10
|
upstream: 502,
|
|
10
11
|
};
|
|
12
|
+
/**
|
|
13
|
+
* A model error's code: `config` when the route is set up wrong (no API key, or the provider
|
|
14
|
+
* refused it: 401/403), so the visitor is not told to try again and the log says what to fix;
|
|
15
|
+
* `upstream` for the rest (provider down, timeout).
|
|
16
|
+
*/
|
|
17
|
+
export function modelErrorCode(err) {
|
|
18
|
+
let e = err;
|
|
19
|
+
// A retried call fails with the last attempt's error inside.
|
|
20
|
+
if (e?.name === "AI_RetryError" && e.lastError)
|
|
21
|
+
e = e.lastError;
|
|
22
|
+
if (e?.name === "AI_LoadAPIKeyError")
|
|
23
|
+
return "config";
|
|
24
|
+
if (e?.name === "AI_APICallError" && (e.statusCode === 401 || e.statusCode === 403))
|
|
25
|
+
return "config";
|
|
26
|
+
return "upstream";
|
|
27
|
+
}
|
|
28
|
+
function logError(err, where) {
|
|
29
|
+
if (where === "model" && modelErrorCode(err) === "config")
|
|
30
|
+
console.error(`[spotlight] model: the AI provider has no API key or refused it. Check the key the route's model is created with (an empty Worker secret or env var is a common cause).`, err);
|
|
31
|
+
else
|
|
32
|
+
console.error(`[spotlight] ${where}:`, err);
|
|
33
|
+
}
|
|
11
34
|
function isRecord(v) {
|
|
12
35
|
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
13
36
|
}
|
|
@@ -53,7 +76,7 @@ export function sourcesIn(data, limit = 50) {
|
|
|
53
76
|
return out;
|
|
54
77
|
}
|
|
55
78
|
export function createAskHandler(options) {
|
|
56
|
-
const { sources = [], uiTools = [], maxInputChars = 1000, maxQuestions = 20, maxHistory = 20, maxHistoryChars = 4000, maxSteps = 4, maxOutputTokens = 700, maxBodyBytes = 256 * 1024, onError =
|
|
79
|
+
const { sources = [], uiTools = [], maxInputChars = 1000, maxQuestions = 20, maxHistory = 20, maxHistoryChars = 4000, maxSteps = 4, maxOutputTokens = 700, maxBodyBytes = 256 * 1024, onError = logError, } = options;
|
|
57
80
|
const cite = options.cite === false ? null : { max: options.cite?.max ?? 4, perResult: options.cite?.perResult ?? 2 };
|
|
58
81
|
const ui = new Set(uiTools);
|
|
59
82
|
return async function handle(request) {
|
|
@@ -120,7 +143,15 @@ export function createAskHandler(options) {
|
|
|
120
143
|
const limited = options.rateLimit ? await options.rateLimit(ctx) : true;
|
|
121
144
|
if (limited !== true)
|
|
122
145
|
return refuse(typeof limited === "string" ? limited : "rate_limited", { "retry-after": "60" });
|
|
123
|
-
|
|
146
|
+
// A model factory that throws (a missing key checked by the site) refuses with `config`.
|
|
147
|
+
let model;
|
|
148
|
+
try {
|
|
149
|
+
model = typeof options.model === "function" ? options.model(ctx) : options.model;
|
|
150
|
+
}
|
|
151
|
+
catch (err) {
|
|
152
|
+
onError(err, "model");
|
|
153
|
+
return refuse("config");
|
|
154
|
+
}
|
|
124
155
|
const system = typeof options.system === "function" ? await options.system(ctx) : options.system;
|
|
125
156
|
const history = turns
|
|
126
157
|
.slice(0, -1)
|
|
@@ -206,13 +237,13 @@ export function createAskHandler(options) {
|
|
|
206
237
|
stream: result.stream,
|
|
207
238
|
onError: (err) => {
|
|
208
239
|
onError(err, "model");
|
|
209
|
-
return
|
|
240
|
+
return modelErrorCode(err);
|
|
210
241
|
},
|
|
211
242
|
}));
|
|
212
243
|
},
|
|
213
244
|
onError: (err) => {
|
|
214
245
|
onError(err, "model");
|
|
215
|
-
return
|
|
246
|
+
return modelErrorCode(err);
|
|
216
247
|
},
|
|
217
248
|
});
|
|
218
249
|
const headers = options.headers ? await options.headers(ctx) : undefined;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { createAskHandler, sourcesIn, type AskErrorCode, type AskHandlerOptions, type AskTurn } from "./handler.js";
|
|
1
|
+
export { createAskHandler, modelErrorCode, sourcesIn, type AskErrorCode, type AskHandlerOptions, type AskTurn } from "./handler.js";
|
|
2
2
|
export { contentSource, mcpSource, toolSource, passagesOf, sectionOf, resetMcpCache, type ContentDoc, type ContentSourceOptions, type McpSourceOptions } from "./sources.js";
|
|
3
3
|
export { turnstile, type TurnstileOptions } from "./turnstile.js";
|
|
4
4
|
export type { AskContext, AskSource, Cited, ToolRun } from "./types.js";
|
package/dist/server/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// `@perxel/spotlight/server`: an optional chat endpoint for Ask. Web-standard (`Request` →
|
|
2
2
|
// `Response`), any AI SDK model, tools from the site's pages, its own functions or a remote MCP.
|
|
3
|
-
export { createAskHandler, sourcesIn } from "./handler.js";
|
|
3
|
+
export { createAskHandler, modelErrorCode, sourcesIn } from "./handler.js";
|
|
4
4
|
export { contentSource, mcpSource, toolSource, passagesOf, sectionOf, resetMcpCache } from "./sources.js";
|
|
5
5
|
export { turnstile } from "./turnstile.js";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@perxel/spotlight",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Search or ask, like Spotlight: a ⌘K box that searches a site as you type and hands a question to an AI chat on Enter. Headless React parts, an optional liquid glass skin, and a Web-standard server helper.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Perxel (https://perxel.com)",
|
|
@@ -23,6 +23,7 @@
|
|
|
23
23
|
"headless"
|
|
24
24
|
],
|
|
25
25
|
"type": "module",
|
|
26
|
+
"packageManager": "pnpm@10.22.0",
|
|
26
27
|
"sideEffects": [
|
|
27
28
|
"*.css"
|
|
28
29
|
],
|
|
@@ -63,6 +64,13 @@
|
|
|
63
64
|
"publishConfig": {
|
|
64
65
|
"access": "public"
|
|
65
66
|
},
|
|
67
|
+
"scripts": {
|
|
68
|
+
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/liquid/liquid.css dist/liquid/liquid.css",
|
|
69
|
+
"dev": "tsc -p tsconfig.build.json --watch",
|
|
70
|
+
"typecheck": "tsc --noEmit",
|
|
71
|
+
"test": "vitest run",
|
|
72
|
+
"prepublishOnly": "pnpm build"
|
|
73
|
+
},
|
|
66
74
|
"dependencies": {
|
|
67
75
|
"@radix-ui/react-dialog": "^1.2.0",
|
|
68
76
|
"streamdown": "^2.6.0",
|
|
@@ -92,11 +100,5 @@
|
|
|
92
100
|
"react-dom": "19.3.0",
|
|
93
101
|
"typescript": "6.0.3",
|
|
94
102
|
"vitest": "^5.0.3"
|
|
95
|
-
},
|
|
96
|
-
"scripts": {
|
|
97
|
-
"build": "rm -rf dist && tsc -p tsconfig.build.json && cp src/liquid/liquid.css dist/liquid/liquid.css",
|
|
98
|
-
"dev": "tsc -p tsconfig.build.json --watch",
|
|
99
|
-
"typecheck": "tsc --noEmit",
|
|
100
|
-
"test": "vitest run"
|
|
101
103
|
}
|
|
102
|
-
}
|
|
104
|
+
}
|