@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,200 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useChat } from "@ai-sdk/react";
|
|
3
|
+
import { DefaultChatTransport } from "ai";
|
|
4
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import { mergeCopy } from "../core/copy.js";
|
|
6
|
+
import { calledTool, sourcesOf, textOf, toolOutputOf } from "../core/messages.js";
|
|
7
|
+
import { useSpotlight } from "../react/provider.js";
|
|
8
|
+
const resolve = async (v) => (typeof v === "function" ? v() : v);
|
|
9
|
+
/**
|
|
10
|
+
* The default transport: the AI SDK UI message stream, the whole conversation sent each turn,
|
|
11
|
+
* `ask.body` and `ask.headers` added per request. A JSON error response's `code` becomes the
|
|
12
|
+
* error's message (mapped to `copy.errors`).
|
|
13
|
+
*/
|
|
14
|
+
export function defaultTransport(ask) {
|
|
15
|
+
return new DefaultChatTransport({
|
|
16
|
+
api: ask.endpoint,
|
|
17
|
+
prepareSendMessagesRequest: async ({ id, messages, body }) => ({
|
|
18
|
+
body: { ...body, id, messages, ...(await resolve(ask.body)) },
|
|
19
|
+
headers: (await resolve(ask.headers)) ?? {},
|
|
20
|
+
}),
|
|
21
|
+
fetch: async (input, init) => {
|
|
22
|
+
const res = await fetch(input, init);
|
|
23
|
+
if (!res.ok) {
|
|
24
|
+
const code = await res
|
|
25
|
+
.json()
|
|
26
|
+
.then((b) => (typeof b.code === "string" ? b.code : "generic"))
|
|
27
|
+
.catch(() => "generic");
|
|
28
|
+
throw new Error(code);
|
|
29
|
+
}
|
|
30
|
+
return res;
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The panel's state: the AI SDK chat on the configured transport, the conversation kept through
|
|
36
|
+
* `persist`, the gate's requests (ask, open an action, show), actions opened from answers, and
|
|
37
|
+
* which view shows (the conversation or an action's view). Must be inside `SpotlightProvider`
|
|
38
|
+
* with `ask`.
|
|
39
|
+
*/
|
|
40
|
+
export function useAskChat({ uiTools = [] } = {}) {
|
|
41
|
+
const s = useSpotlight();
|
|
42
|
+
const ask = s.ask ?? {};
|
|
43
|
+
const persist = s.persist;
|
|
44
|
+
const maxInput = ask.maxInput ?? 1000;
|
|
45
|
+
const initial = useMemo(() => persist.read(), [persist]);
|
|
46
|
+
const [id, setId] = useState(() => initial?.id ?? crypto.randomUUID());
|
|
47
|
+
// When the visitor last sent a message: the storage time counts from here, not from each visit.
|
|
48
|
+
const lastSent = useRef(initial?.savedAt ?? 0);
|
|
49
|
+
const [input, setInput] = useState("");
|
|
50
|
+
const [view, setView] = useState({ kind: "chat" });
|
|
51
|
+
const viewKey = useRef(0);
|
|
52
|
+
const transport = useMemo(() => ask.transport ?? defaultTransport(ask),
|
|
53
|
+
// The config object may be recreated per render; the endpoint and transport are what matter.
|
|
54
|
+
[ask.transport, ask.endpoint]);
|
|
55
|
+
const askRef = useRef(ask);
|
|
56
|
+
askRef.current = ask;
|
|
57
|
+
const chat = useChat({
|
|
58
|
+
id,
|
|
59
|
+
messages: initial?.id === id ? initial.messages : [],
|
|
60
|
+
transport,
|
|
61
|
+
});
|
|
62
|
+
const { messages, sendMessage, status, error, stop, setMessages, clearError } = chat;
|
|
63
|
+
const list = messages;
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
persist.write(list.length ? { id, messages: list, savedAt: lastSent.current } : null);
|
|
66
|
+
}, [id, messages, status]);
|
|
67
|
+
const actionTools = useMemo(() => new Map(s.actions.filter((a) => a.tool).map((a) => [a.tool, a])), [s.actions]);
|
|
68
|
+
const ui = useMemo(() => new Set([...uiTools, ...actionTools.keys()]), [uiTools.join("\u0000"), actionTools]);
|
|
69
|
+
const busy = status === "submitted" || status === "streaming";
|
|
70
|
+
const copy = useMemo(() => mergeCopy(s.copy), [s.copy]);
|
|
71
|
+
const errorCode = error ? (error.message in copy.errors ? error.message : "generic") : null;
|
|
72
|
+
const lastAssistant = [...list].reverse().find((m) => m.role === "assistant");
|
|
73
|
+
const text = (m) => textOf(m, ui);
|
|
74
|
+
// Waiting: sent, and no answer text yet.
|
|
75
|
+
const waiting = busy && !(lastAssistant && status === "streaming" && text(lastAssistant));
|
|
76
|
+
/** The action an answer prepared (its tool has returned), with its input. */
|
|
77
|
+
const actionOf = (m) => {
|
|
78
|
+
if (m.role !== "assistant" || !actionTools.size)
|
|
79
|
+
return null;
|
|
80
|
+
const out = toolOutputOf(m, actionTools.keys());
|
|
81
|
+
if (!out)
|
|
82
|
+
return null;
|
|
83
|
+
const action = actionTools.get(out.tool);
|
|
84
|
+
return { action, input: action.fromTool ? action.fromTool(out.output, list) : out.output, callId: out.callId };
|
|
85
|
+
};
|
|
86
|
+
const openAction = (action, value) => {
|
|
87
|
+
if (action.view)
|
|
88
|
+
setView({ kind: "action", action, input: value, key: ++viewKey.current });
|
|
89
|
+
else
|
|
90
|
+
action.run?.(value);
|
|
91
|
+
};
|
|
92
|
+
// An answer that prepared an action opens it once the answer is done. Ones restored from
|
|
93
|
+
// storage stay closed.
|
|
94
|
+
const opened = useRef(new Set((initial?.messages ?? []).flatMap((m) => {
|
|
95
|
+
const out = toolOutputOf(m, s.actions.flatMap((a) => (a.tool ? [a.tool] : [])));
|
|
96
|
+
return out ? [out.callId] : [];
|
|
97
|
+
})));
|
|
98
|
+
const pending = lastAssistant ? actionOf(lastAssistant) : null;
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (busy || !pending || opened.current.has(pending.callId))
|
|
101
|
+
return;
|
|
102
|
+
opened.current.add(pending.callId);
|
|
103
|
+
openAction(pending.action, pending.input);
|
|
104
|
+
}, [busy, pending?.callId]);
|
|
105
|
+
const send = (value) => {
|
|
106
|
+
const t = value.trim().slice(0, maxInput);
|
|
107
|
+
if (!t || busy)
|
|
108
|
+
return;
|
|
109
|
+
clearError();
|
|
110
|
+
setInput("");
|
|
111
|
+
lastSent.current = Date.now();
|
|
112
|
+
void sendMessage({ text: t });
|
|
113
|
+
};
|
|
114
|
+
const reset = () => {
|
|
115
|
+
stop();
|
|
116
|
+
setMessages([]);
|
|
117
|
+
clearError();
|
|
118
|
+
setView({ kind: "chat" });
|
|
119
|
+
setId(crypto.randomUUID());
|
|
120
|
+
persist.write(null);
|
|
121
|
+
};
|
|
122
|
+
// The gate's request. A question waits in `queued` until the chat can take it: after a reset
|
|
123
|
+
// (a new `id`, so a new chat instance next render) or once a running answer has stopped.
|
|
124
|
+
const [queued, setQueued] = useState(null);
|
|
125
|
+
const handled = useRef(0);
|
|
126
|
+
const request = s.request;
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
if (!request || request.n === handled.current)
|
|
129
|
+
return;
|
|
130
|
+
handled.current = request.n;
|
|
131
|
+
const r = request.r;
|
|
132
|
+
if (r.kind === "ask") {
|
|
133
|
+
if (r.fresh && list.length)
|
|
134
|
+
reset();
|
|
135
|
+
else if (busy)
|
|
136
|
+
void stop();
|
|
137
|
+
setView({ kind: "chat" });
|
|
138
|
+
setQueued(r.text);
|
|
139
|
+
}
|
|
140
|
+
else if (r.kind === "action") {
|
|
141
|
+
const action = s.actions.find((a) => a.id === r.id);
|
|
142
|
+
if (action)
|
|
143
|
+
openAction(action, r.input);
|
|
144
|
+
}
|
|
145
|
+
else
|
|
146
|
+
setView({ kind: "chat" });
|
|
147
|
+
// Once per request; the handlers read the current state.
|
|
148
|
+
}, [request?.n]);
|
|
149
|
+
useEffect(() => {
|
|
150
|
+
if (queued === null || busy)
|
|
151
|
+
return;
|
|
152
|
+
setQueued(null);
|
|
153
|
+
send(queued);
|
|
154
|
+
}, [queued, busy, id]);
|
|
155
|
+
/** Start over through the gate: the panel has no empty state of its own. */
|
|
156
|
+
const newChat = () => {
|
|
157
|
+
reset();
|
|
158
|
+
s.setPanelOpen(false);
|
|
159
|
+
s.openGate();
|
|
160
|
+
};
|
|
161
|
+
/** An action view's back: to the conversation, or (opened from the gate with none) back to the gate. */
|
|
162
|
+
const back = () => {
|
|
163
|
+
if (list.length)
|
|
164
|
+
return setView({ kind: "chat" });
|
|
165
|
+
s.setPanelOpen(false);
|
|
166
|
+
s.openGate();
|
|
167
|
+
};
|
|
168
|
+
return {
|
|
169
|
+
id,
|
|
170
|
+
messages: list,
|
|
171
|
+
status,
|
|
172
|
+
busy,
|
|
173
|
+
waiting,
|
|
174
|
+
error,
|
|
175
|
+
/** The error's code, a key of `copy.errors` (`generic` for unknown ones); null without an error. */
|
|
176
|
+
errorCode,
|
|
177
|
+
input,
|
|
178
|
+
setInput,
|
|
179
|
+
send,
|
|
180
|
+
stop,
|
|
181
|
+
reset,
|
|
182
|
+
newChat,
|
|
183
|
+
back,
|
|
184
|
+
close: () => s.setPanelOpen(false),
|
|
185
|
+
view,
|
|
186
|
+
setView,
|
|
187
|
+
openAction,
|
|
188
|
+
actionOf,
|
|
189
|
+
lastAssistant,
|
|
190
|
+
/** A message's text (answers: after the last content tool call). */
|
|
191
|
+
textOf: text,
|
|
192
|
+
sourcesOf,
|
|
193
|
+
/** True when the message called `tool` (e.g. a handoff tool that shows contact options). */
|
|
194
|
+
calledTool,
|
|
195
|
+
copy,
|
|
196
|
+
/** Characters in one question (`ask.maxInput`). */
|
|
197
|
+
maxInput,
|
|
198
|
+
chat,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** English defaults. A site passes its own strings (any language) through `copy`. */
|
|
2
|
+
export const defaultCopy = {
|
|
3
|
+
gateTitle: "Search",
|
|
4
|
+
placeholder: "Search or ask…",
|
|
5
|
+
placeholderSearch: "Search…",
|
|
6
|
+
placeholderAsk: "Ask a question…",
|
|
7
|
+
hintAsk: "to ask AI",
|
|
8
|
+
hintOpen: "to open",
|
|
9
|
+
kinds: {},
|
|
10
|
+
results: (n) => (n === 1 ? "1 result" : `${n} results`),
|
|
11
|
+
panelTitle: "Ask",
|
|
12
|
+
newChat: "New chat",
|
|
13
|
+
close: "Close",
|
|
14
|
+
back: "Back",
|
|
15
|
+
send: "Send",
|
|
16
|
+
stop: "Stop",
|
|
17
|
+
thinking: "Looking it up",
|
|
18
|
+
sources: "Sources",
|
|
19
|
+
scrollToEnd: "Scroll to the newest message",
|
|
20
|
+
errors: {
|
|
21
|
+
generic: "Something went wrong. Please try again.",
|
|
22
|
+
rate_limited: "Too many questions at once. Please wait a minute.",
|
|
23
|
+
verify_failed: "We could not check that you are not a bot. Please reload and try again.",
|
|
24
|
+
conversation_limit: "This conversation is at its limit. Start a new chat.",
|
|
25
|
+
budget: "Ask is resting for today. Please try again tomorrow.",
|
|
26
|
+
config: "Ask is not available right now.",
|
|
27
|
+
upstream: "The answer could not be written. Please try again.",
|
|
28
|
+
bad_request: "That message could not be sent.",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export function mergeCopy(copy) {
|
|
32
|
+
if (!copy)
|
|
33
|
+
return defaultCopy;
|
|
34
|
+
return {
|
|
35
|
+
...defaultCopy,
|
|
36
|
+
...copy,
|
|
37
|
+
kinds: { ...defaultCopy.kinds, ...copy.kinds },
|
|
38
|
+
errors: { ...defaultCopy.errors, ...copy.errors },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lowercase, accents and other combining marks removed, `đ` folded to `d`, a dot or apostrophe
|
|
3
|
+
* inside a word dropped ("Next.js" → "nextjs"), anything else that is not a letter or digit
|
|
4
|
+
* turned into a space. "Dịch vụ & SEO" → "dich vu seo".
|
|
5
|
+
*/
|
|
6
|
+
export declare function fold(text: string): string;
|
|
7
|
+
/** Email addresses in a text (global). */
|
|
8
|
+
export declare const EMAIL_IN_TEXT: RegExp;
|
|
9
|
+
/** The first email address in a text. */
|
|
10
|
+
export declare function emailIn(text: string): string | undefined;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Small text helpers on their own (folding for matching, finding an email), so light modules (an
|
|
2
|
+
// action's `fromSearch`) can use them without the search engine or the message helpers.
|
|
3
|
+
/**
|
|
4
|
+
* Lowercase, accents and other combining marks removed, `đ` folded to `d`, a dot or apostrophe
|
|
5
|
+
* inside a word dropped ("Next.js" → "nextjs"), anything else that is not a letter or digit
|
|
6
|
+
* turned into a space. "Dịch vụ & SEO" → "dich vu seo".
|
|
7
|
+
*/
|
|
8
|
+
export function fold(text) {
|
|
9
|
+
return text
|
|
10
|
+
.normalize("NFD")
|
|
11
|
+
.replace(/\p{M}+/gu, "")
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/đ/g, "d")
|
|
14
|
+
.replace(/(?<=[\p{L}\p{N}])[.'’](?=[\p{L}\p{N}])/gu, "")
|
|
15
|
+
.replace(/[^\p{L}\p{N}]+/gu, " ")
|
|
16
|
+
.trim();
|
|
17
|
+
}
|
|
18
|
+
/** Email addresses in a text (global). */
|
|
19
|
+
export const EMAIL_IN_TEXT = /[^\s@<>()"',;:]+@[^\s@<>()"',;:]+\.[a-z]{2,}/gi;
|
|
20
|
+
/** The first email address in a text. */
|
|
21
|
+
export function emailIn(text) {
|
|
22
|
+
return text.match(new RegExp(EMAIL_IN_TEXT.source, "i"))?.[0];
|
|
23
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { ChatMessage, ChatPart } from "../types.js";
|
|
2
|
+
/** A tool part (`tool-<name>`, or `dynamic-tool` with `toolName`). */
|
|
3
|
+
export declare function toolName(part: ChatPart): string | null;
|
|
4
|
+
/**
|
|
5
|
+
* A message's text. For an answer, only what follows its last content tool call: what a model
|
|
6
|
+
* writes between tool steps ("let me check…") is not the answer. Tools named in `uiTools` (ones
|
|
7
|
+
* that only drive the interface, like an action) do not cut the text.
|
|
8
|
+
*/
|
|
9
|
+
export declare function textOf(m: ChatMessage, uiTools?: ReadonlySet<string>): string;
|
|
10
|
+
/** The sources an answer cited (`source-url` parts), deduplicated. */
|
|
11
|
+
export declare function sourcesOf(m: ChatMessage): {
|
|
12
|
+
url: string;
|
|
13
|
+
title: string;
|
|
14
|
+
}[];
|
|
15
|
+
/** The first finished call of one of `tools` in a message: its call id, tool name and output. */
|
|
16
|
+
export declare function toolOutputOf(m: ChatMessage, tools: Iterable<string>): {
|
|
17
|
+
callId: string;
|
|
18
|
+
tool: string;
|
|
19
|
+
output: unknown;
|
|
20
|
+
} | null;
|
|
21
|
+
/** True when the message called `tool` (any state). */
|
|
22
|
+
export declare function calledTool(m: ChatMessage, tool: string): boolean;
|
|
23
|
+
/** The latest email (or, for `contact`, email or phone number) in the visitor's own messages. */
|
|
24
|
+
export declare function latestFromVisitor(messages: ChatMessage[], kind: "email" | "contact"): string | undefined;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Helpers that read chat messages (AI SDK `UIMessage`s, structurally). No React, no AI SDK.
|
|
2
|
+
import { EMAIL_IN_TEXT } from "./fold.js";
|
|
3
|
+
/** A tool part (`tool-<name>`, or `dynamic-tool` with `toolName`). */
|
|
4
|
+
export function toolName(part) {
|
|
5
|
+
if (part.type === "dynamic-tool")
|
|
6
|
+
return typeof part.toolName === "string" ? part.toolName : null;
|
|
7
|
+
return part.type.startsWith("tool-") ? part.type.slice(5) : null;
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A message's text. For an answer, only what follows its last content tool call: what a model
|
|
11
|
+
* writes between tool steps ("let me check…") is not the answer. Tools named in `uiTools` (ones
|
|
12
|
+
* that only drive the interface, like an action) do not cut the text.
|
|
13
|
+
*/
|
|
14
|
+
export function textOf(m, uiTools = new Set()) {
|
|
15
|
+
let parts = m.parts;
|
|
16
|
+
if (m.role === "assistant") {
|
|
17
|
+
const lastTool = parts.findLastIndex((p) => {
|
|
18
|
+
const name = toolName(p);
|
|
19
|
+
return name !== null && !uiTools.has(name);
|
|
20
|
+
});
|
|
21
|
+
const after = parts.slice(lastTool + 1);
|
|
22
|
+
if (lastTool >= 0 && after.some((p) => p.type === "text" && String(p.text ?? "").trim()))
|
|
23
|
+
parts = after;
|
|
24
|
+
}
|
|
25
|
+
return parts.map((p) => (p.type === "text" ? String(p.text ?? "") : "")).join("").trim();
|
|
26
|
+
}
|
|
27
|
+
/** The sources an answer cited (`source-url` parts), deduplicated. */
|
|
28
|
+
export function sourcesOf(m) {
|
|
29
|
+
const out = [];
|
|
30
|
+
for (const p of m.parts) {
|
|
31
|
+
if (p.type !== "source-url" || typeof p.url !== "string")
|
|
32
|
+
continue;
|
|
33
|
+
if (out.some((s) => s.url === p.url))
|
|
34
|
+
continue;
|
|
35
|
+
out.push({ url: p.url, title: typeof p.title === "string" && p.title ? p.title : p.url });
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
/** The first finished call of one of `tools` in a message: its call id, tool name and output. */
|
|
40
|
+
export function toolOutputOf(m, tools) {
|
|
41
|
+
const names = new Set(tools);
|
|
42
|
+
for (const p of m.parts) {
|
|
43
|
+
const name = toolName(p);
|
|
44
|
+
if (!name || !names.has(name))
|
|
45
|
+
continue;
|
|
46
|
+
if (p.state === "output-available" && p.output !== undefined)
|
|
47
|
+
return { callId: String(p.toolCallId), tool: name, output: p.output };
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
/** True when the message called `tool` (any state). */
|
|
52
|
+
export function calledTool(m, tool) {
|
|
53
|
+
return m.parts.some((p) => toolName(p) === tool);
|
|
54
|
+
}
|
|
55
|
+
const PHONE_IN_TEXT = /\+?\d[\d\s.-]{6,}\d/g;
|
|
56
|
+
/** The latest email (or, for `contact`, email or phone number) in the visitor's own messages. */
|
|
57
|
+
export function latestFromVisitor(messages, kind) {
|
|
58
|
+
for (const m of [...messages].reverse()) {
|
|
59
|
+
if (m.role !== "user")
|
|
60
|
+
continue;
|
|
61
|
+
const text = textOf(m);
|
|
62
|
+
const email = text.match(EMAIL_IN_TEXT)?.at(-1);
|
|
63
|
+
if (email)
|
|
64
|
+
return email;
|
|
65
|
+
if (kind === "contact") {
|
|
66
|
+
const phone = text.match(PHONE_IN_TEXT)?.findLast((n) => n.replace(/\D/g, "").length >= 8);
|
|
67
|
+
if (phone)
|
|
68
|
+
return phone.trim();
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return undefined;
|
|
72
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { fold } from "./fold.js";
|
|
2
|
+
export { fold };
|
|
3
|
+
/** One searchable thing. The host builds these (usually at build time) from its own content. */
|
|
4
|
+
export interface SearchDoc {
|
|
5
|
+
/** Unique across the set. */
|
|
6
|
+
id: string;
|
|
7
|
+
/** What it is ("page", "insight", "action"…). Results are grouped and tie-broken by it. */
|
|
8
|
+
kind: string;
|
|
9
|
+
/** Shown and ranked first. */
|
|
10
|
+
title: string;
|
|
11
|
+
/** Shown under or beside the title (a category, a short label). Matched, weakly. */
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
/** Where opening it goes. Omit for things that are not links (actions): the host handles them in `onOpen`. */
|
|
14
|
+
url?: string;
|
|
15
|
+
/** Extra text to match but not show (short titles, tags, synonyms). */
|
|
16
|
+
keywords?: string[];
|
|
17
|
+
/**
|
|
18
|
+
* Readable text, matched like `keywords` and shown as the result's `snippet` when the title
|
|
19
|
+
* does not explain the match (section headings, an excerpt). Ordered by preference: the first
|
|
20
|
+
* one holding the most of the words missing from the title is shown.
|
|
21
|
+
*/
|
|
22
|
+
snippets?: string[];
|
|
23
|
+
}
|
|
24
|
+
/** A built index: the docs plus their folded text. Plain data, so it can be serialized. */
|
|
25
|
+
export interface SearchIndex {
|
|
26
|
+
docs: SearchDoc[];
|
|
27
|
+
/** Per doc: folded title, folded subtitle, folded keywords and snippets (joined). */
|
|
28
|
+
text: [title: string, subtitle: string, keywords: string][];
|
|
29
|
+
}
|
|
30
|
+
export interface SearchOptions {
|
|
31
|
+
/** At most this many results (default 8). */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Kinds in display order; also breaks score ties. Kinds not listed come after, in first-seen order. */
|
|
34
|
+
kindOrder?: string[];
|
|
35
|
+
/** A snippet longer than this many characters is cut to a window around its first match (default 80). */
|
|
36
|
+
snippetLength?: number;
|
|
37
|
+
}
|
|
38
|
+
/** A doc's snippet with the typed words marked: `[start, end)` ranges into `text`, in order. */
|
|
39
|
+
export interface Snippet {
|
|
40
|
+
text: string;
|
|
41
|
+
marks: [start: number, end: number][];
|
|
42
|
+
}
|
|
43
|
+
export interface SearchResult {
|
|
44
|
+
doc: SearchDoc;
|
|
45
|
+
score: number;
|
|
46
|
+
/** Where the typed words are in `doc.title`, as `[start, end)` ranges (see `markTerms`). Empty when only other fields matched. */
|
|
47
|
+
titleMarks: [start: number, end: number][];
|
|
48
|
+
/** Set when some typed word is not in the title but is in one of the doc's `snippets`. */
|
|
49
|
+
snippet?: Snippet;
|
|
50
|
+
}
|
|
51
|
+
export declare function buildIndex(docs: SearchDoc[]): SearchIndex;
|
|
52
|
+
/**
|
|
53
|
+
* Every query term must match somewhere (title, subtitle, keywords or snippets). Ranking: the whole
|
|
54
|
+
* query starting the title, then per term: word prefix in the title, substring in the title,
|
|
55
|
+
* word prefix in keywords, then subtitle, then substring there. Ties go by `kindOrder`, then
|
|
56
|
+
* the docs' own order. An empty query returns nothing.
|
|
57
|
+
*
|
|
58
|
+
* When no doc matches every term ("quote landing page": the action matches "quote", a post
|
|
59
|
+
* "landing page"), the docs matching the most terms are returned instead, so a query that
|
|
60
|
+
* mixes an intent and a topic still finds both.
|
|
61
|
+
*/
|
|
62
|
+
export declare function search(index: SearchIndex, query: string, { limit, kindOrder, snippetLength }?: SearchOptions): SearchResult[];
|
|
63
|
+
/**
|
|
64
|
+
* Where `terms` (folded) match in `text` (not folded), as ranges into `text`: word starts, and
|
|
65
|
+
* anywhere for terms of 3+ characters, like the search. Folds one character at a time, so each
|
|
66
|
+
* folded character knows the original it came from ("trình" marks all of "trình" for "trinh").
|
|
67
|
+
*/
|
|
68
|
+
export declare function markTerms(text: string, terms: string[]): [number, number][];
|
|
69
|
+
/** Results in display order: grouped by kind, groups in `kindOrder`, best first inside a group. */
|
|
70
|
+
export declare function groupByKind(results: SearchResult[], kindOrder?: string[]): {
|
|
71
|
+
kind: string;
|
|
72
|
+
results: SearchResult[];
|
|
73
|
+
}[];
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// The search engine: instant, client-side search over a small set of documents (a site's pages,
|
|
2
|
+
// posts, actions). No React, no Next, no dependencies: pure functions, safe anywhere.
|
|
3
|
+
// Imports only `fold`.
|
|
4
|
+
import { fold } from "./fold.js";
|
|
5
|
+
export { fold };
|
|
6
|
+
/** The title with in-word dots split ("Next.js" → "next js"), so "next js" finds it too. */
|
|
7
|
+
function splitDots(text) {
|
|
8
|
+
return fold(text.replace(/[.]/g, " "));
|
|
9
|
+
}
|
|
10
|
+
export function buildIndex(docs) {
|
|
11
|
+
return {
|
|
12
|
+
docs,
|
|
13
|
+
text: docs.map((d) => {
|
|
14
|
+
const title = fold(d.title);
|
|
15
|
+
const split = splitDots(d.title);
|
|
16
|
+
const extra = [...(d.keywords ?? []), ...(d.snippets ?? [])];
|
|
17
|
+
return [title, fold(d.subtitle ?? ""), [fold(extra.join(" ")), split === title ? "" : split].join(" ").trim()];
|
|
18
|
+
}),
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** True when `term` starts a word in `text` (both folded). */
|
|
22
|
+
function wordPrefix(text, term) {
|
|
23
|
+
return text.startsWith(term) || text.includes(" " + term);
|
|
24
|
+
}
|
|
25
|
+
/** True when `term` matches `text` (both folded): a word start, or anywhere for terms of 3+ characters. */
|
|
26
|
+
function hits(text, term) {
|
|
27
|
+
return wordPrefix(text, term) || (term.length >= 3 && text.includes(term));
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* How well one query term matches a doc's folded text, 0 for not at all. Terms of one or two
|
|
31
|
+
* characters only match the start of a word, so "a" does not match every doc.
|
|
32
|
+
*/
|
|
33
|
+
function termScore([title, subtitle, keywords], term) {
|
|
34
|
+
if (wordPrefix(title, term))
|
|
35
|
+
return 10;
|
|
36
|
+
const short = term.length < 3;
|
|
37
|
+
if (!short && title.includes(term))
|
|
38
|
+
return 6;
|
|
39
|
+
if (wordPrefix(keywords, term))
|
|
40
|
+
return 4;
|
|
41
|
+
if (wordPrefix(subtitle, term))
|
|
42
|
+
return 3;
|
|
43
|
+
if (!short && (keywords.includes(term) || subtitle.includes(term)))
|
|
44
|
+
return 1;
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Every query term must match somewhere (title, subtitle, keywords or snippets). Ranking: the whole
|
|
49
|
+
* query starting the title, then per term: word prefix in the title, substring in the title,
|
|
50
|
+
* word prefix in keywords, then subtitle, then substring there. Ties go by `kindOrder`, then
|
|
51
|
+
* the docs' own order. An empty query returns nothing.
|
|
52
|
+
*
|
|
53
|
+
* When no doc matches every term ("quote landing page": the action matches "quote", a post
|
|
54
|
+
* "landing page"), the docs matching the most terms are returned instead, so a query that
|
|
55
|
+
* mixes an intent and a topic still finds both.
|
|
56
|
+
*/
|
|
57
|
+
export function search(index, query, { limit = 8, kindOrder = [], snippetLength = 80 } = {}) {
|
|
58
|
+
const q = fold(query);
|
|
59
|
+
if (!q)
|
|
60
|
+
return [];
|
|
61
|
+
const terms = [...new Set(q.split(" "))];
|
|
62
|
+
const rank = kindRank(index.docs, kindOrder);
|
|
63
|
+
const all = [];
|
|
64
|
+
index.text.forEach((text, i) => {
|
|
65
|
+
let score = 0;
|
|
66
|
+
let matched = 0;
|
|
67
|
+
for (const term of terms) {
|
|
68
|
+
const s = termScore(text, term);
|
|
69
|
+
if (s)
|
|
70
|
+
matched++;
|
|
71
|
+
score += s;
|
|
72
|
+
}
|
|
73
|
+
if (!matched)
|
|
74
|
+
return;
|
|
75
|
+
if (text[0].startsWith(q))
|
|
76
|
+
score += 20;
|
|
77
|
+
else if (text[0].includes(q) && terms.length > 1)
|
|
78
|
+
score += 8;
|
|
79
|
+
all.push({ doc: index.docs[i], score, i, matched });
|
|
80
|
+
});
|
|
81
|
+
const most = Math.max(0, ...all.map((h) => h.matched));
|
|
82
|
+
const found = all.filter((h) => h.matched === most);
|
|
83
|
+
found.sort((a, b) => b.score - a.score || rank(a.doc.kind) - rank(b.doc.kind) || a.i - b.i);
|
|
84
|
+
return found.slice(0, limit).map(({ doc, score, i }) => {
|
|
85
|
+
const snippet = snippetFor(doc, index.text[i][0], terms, snippetLength);
|
|
86
|
+
const titleMarks = markTerms(doc.title, terms);
|
|
87
|
+
return snippet ? { doc, score, titleMarks, snippet } : { doc, score, titleMarks };
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* The snippet to show under a result: none while every term is in the title (the title explains
|
|
92
|
+
* the match); else the first of `doc.snippets` holding the most of the missing terms, every
|
|
93
|
+
* term marked, cut to `max` characters around its first mark.
|
|
94
|
+
*/
|
|
95
|
+
function snippetFor(doc, title, terms, max) {
|
|
96
|
+
const titleText = title + " " + splitDots(doc.title);
|
|
97
|
+
const missing = terms.filter((t) => !hits(titleText, t));
|
|
98
|
+
if (!missing.length || !doc.snippets?.length)
|
|
99
|
+
return;
|
|
100
|
+
let best;
|
|
101
|
+
let bestCount = 0;
|
|
102
|
+
for (const text of doc.snippets) {
|
|
103
|
+
const folded = fold(text);
|
|
104
|
+
const count = missing.filter((t) => hits(folded, t)).length;
|
|
105
|
+
if (count > bestCount)
|
|
106
|
+
[best, bestCount] = [text, count];
|
|
107
|
+
}
|
|
108
|
+
if (!best)
|
|
109
|
+
return;
|
|
110
|
+
return cutAround({ text: best, marks: markTerms(best, terms) }, max);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Where `terms` (folded) match in `text` (not folded), as ranges into `text`: word starts, and
|
|
114
|
+
* anywhere for terms of 3+ characters, like the search. Folds one character at a time, so each
|
|
115
|
+
* folded character knows the original it came from ("trình" marks all of "trình" for "trinh").
|
|
116
|
+
*/
|
|
117
|
+
export function markTerms(text, terms) {
|
|
118
|
+
let folded = "";
|
|
119
|
+
const from = [];
|
|
120
|
+
const to = [];
|
|
121
|
+
const chars = Array.from(text);
|
|
122
|
+
let at = 0;
|
|
123
|
+
chars.forEach((c, k) => {
|
|
124
|
+
const end = at + c.length;
|
|
125
|
+
const f = c.normalize("NFD").replace(/\p{M}+/gu, "").toLowerCase().replace(/đ/g, "d");
|
|
126
|
+
const word = (x) => !!x && /[\p{L}\p{N}]/u.test(x);
|
|
127
|
+
if (f && /^[\p{L}\p{N}]+$/u.test(f)) {
|
|
128
|
+
for (const ch of f) {
|
|
129
|
+
folded += ch;
|
|
130
|
+
from.push(at);
|
|
131
|
+
to.push(end);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
else if (!f) {
|
|
135
|
+
// A combining mark of decomposed text: part of the previous character.
|
|
136
|
+
if (to.length)
|
|
137
|
+
to[to.length - 1] = end;
|
|
138
|
+
}
|
|
139
|
+
else if (/[.'’]/.test(c) && word(folded.at(-1)) && word(chars[k + 1])) {
|
|
140
|
+
// A dot or apostrophe inside a word is dropped, as in `fold`.
|
|
141
|
+
}
|
|
142
|
+
else if (folded && !folded.endsWith(" ")) {
|
|
143
|
+
folded += " ";
|
|
144
|
+
from.push(at);
|
|
145
|
+
to.push(end);
|
|
146
|
+
}
|
|
147
|
+
at = end;
|
|
148
|
+
});
|
|
149
|
+
const ranges = [];
|
|
150
|
+
for (const term of terms) {
|
|
151
|
+
if (!term)
|
|
152
|
+
continue;
|
|
153
|
+
for (let p = folded.indexOf(term); p !== -1; p = folded.indexOf(term, p + 1)) {
|
|
154
|
+
if (term.length < 3 && p > 0 && folded[p - 1] !== " ")
|
|
155
|
+
continue;
|
|
156
|
+
ranges.push([from[p], to[p + term.length - 1]]);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
ranges.sort((a, b) => a[0] - b[0]);
|
|
160
|
+
const merged = [];
|
|
161
|
+
for (const r of ranges) {
|
|
162
|
+
const last = merged.at(-1);
|
|
163
|
+
// Overlapping, or only a space apart: one mark ("trình duyệt", not two).
|
|
164
|
+
if (last && (r[0] <= last[1] || !text.slice(last[1], r[0]).trim()))
|
|
165
|
+
last[1] = Math.max(last[1], r[1]);
|
|
166
|
+
else
|
|
167
|
+
merged.push([...r]);
|
|
168
|
+
}
|
|
169
|
+
return merged;
|
|
170
|
+
}
|
|
171
|
+
/** Cuts a long snippet to about `max` characters on word boundaries, starting a little before its first mark, with "…" where cut. */
|
|
172
|
+
function cutAround({ text, marks }, max) {
|
|
173
|
+
if (text.length <= max || !marks.length)
|
|
174
|
+
return { text, marks };
|
|
175
|
+
const first = marks[0][0];
|
|
176
|
+
let start = Math.max(0, Math.min(first - Math.floor(max * 0.3), text.length - max));
|
|
177
|
+
if (start > 0) {
|
|
178
|
+
const space = text.indexOf(" ", start);
|
|
179
|
+
start = space !== -1 && space < first ? space + 1 : start;
|
|
180
|
+
}
|
|
181
|
+
let end = Math.min(text.length, start + max);
|
|
182
|
+
if (end < text.length) {
|
|
183
|
+
const space = text.lastIndexOf(" ", end);
|
|
184
|
+
if (space > Math.max(start, marks[0][1]))
|
|
185
|
+
end = space;
|
|
186
|
+
}
|
|
187
|
+
const lead = start > 0 ? "…" : "";
|
|
188
|
+
const shift = lead.length - start;
|
|
189
|
+
return {
|
|
190
|
+
text: lead + text.slice(start, end).trimEnd() + (end < text.length ? "…" : ""),
|
|
191
|
+
marks: marks
|
|
192
|
+
.filter(([a, b]) => a >= start && b <= end)
|
|
193
|
+
.map(([a, b]) => [a + shift, b + shift]),
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
/** Results in display order: grouped by kind, groups in `kindOrder`, best first inside a group. */
|
|
197
|
+
export function groupByKind(results, kindOrder = []) {
|
|
198
|
+
const groups = new Map();
|
|
199
|
+
for (const r of results)
|
|
200
|
+
groups.set(r.doc.kind, [...(groups.get(r.doc.kind) ?? []), r]);
|
|
201
|
+
const rank = kindRank(results.map((r) => r.doc), kindOrder);
|
|
202
|
+
return [...groups].sort(([a], [b]) => rank(a) - rank(b)).map(([kind, results]) => ({ kind, results }));
|
|
203
|
+
}
|
|
204
|
+
function kindRank(docs, kindOrder) {
|
|
205
|
+
const order = [...kindOrder];
|
|
206
|
+
for (const d of docs)
|
|
207
|
+
if (!order.includes(d.kind))
|
|
208
|
+
order.push(d.kind);
|
|
209
|
+
return (kind) => order.indexOf(kind);
|
|
210
|
+
}
|