@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
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Perxel
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,175 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @perxel/spotlight
|
|
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
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
- **Search and Ask are independent.** Give it documents, an endpoint, or both. Whatever you leave out is never loaded.
|
|
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.
|
|
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.
|
|
10
|
+
- **Nothing in first load** but the provider and your trigger: the box, the chat, the index and the AI SDK are lazy chunks.
|
|
11
|
+
|
|
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).
|
|
13
|
+
|
|
14
|
+
| perxel.com: the liquid skin's chat panel | phucbm.com: the headless parts, its own styling |
|
|
15
|
+
|---|---|
|
|
16
|
+
|  |  |
|
|
17
|
+
|
|
18
|
+
| Search only | Ask only | Both |
|
|
19
|
+
|---|---|---|
|
|
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 |
|
|
21
|
+
|
|
22
|
+
## Install
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
pnpm add @perxel/spotlight
|
|
26
|
+
# for Ask:
|
|
27
|
+
pnpm add ai @ai-sdk/react
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
React 19. `ai` and `@ai-sdk/react` are optional peers, needed only for Ask.
|
|
31
|
+
|
|
32
|
+
## Quick start (liquid skin)
|
|
33
|
+
|
|
34
|
+
```tsx
|
|
35
|
+
"use client"
|
|
36
|
+
import {SpotlightLiquid, Trigger} from "@perxel/spotlight/liquid"
|
|
37
|
+
|
|
38
|
+
export function Providers({children}: {children: React.ReactNode}) {
|
|
39
|
+
return (
|
|
40
|
+
<SpotlightLiquid
|
|
41
|
+
search={{load: () => fetch("/search.json").then((r) => r.json())}}
|
|
42
|
+
ask={{endpoint: "/api/ask"}}
|
|
43
|
+
>
|
|
44
|
+
{children}
|
|
45
|
+
<Trigger>Search ⌘K</Trigger>
|
|
46
|
+
</SpotlightLiquid>
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The skin imports its own CSS from its lazy chunks (`@perxel/spotlight/liquid.css` is exported too, for bundlers that skip CSS imported from packages).
|
|
52
|
+
|
|
53
|
+
## Search
|
|
54
|
+
|
|
55
|
+
A document is a `SearchDoc`:
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
{id: "note:og-images", kind: "note", title: "Dynamic OG images", subtitle: "Note", url: "/notes/og-images",
|
|
59
|
+
keywords: ["satori", "next/og"], // matched, never shown
|
|
60
|
+
snippets: ["Fonts", "What broke", "…"]} // matched, and shown under a result whose title lacks a typed word
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Matching: every typed word must match; accents and case are folded (`đ` → `d`, so Vietnamese works typed without accents); one- and two-letter words only match word starts. When nothing matches every word, the documents matching the most words show. Options: `limit` (8), `kindOrder`, `snippetLength` (80).
|
|
64
|
+
|
|
65
|
+
Give it `search={{docs}}` for a few documents, or `search={{load}}` (recommended) for a lazy file: a dynamic `import()` of a generated JSON, or a `fetch` of a static route. Build helpers for Node: `@perxel/spotlight/build` (`markdownHeadings`, `dropCommonHeadings` for template headings found in many posts, `buildSearchDocs`, `writeSearchIndex`). The package reads no CMS: you map your content.
|
|
66
|
+
|
|
67
|
+
## Ask
|
|
68
|
+
|
|
69
|
+
The default transport is the AI SDK UI message stream with the whole conversation in each request, so any `useChat`-compatible endpoint works.
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
ask={{
|
|
73
|
+
endpoint: "/api/ask",
|
|
74
|
+
body: async () => ({locale, turnstileToken: await bot.token()}), // per request
|
|
75
|
+
prepare: () => bot.prepare(), // when the box opens
|
|
76
|
+
persist: localStorageAdapter({key: "my-chat", days: 7}), // default; `false` for none
|
|
77
|
+
continueHours: 24, // a question from the box continues a conversation younger than this
|
|
78
|
+
maxInput: 1000,
|
|
79
|
+
}}
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
`transport` takes any AI SDK `ChatTransport` instead of `endpoint`, for a backend that keeps its own history or sessions.
|
|
83
|
+
|
|
84
|
+
### The server helper
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
// app/api/ask/route.ts
|
|
88
|
+
import {createOpenRouter} from "@openrouter/ai-sdk-provider"
|
|
89
|
+
import {contentSource, createAskHandler, mcpSource, toolSource, turnstile} from "@perxel/spotlight/server"
|
|
90
|
+
|
|
91
|
+
export const POST = createAskHandler({
|
|
92
|
+
model: createOpenRouter({apiKey: process.env.OPENROUTER_API_KEY})("anthropic/claude-haiku-5.5"),
|
|
93
|
+
system: "You answer questions about this site, only from tool results…",
|
|
94
|
+
sources: [
|
|
95
|
+
contentSource({docs, baseUrl: "https://example.com"}), // the site's pages: search_site, get_page
|
|
96
|
+
toolSource({get_stats: tool({...})}), // the site's own functions
|
|
97
|
+
mcpSource({url: "https://mcp.example.com/mcp", tools: ["search_content", "get_item"]}),
|
|
98
|
+
],
|
|
99
|
+
verify: turnstile({secret: process.env.TURNSTILE_SECRET}),
|
|
100
|
+
rateLimit: async (ctx) => true, // or false / an error code
|
|
101
|
+
onFinish: (turn) => log(turn), // question, answer, tools, sources, tokens, timing
|
|
102
|
+
})
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
- **Stateless**: the client sends the history; only its text reaches the model (tool results and sources from the client are dropped).
|
|
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.
|
|
107
|
+
- **Sources cited**: `{url, title}` pairs in tool results become `source-url` parts (4 at most, 2 per result; `cite: false` turns it off).
|
|
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).
|
|
110
|
+
- **Limits**: `maxInputChars` (1000), `maxQuestions` (20), `maxHistory` (20), `maxSteps` (4), `maxOutputTokens` (700). CORS only when `origins` is set.
|
|
111
|
+
- On Workers, pass `waitUntil` so `onFinish` outlives the response.
|
|
112
|
+
|
|
113
|
+
Bot check on the client: `turnstileWidget({siteKey})` from `@perxel/spotlight/react` (script injected on `prepare()`, invisible unless Cloudflare is unsure, a fresh token per question).
|
|
114
|
+
|
|
115
|
+
## Actions
|
|
116
|
+
|
|
117
|
+
Things a visitor can do from the box or from an answer (a contact form, a newsletter signup). The package ships none.
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
const contact = defineAction({
|
|
121
|
+
id: "contact",
|
|
122
|
+
search: {title: "Send us a message", keywords: ["contact", "hire"]}, // a search result (or a doc with id "action:contact" in your index)
|
|
123
|
+
fromSearch: (typed) => ({message: typed}),
|
|
124
|
+
tool: "prepare_contact", // a finished answer with this tool's output opens it
|
|
125
|
+
fromTool: (output) => output,
|
|
126
|
+
view: ContactForm, // shown in the panel: ({input, messages, conversationId, back, close})
|
|
127
|
+
title: "Contact", label: "Open the contact form", hint: "to open the form",
|
|
128
|
+
})
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
An action can `run(input)` instead of showing a view (navigate, open your own dialog). Keep views lazy (`React.lazy`) so forms stay out of first load.
|
|
132
|
+
|
|
133
|
+
## Headless
|
|
134
|
+
|
|
135
|
+
```tsx
|
|
136
|
+
import {SpotlightProvider, Trigger, useSpotlight} from "@perxel/spotlight/react"
|
|
137
|
+
<SpotlightProvider search={…} ask={…} gate={() => import("./MyGate")} panel={() => import("./MyPanel")}>
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
The gate module (`@perxel/spotlight/gate`): `Gate.Root` (Radix dialog, focus in and back, Escape clears a highlight first), `Gate.Input`, `Gate.Results` (render function per result), `Gate.Item` (link or button, `data-highlighted`, `data-kind`), `Gate.Marked`, `Gate.Hint` (`data-on` while Enter does something), `Gate.Send`, `Gate.Close`; `useGateContext()` for everything else. The panel module (`@perxel/spotlight/chat`): `useAskChat()` (messages, send, stop, status, `errorCode`, the current view, actions from answers, `textOf`, `sourcesOf`), `Chat.Panel`, `Chat.Title`, `Chat.Close`, `Chat.Messages` + `Chat.Content` (stick to bottom), `Chat.ScrollButton`, `Chat.Composer`, `Chat.Submit`, `Chat.Markdown`. Import these two entries only from your lazy gate and panel modules.
|
|
141
|
+
|
|
142
|
+
Other props: `navigate(url, {newTab, from})` (default: `location`, other sites in a new tab), `shortcut` (`"mod+k"`, another key, or `false`), `enabled`, `copy` (English defaults; pass your language), `prefetch` (the box's code once the page is idle).
|
|
143
|
+
|
|
144
|
+
## Theming the liquid skin
|
|
145
|
+
|
|
146
|
+
Set the variables on `:root` or on the skin's `className`:
|
|
147
|
+
|
|
148
|
+
| Variable | What |
|
|
149
|
+
|---|---|
|
|
150
|
+
| `--sp-accent` | Marks, the highlight's tint, the tide, the send button's hover |
|
|
151
|
+
| `--sp-ink` | Text on the accent glass |
|
|
152
|
+
| `--sp-text`, `--sp-muted`, `--sp-subtle` | Text tones |
|
|
153
|
+
| `--sp-surface`, `--sp-bubble`, `--sp-quiet` | Panel, the visitor's bubble, quiet fills |
|
|
154
|
+
| `--sp-button`, `--sp-button-text`, `--sp-button-hover` | The round send/stop button |
|
|
155
|
+
| `--sp-glass-fill`, `--sp-glass-edge`, `--sp-glow`, `--sp-lift` | The glass |
|
|
156
|
+
| `--sp-gate-fill`, `--sp-pill-fill` | The box over the page; the panel's header pill |
|
|
157
|
+
| `--sp-font`, `--sp-font-display`, `--sp-display-weight` | Type |
|
|
158
|
+
| `--sp-dim`, `--sp-dim-blur`, `--sp-z` | The page behind the box; stacking |
|
|
159
|
+
|
|
160
|
+
Skin options: `logo`, `privacyNote`, `handoff(chat)`, `composerExtra(chat)`, `uiTools`, `localHosts`, `tide`, `gateProps` / `panelProps` (attributes on the roots), `className`. Reduced motion and reduced transparency are respected.
|
|
161
|
+
|
|
162
|
+
## Local development against a site
|
|
163
|
+
|
|
164
|
+
Link it as a pnpm injected dependency, so the package resolves the site's own React and AI SDK (a plain `link:` brings its dev copies along):
|
|
165
|
+
|
|
166
|
+
```json
|
|
167
|
+
"dependencies": {"@perxel/spotlight": "file:../perxel-spotlight"},
|
|
168
|
+
"dependenciesMeta": {"@perxel/spotlight": {"injected": true}}
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
After a change, `scripts/sync.sh ../my-site` rebuilds and copies `dist/` in.
|
|
172
|
+
|
|
173
|
+
## License
|
|
174
|
+
|
|
175
|
+
MIT © Perxel
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SearchDoc } from "../core/search.js";
|
|
2
|
+
/**
|
|
3
|
+
* The plain text of every heading of `levels` (default `##` and `###`) outside code fences: links
|
|
4
|
+
* keep their text, inline code and emphasis lose their marks.
|
|
5
|
+
*/
|
|
6
|
+
export declare function markdownHeadings(markdown: string, { levels }?: {
|
|
7
|
+
levels?: number[];
|
|
8
|
+
}): string[];
|
|
9
|
+
/**
|
|
10
|
+
* Drops headings found in `threshold` (default 3) or more documents of one group: template
|
|
11
|
+
* sections ("FAQ", "Conclusion") name no topic and would bring up every post that has them.
|
|
12
|
+
* `group` puts keys in groups (a locale); one group by default.
|
|
13
|
+
*/
|
|
14
|
+
export declare function dropCommonHeadings(headings: Record<string, string[]>, { threshold, group }?: {
|
|
15
|
+
threshold?: number;
|
|
16
|
+
group?: (key: string) => string;
|
|
17
|
+
}): Record<string, string[]>;
|
|
18
|
+
/** A content item for `buildSearchDocs`: a document plus the key of its headings. */
|
|
19
|
+
export type SearchItem = SearchDoc & {
|
|
20
|
+
headingsKey?: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Documents from content items and static pages: each item's headings (by `headingsKey`) go first
|
|
24
|
+
* in its `snippets` (short, and they name a section), empty fields dropped.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildSearchDocs({ items, pages, headings }: {
|
|
27
|
+
items?: SearchItem[];
|
|
28
|
+
pages?: SearchDoc[];
|
|
29
|
+
headings?: Record<string, string[]>;
|
|
30
|
+
}): SearchDoc[];
|
|
31
|
+
/** Writes the documents as JSON (one line), only when the content changed. Returns true when it wrote. */
|
|
32
|
+
export declare function writeSearchIndex(docs: SearchDoc[], file: string): boolean;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// `@perxel/spotlight/build`: build-time helpers for the search index (Node). A site maps its own
|
|
2
|
+
// content to `SearchDoc`s; these read headings out of markdown, drop template headings and write
|
|
3
|
+
// the index file. The package reads no CMS.
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
/**
|
|
7
|
+
* The plain text of every heading of `levels` (default `##` and `###`) outside code fences: links
|
|
8
|
+
* keep their text, inline code and emphasis lose their marks.
|
|
9
|
+
*/
|
|
10
|
+
export function markdownHeadings(markdown, { levels = [2, 3] } = {}) {
|
|
11
|
+
let fence = false;
|
|
12
|
+
const out = [];
|
|
13
|
+
for (const line of markdown.split("\n")) {
|
|
14
|
+
if (/^\s*(```|~~~)/.test(line))
|
|
15
|
+
fence = !fence;
|
|
16
|
+
const m = !fence && line.match(/^(#{1,6})\s+(.+?)\s*#*\s*$/);
|
|
17
|
+
if (!m || !levels.includes(m[1].length))
|
|
18
|
+
continue;
|
|
19
|
+
out.push(m[2].replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/[`*_]/g, "").trim());
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Drops headings found in `threshold` (default 3) or more documents of one group: template
|
|
25
|
+
* sections ("FAQ", "Conclusion") name no topic and would bring up every post that has them.
|
|
26
|
+
* `group` puts keys in groups (a locale); one group by default.
|
|
27
|
+
*/
|
|
28
|
+
export function dropCommonHeadings(headings, { threshold = 3, group = () => "" } = {}) {
|
|
29
|
+
const seen = new Map();
|
|
30
|
+
for (const [key, list] of Object.entries(headings)) {
|
|
31
|
+
for (const h of new Set(list))
|
|
32
|
+
seen.set(`${group(key)}|${h}`, (seen.get(`${group(key)}|${h}`) ?? 0) + 1);
|
|
33
|
+
}
|
|
34
|
+
return Object.fromEntries(Object.entries(headings).map(([key, list]) => [key, list.filter((h) => (seen.get(`${group(key)}|${h}`) ?? 0) < threshold)]));
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Documents from content items and static pages: each item's headings (by `headingsKey`) go first
|
|
38
|
+
* in its `snippets` (short, and they name a section), empty fields dropped.
|
|
39
|
+
*/
|
|
40
|
+
export function buildSearchDocs({ items = [], pages = [], headings = {} }) {
|
|
41
|
+
const clean = (d) => {
|
|
42
|
+
const out = { id: d.id, kind: d.kind, title: d.title };
|
|
43
|
+
if (d.subtitle)
|
|
44
|
+
out.subtitle = d.subtitle;
|
|
45
|
+
if (d.url)
|
|
46
|
+
out.url = d.url;
|
|
47
|
+
const keywords = (d.keywords ?? []).filter(Boolean);
|
|
48
|
+
if (keywords.length)
|
|
49
|
+
out.keywords = keywords;
|
|
50
|
+
const snippets = (d.snippets ?? []).filter(Boolean);
|
|
51
|
+
if (snippets.length)
|
|
52
|
+
out.snippets = snippets;
|
|
53
|
+
return out;
|
|
54
|
+
};
|
|
55
|
+
return [
|
|
56
|
+
...pages.map(clean),
|
|
57
|
+
...items.map(({ headingsKey, ...d }) => clean({ ...d, snippets: [...(headingsKey ? headings[headingsKey] ?? [] : []), ...(d.snippets ?? [])] })),
|
|
58
|
+
];
|
|
59
|
+
}
|
|
60
|
+
/** Writes the documents as JSON (one line), only when the content changed. Returns true when it wrote. */
|
|
61
|
+
export function writeSearchIndex(docs, file) {
|
|
62
|
+
const json = JSON.stringify(docs) + "\n";
|
|
63
|
+
if (fs.existsSync(file) && fs.readFileSync(file, "utf8") === json)
|
|
64
|
+
return false;
|
|
65
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
66
|
+
fs.writeFileSync(file, json);
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// `@perxel/spotlight/chat`: the chat half (imports the AI SDK). Import it only from the panel
|
|
2
|
+
// module the provider loads lazily, so pages never load it until the gate hands over a question.
|
|
3
|
+
export { useAskChat, defaultTransport } from "./useAskChat.js";
|
|
4
|
+
export { Chat } from "./parts.js";
|
|
5
|
+
export { calledTool, sourcesOf, textOf, toolOutputOf, latestFromVisitor } from "../core/messages.js";
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { ComponentProps, ComponentPropsWithoutRef, ReactNode, Ref } from "react";
|
|
2
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
3
|
+
import type { AskChat } from "./useAskChat.js";
|
|
4
|
+
/**
|
|
5
|
+
* The conversation's scroll box: stays scrolled to the bottom while an answer streams, unless the
|
|
6
|
+
* visitor scrolled up. `Chat.Content` holds the messages; `Chat.ScrollButton` shows while not at
|
|
7
|
+
* the bottom.
|
|
8
|
+
*/
|
|
9
|
+
declare function Messages({ children, ...props }: ComponentPropsWithoutRef<"div">): import("react").JSX.Element;
|
|
10
|
+
/** The scrolling element and the content inside it (`viewportProps` go on the scrolling one). */
|
|
11
|
+
declare function Content({ children, viewportProps, ...props }: ComponentPropsWithoutRef<"div"> & {
|
|
12
|
+
viewportProps?: ComponentPropsWithoutRef<"div">;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
14
|
+
/** Back to the newest message; renders only while scrolled up. */
|
|
15
|
+
declare function ScrollButton({ onClick, ...props }: ComponentPropsWithoutRef<"button">): import("react").JSX.Element | null;
|
|
16
|
+
/**
|
|
17
|
+
* The composer: a form with a growing textarea; Enter sends, Shift+Enter breaks the line.
|
|
18
|
+
* `children` render after the textarea (the send or stop button: `Chat.Submit`).
|
|
19
|
+
*/
|
|
20
|
+
declare function Composer({ chat, children, textareaProps, ...props }: {
|
|
21
|
+
chat: AskChat;
|
|
22
|
+
children?: ReactNode;
|
|
23
|
+
textareaProps?: ComponentPropsWithoutRef<"textarea"> & {
|
|
24
|
+
ref?: Ref<HTMLTextAreaElement>;
|
|
25
|
+
};
|
|
26
|
+
} & Omit<ComponentProps<"form">, "children">): import("react").JSX.Element;
|
|
27
|
+
/** Send while idle (disabled with nothing typed), stop while an answer runs. `data-action` says which. */
|
|
28
|
+
declare function Submit({ chat, send, stop, ...props }: {
|
|
29
|
+
chat: AskChat;
|
|
30
|
+
send?: ReactNode;
|
|
31
|
+
stop?: ReactNode;
|
|
32
|
+
} & ComponentPropsWithoutRef<"button">): import("react").JSX.Element;
|
|
33
|
+
/**
|
|
34
|
+
* The panel's dialog (Radix): non-modal by default, so the page stays usable behind it; a press
|
|
35
|
+
* outside does not close it (only `Chat.Close` or Escape). `modal` for a sheet over the page.
|
|
36
|
+
* State: `data-state` (open/closed), `data-spotlight-panel`. Put a `Chat.Title` inside.
|
|
37
|
+
*/
|
|
38
|
+
declare function Panel({ open, onOpenChange, modal, children, ...props }: {
|
|
39
|
+
open: boolean;
|
|
40
|
+
onOpenChange: (open: boolean) => void;
|
|
41
|
+
modal?: boolean;
|
|
42
|
+
} & ComponentPropsWithoutRef<"div">): import("react").JSX.Element;
|
|
43
|
+
export declare const Chat: {
|
|
44
|
+
Panel: typeof Panel;
|
|
45
|
+
Title: import("react").ForwardRefExoticComponent<Dialog.DialogTitleProps & import("react").RefAttributes<HTMLHeadingElement>>;
|
|
46
|
+
Close: import("react").ForwardRefExoticComponent<Dialog.DialogCloseProps & import("react").RefAttributes<HTMLButtonElement>>;
|
|
47
|
+
Messages: typeof Messages;
|
|
48
|
+
Content: typeof Content;
|
|
49
|
+
ScrollButton: typeof ScrollButton;
|
|
50
|
+
Composer: typeof Composer;
|
|
51
|
+
Submit: typeof Submit;
|
|
52
|
+
Markdown: import("react").MemoExoticComponent<({ text, className }: {
|
|
53
|
+
text: string;
|
|
54
|
+
className?: string;
|
|
55
|
+
}) => import("react").JSX.Element>;
|
|
56
|
+
};
|
|
57
|
+
export {};
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
4
|
+
import { memo } from "react";
|
|
5
|
+
import { Streamdown } from "streamdown";
|
|
6
|
+
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
|
7
|
+
/**
|
|
8
|
+
* The conversation's scroll box: stays scrolled to the bottom while an answer streams, unless the
|
|
9
|
+
* visitor scrolled up. `Chat.Content` holds the messages; `Chat.ScrollButton` shows while not at
|
|
10
|
+
* the bottom.
|
|
11
|
+
*/
|
|
12
|
+
function Messages({ children, ...props }) {
|
|
13
|
+
return (_jsx(StickToBottom, { initial: "smooth", resize: "smooth", role: "log", "data-spotlight-messages": "", ...props, children: children }));
|
|
14
|
+
}
|
|
15
|
+
/** The scrolling element and the content inside it (`viewportProps` go on the scrolling one). */
|
|
16
|
+
function Content({ children, viewportProps, ...props }) {
|
|
17
|
+
const { scrollRef, contentRef } = useStickToBottomContext();
|
|
18
|
+
return (_jsx("div", { ref: scrollRef, "data-spotlight-viewport": "", "data-lenis-prevent": "", ...viewportProps, children: _jsx("div", { ref: contentRef, "data-spotlight-content": "", ...props, children: children }) }));
|
|
19
|
+
}
|
|
20
|
+
/** Back to the newest message; renders only while scrolled up. */
|
|
21
|
+
function ScrollButton({ onClick, ...props }) {
|
|
22
|
+
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
|
23
|
+
if (isAtBottom)
|
|
24
|
+
return null;
|
|
25
|
+
return (_jsx("button", { type: "button", "data-spotlight-scroll-button": "", ...props, onClick: (e) => {
|
|
26
|
+
onClick?.(e);
|
|
27
|
+
void scrollToBottom();
|
|
28
|
+
} }));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* The composer: a form with a growing textarea; Enter sends, Shift+Enter breaks the line.
|
|
32
|
+
* `children` render after the textarea (the send or stop button: `Chat.Submit`).
|
|
33
|
+
*/
|
|
34
|
+
function Composer({ chat, children, textareaProps, ...props }) {
|
|
35
|
+
return (_jsxs("form", { "data-spotlight-composer": "", ...props, onSubmit: (e) => {
|
|
36
|
+
e.preventDefault();
|
|
37
|
+
chat.send(chat.input);
|
|
38
|
+
}, children: [_jsx("textarea", { value: chat.input, onChange: (e) => chat.setInput(e.target.value), onKeyDown: (e) => {
|
|
39
|
+
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
|
40
|
+
e.preventDefault();
|
|
41
|
+
chat.send(chat.input);
|
|
42
|
+
}
|
|
43
|
+
}, placeholder: chat.copy.placeholderAsk, "aria-label": chat.copy.placeholderAsk, maxLength: chat.maxInput, rows: 1, ...textareaProps }), children] }));
|
|
44
|
+
}
|
|
45
|
+
/** Send while idle (disabled with nothing typed), stop while an answer runs. `data-action` says which. */
|
|
46
|
+
function Submit({ chat, send, stop, ...props }) {
|
|
47
|
+
return chat.busy ? (_jsx("button", { type: "button", "data-spotlight-submit": "", "data-action": "stop", "aria-label": chat.copy.stop, ...props, onClick: () => void chat.stop(), children: stop })) : (_jsx("button", { type: "submit", "data-spotlight-submit": "", "data-action": "send", "aria-label": chat.copy.send, disabled: !chat.input.trim(), ...props, children: send }));
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* An answer's markdown, streamed safely (Streamdown), unstyled: style it through `className`.
|
|
51
|
+
* Links open in place (the panel decides where they go); memoised on the text.
|
|
52
|
+
*/
|
|
53
|
+
const Markdown = memo(({ text, className }) => (_jsx(Streamdown, { className: className, linkSafety: { enabled: false }, children: text })), (a, b) => a.text === b.text && a.className === b.className);
|
|
54
|
+
/**
|
|
55
|
+
* The panel's dialog (Radix): non-modal by default, so the page stays usable behind it; a press
|
|
56
|
+
* outside does not close it (only `Chat.Close` or Escape). `modal` for a sheet over the page.
|
|
57
|
+
* State: `data-state` (open/closed), `data-spotlight-panel`. Put a `Chat.Title` inside.
|
|
58
|
+
*/
|
|
59
|
+
function Panel({ open, onOpenChange, modal = false, children, ...props }) {
|
|
60
|
+
return (_jsx(Dialog.Root, { open: open, onOpenChange: onOpenChange, modal: modal, children: _jsx(Dialog.Portal, { children: _jsx(Dialog.Content, { "data-spotlight-panel": "", "aria-describedby": undefined, onInteractOutside: (e) => {
|
|
61
|
+
if (!modal)
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
}, ...props, children: children }) }) }));
|
|
64
|
+
}
|
|
65
|
+
/** The panel's accessible title (shown; hide it yourself if you want it for screen readers only). */
|
|
66
|
+
const Title = Dialog.Title;
|
|
67
|
+
/** Closes the panel. */
|
|
68
|
+
const Close = Dialog.Close;
|
|
69
|
+
export const Chat = { Panel, Title, Close, Messages, Content, ScrollButton, Composer, Submit, Markdown };
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { type ChatTransport, type UIMessage } from "ai";
|
|
2
|
+
import { calledTool, sourcesOf } from "../core/messages.js";
|
|
3
|
+
import type { Action, AskConfig, ChatMessage } from "../types.js";
|
|
4
|
+
/**
|
|
5
|
+
* The default transport: the AI SDK UI message stream, the whole conversation sent each turn,
|
|
6
|
+
* `ask.body` and `ask.headers` added per request. A JSON error response's `code` becomes the
|
|
7
|
+
* error's message (mapped to `copy.errors`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function defaultTransport(ask: AskConfig): ChatTransport<UIMessage>;
|
|
10
|
+
export type ChatView = {
|
|
11
|
+
kind: "chat";
|
|
12
|
+
} | {
|
|
13
|
+
kind: "action";
|
|
14
|
+
action: Action;
|
|
15
|
+
input: unknown;
|
|
16
|
+
key: number;
|
|
17
|
+
};
|
|
18
|
+
export interface UseAskChatOptions {
|
|
19
|
+
/**
|
|
20
|
+
* Tool names that only drive the interface (an action, a "show the contact options" tool): the
|
|
21
|
+
* answer text is not cut at them (see `textOf`). The actions' own tools are added.
|
|
22
|
+
*/
|
|
23
|
+
uiTools?: string[];
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The panel's state: the AI SDK chat on the configured transport, the conversation kept through
|
|
27
|
+
* `persist`, the gate's requests (ask, open an action, show), actions opened from answers, and
|
|
28
|
+
* which view shows (the conversation or an action's view). Must be inside `SpotlightProvider`
|
|
29
|
+
* with `ask`.
|
|
30
|
+
*/
|
|
31
|
+
export declare function useAskChat({ uiTools }?: UseAskChatOptions): {
|
|
32
|
+
id: string;
|
|
33
|
+
messages: ChatMessage[];
|
|
34
|
+
status: import("ai").ChatStatus;
|
|
35
|
+
busy: boolean;
|
|
36
|
+
waiting: boolean;
|
|
37
|
+
error: Error | undefined;
|
|
38
|
+
/** The error's code, a key of `copy.errors` (`generic` for unknown ones); null without an error. */
|
|
39
|
+
errorCode: string | null;
|
|
40
|
+
input: string;
|
|
41
|
+
setInput: import("react").Dispatch<import("react").SetStateAction<string>>;
|
|
42
|
+
send: (value: string) => void;
|
|
43
|
+
stop: () => Promise<void>;
|
|
44
|
+
reset: () => void;
|
|
45
|
+
newChat: () => void;
|
|
46
|
+
back: () => void;
|
|
47
|
+
close: () => void;
|
|
48
|
+
view: ChatView;
|
|
49
|
+
setView: import("react").Dispatch<import("react").SetStateAction<ChatView>>;
|
|
50
|
+
openAction: (action: Action, value: unknown) => void;
|
|
51
|
+
actionOf: (m: ChatMessage) => {
|
|
52
|
+
action: Action;
|
|
53
|
+
input: unknown;
|
|
54
|
+
callId: string;
|
|
55
|
+
} | null;
|
|
56
|
+
lastAssistant: ChatMessage | undefined;
|
|
57
|
+
/** A message's text (answers: after the last content tool call). */
|
|
58
|
+
textOf: (m: ChatMessage) => string;
|
|
59
|
+
sourcesOf: typeof sourcesOf;
|
|
60
|
+
/** True when the message called `tool` (e.g. a handoff tool that shows contact options). */
|
|
61
|
+
calledTool: typeof calledTool;
|
|
62
|
+
copy: import("../types.js").SpotlightCopy;
|
|
63
|
+
/** Characters in one question (`ask.maxInput`). */
|
|
64
|
+
maxInput: number;
|
|
65
|
+
chat: import("@ai-sdk/react").UseChatHelpers<UIMessage<unknown, import("ai").UIDataTypes, import("ai").UITools>>;
|
|
66
|
+
};
|
|
67
|
+
export type AskChat = ReturnType<typeof useAskChat>;
|