@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.
Files changed (60) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +174 -2
  3. package/dist/build/index.d.ts +32 -0
  4. package/dist/build/index.js +68 -0
  5. package/dist/chat/index.d.ts +3 -0
  6. package/dist/chat/index.js +5 -0
  7. package/dist/chat/parts.d.ts +57 -0
  8. package/dist/chat/parts.js +69 -0
  9. package/dist/chat/useAskChat.d.ts +67 -0
  10. package/dist/chat/useAskChat.js +200 -0
  11. package/dist/core/copy.d.ts +4 -0
  12. package/dist/core/copy.js +40 -0
  13. package/dist/core/fold.d.ts +10 -0
  14. package/dist/core/fold.js +23 -0
  15. package/dist/core/messages.d.ts +24 -0
  16. package/dist/core/messages.js +72 -0
  17. package/dist/core/search.d.ts +73 -0
  18. package/dist/core/search.js +210 -0
  19. package/dist/core/storage.d.ts +17 -0
  20. package/dist/core/storage.js +48 -0
  21. package/dist/gate/index.d.ts +2 -0
  22. package/dist/gate/index.js +5 -0
  23. package/dist/gate/parts.d.ts +179 -0
  24. package/dist/gate/parts.js +282 -0
  25. package/dist/gate/useSearchAsk.d.ts +90 -0
  26. package/dist/gate/useSearchAsk.js +122 -0
  27. package/dist/index.d.ts +7 -0
  28. package/dist/index.js +8 -0
  29. package/dist/liquid/context.d.ts +26 -0
  30. package/dist/liquid/context.js +6 -0
  31. package/dist/liquid/gate.d.ts +8 -0
  32. package/dist/liquid/gate.js +52 -0
  33. package/dist/liquid/icons.d.ts +12 -0
  34. package/dist/liquid/icons.js +15 -0
  35. package/dist/liquid/index.d.ts +9 -0
  36. package/dist/liquid/index.js +19 -0
  37. package/dist/liquid/liquid.css +1007 -0
  38. package/dist/liquid/panel.d.ts +8 -0
  39. package/dist/liquid/panel.js +186 -0
  40. package/dist/liquid/util.d.ts +6 -0
  41. package/dist/liquid/util.js +16 -0
  42. package/dist/react/index.d.ts +6 -0
  43. package/dist/react/index.js +10 -0
  44. package/dist/react/provider.d.ts +52 -0
  45. package/dist/react/provider.js +218 -0
  46. package/dist/react/turnstile.d.ts +16 -0
  47. package/dist/react/turnstile.js +100 -0
  48. package/dist/server/handler.d.ts +102 -0
  49. package/dist/server/handler.js +274 -0
  50. package/dist/server/index.d.ts +4 -0
  51. package/dist/server/index.js +5 -0
  52. package/dist/server/sources.d.ts +79 -0
  53. package/dist/server/sources.js +315 -0
  54. package/dist/server/turnstile.d.ts +15 -0
  55. package/dist/server/turnstile.js +29 -0
  56. package/dist/server/types.d.ts +34 -0
  57. package/dist/server/types.js +1 -0
  58. package/dist/types.d.ts +207 -0
  59. package/dist/types.js +7 -0
  60. 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
- # Temporary Holding Version
1
+ # @perxel/spotlight
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ ![The liquid skin on perxel.com: results as you type "next", the typed word marked in each row](https://raw.githubusercontent.com/perxel/perxel-spotlight/main/docs/perxel-gate.webp)
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
+ | ![The chat panel answering a question, with the pages it used folded under the answer](https://raw.githubusercontent.com/perxel/perxel-spotlight/main/docs/perxel-panel.webp) | ![A plain, paper-like search box on phucbm.com](https://raw.githubusercontent.com/perxel/perxel-spotlight/main/docs/phucbm-gate.webp) |
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,3 @@
1
+ export { useAskChat, defaultTransport, type AskChat, type ChatView, type UseAskChatOptions } from "./useAskChat.js";
2
+ export { Chat } from "./parts.js";
3
+ export { calledTool, sourcesOf, textOf, toolOutputOf, latestFromVisitor } from "../core/messages.js";
@@ -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>;