starlight-onetag 0.0.0-stage → 0.2.0
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 +104 -2
- package/components/Search.astro +433 -0
- package/index.js +62 -0
- package/package.json +58 -4
- package/plugins/explain/client.js +96 -0
- package/plugins/explain/index.js +26 -0
- package/plugins/plugin.js +47 -0
- package/plugins/search/client.js +149 -0
- package/plugins/search/index.js +28 -0
- package/plugins/selection/client.js +137 -0
- package/plugins/selection/index.js +27 -0
- package/plugins/shared.js +175 -0
- package/plugins/summary/client.js +66 -0
- package/plugins/summary/index.js +28 -0
- package/plugins/translate/client.js +176 -0
- package/plugins/translate/index.js +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 OdysseyX LLC
|
|
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,105 @@
|
|
|
1
|
-
#
|
|
1
|
+
# starlight-onetag
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Ask AI for [Starlight](https://starlight.astro.build) docs. Adds an **Ask AI** button next to the search box; readers ask a question and get an answer written from your own pages, with links to them and follow-ups.
|
|
4
|
+
|
|
5
|
+
- Retrieval uses the Pagefind index Starlight already builds. No extra service, no indexing step.
|
|
6
|
+
- Answers come from [OneTag AI](https://onetagai.com/?ref=starlight-onetag) with a public key, so your site stays static: no backend, no API key in the code.
|
|
7
|
+
- Works right after install: on localhost a shared demo key answers, so you can try it before signing up for anything.
|
|
8
|
+
- The AI code is a separate chunk fetched only when the dialog opens. Lighthouse stays where it was.
|
|
9
|
+
- Answers in the language of the question.
|
|
10
|
+
|
|
11
|
+
Live demo: the [Astro AI Docs docs template](https://fengyiqicoder.github.io/astro-ai-docs/) runs the same component.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install starlight-onetag
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
```js
|
|
20
|
+
// astro.config.mjs
|
|
21
|
+
import { defineConfig } from "astro/config";
|
|
22
|
+
import starlight from "@astrojs/starlight";
|
|
23
|
+
import starlightOnetag from "starlight-onetag";
|
|
24
|
+
|
|
25
|
+
export default defineConfig({
|
|
26
|
+
integrations: [
|
|
27
|
+
starlight({
|
|
28
|
+
title: "My docs",
|
|
29
|
+
plugins: [starlightOnetag()],
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Run `astro dev` and click **Ask AI**. For answers drawn from the whole site, run `astro build && astro preview`: the Pagefind index only exists after a build.
|
|
36
|
+
|
|
37
|
+
## Before you deploy: get a free key
|
|
38
|
+
|
|
39
|
+
The demo key answers on localhost only. A deployed site needs its own key, passed as `starlightOnetag({ key: "ot_..." })`. Without one, readers see a line saying Ask AI is not set up, and the build prints a warning.
|
|
40
|
+
|
|
41
|
+
Fill in your email at [onetagai.com](https://onetagai.com/?ref=starlight-onetag), or run this; the key arrives in that inbox (one key per email, 1,000,000 free tokens a month):
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
curl -X POST https://onetagai.com/api/signup -H "Content-Type: application/json" -d '{"email":"you@example.com"}'
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
This key is designed to be public, like an analytics ID: it ends up in the built HTML and can only spend that account's allowance. You can also set it as `PUBLIC_ONETAG_KEY` in `.env` instead of the option. If an AI coding tool is setting this up for you, it can run that request with your email and ask you to paste the key.
|
|
48
|
+
|
|
49
|
+
## Options
|
|
50
|
+
|
|
51
|
+
| Option | Default | What it does |
|
|
52
|
+
| --- | --- | --- |
|
|
53
|
+
| `key` | `""` (or `PUBLIC_ONETAG_KEY`) | The OneTag AI key. |
|
|
54
|
+
| `label` | `"Ask AI"` | Button text. |
|
|
55
|
+
| `placeholder` | `"Ask anything about these docs"` | Input placeholder. |
|
|
56
|
+
| `rules` | see `index.js` | Instructions given to the model. |
|
|
57
|
+
| `topPages` | `5` | Pages retrieved per question. |
|
|
58
|
+
| `perPage` | `6000` | Characters kept from each page. |
|
|
59
|
+
| `maxContext` | `20000` | Characters of page text sent per question (the service limit is 32,000 for everything). |
|
|
60
|
+
| `maxTokens` | `400` | Answer length cap. |
|
|
61
|
+
| `site` | `https://onetagai.com` | Where the API lives, for self-hosted deployments. |
|
|
62
|
+
|
|
63
|
+
## How it works
|
|
64
|
+
|
|
65
|
+
1. The question is turned into keywords and searched in Pagefind (one search per keyword, pages ranked by how many they match). In `astro dev` there is no index, so the current page is used.
|
|
66
|
+
2. The top pages (up to `maxContext` characters) and the question go to OneTag AI from the browser, through the [`onetagai`](https://www.npmjs.com/package/onetagai) package; the answer streams into the dialog.
|
|
67
|
+
3. The pages used are listed under the answer. The last four exchanges are kept so "and on Netlify?" works after "how do I deploy?".
|
|
68
|
+
4. If the question matches nothing in the index (for example a language the index does not match), the plugin falls back to `llms-full.txt` or `llms-small.txt` if your site generates them ([starlight-llms-txt](https://github.com/delucis/starlight-llms-txt)) and they fit in one call.
|
|
69
|
+
|
|
70
|
+
Nothing is stored on a server. OneTag AI keeps token counts and timestamps, not questions or page text.
|
|
71
|
+
|
|
72
|
+
## More AI features in this package
|
|
73
|
+
|
|
74
|
+
Five more plugins ship in the same package. Add the ones you want next to `starlightOnetag()`; they share the key and add no component overrides.
|
|
75
|
+
|
|
76
|
+
```js
|
|
77
|
+
import starlightOnetag from "starlight-onetag";
|
|
78
|
+
import starlightOnetagSearch from "starlight-onetag/search";
|
|
79
|
+
import starlightOnetagTranslate from "starlight-onetag/translate";
|
|
80
|
+
import starlightOnetagExplain from "starlight-onetag/explain";
|
|
81
|
+
import starlightOnetagSummary from "starlight-onetag/summary";
|
|
82
|
+
import starlightOnetagSelection from "starlight-onetag/selection";
|
|
83
|
+
|
|
84
|
+
starlight({
|
|
85
|
+
plugins: [
|
|
86
|
+
starlightOnetag(),
|
|
87
|
+
starlightOnetagSearch(), // AI answer inside the search dialog, above the Pagefind results
|
|
88
|
+
starlightOnetagTranslate(), // "Read in <your language>": AI translation of the page for the reader
|
|
89
|
+
starlightOnetagExplain(), // "Explain this code" under every code block
|
|
90
|
+
starlightOnetagSummary(), // "Summarize this page" on long pages
|
|
91
|
+
starlightOnetagSelection(), // select text, click Ask AI, get it explained in context
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Each one has its own page with screenshots and options: [search](https://github.com/fengyiqicoder/starlight-onetag-search), [translate](https://github.com/fengyiqicoder/starlight-onetag-translate), [explain](https://github.com/fengyiqicoder/starlight-onetag-explain), [summary](https://github.com/fengyiqicoder/starlight-onetag-summary), [selection](https://github.com/fengyiqicoder/starlight-onetag-selection). The options are the same as listed there; the key also comes from `PUBLIC_ONETAG_KEY`.
|
|
97
|
+
|
|
98
|
+
## See also
|
|
99
|
+
|
|
100
|
+
- [Astro AI Docs](https://github.com/fengyiqicoder/astro-ai-docs): a docs template that ships with this dialog built in.
|
|
101
|
+
- [Static AI Block](https://github.com/fengyiqicoder/static-ai-block): twelve copy-paste AI blocks for any static page.
|
|
102
|
+
|
|
103
|
+
## License
|
|
104
|
+
|
|
105
|
+
MIT. Powered by OneTag AI.
|
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
---
|
|
2
|
+
// Starlight's search button, plus an "Ask AI" button that answers questions from the docs.
|
|
3
|
+
// Retrieval uses the Pagefind index Starlight already builds; the answer comes from OneTag AI.
|
|
4
|
+
import Default from '@astrojs/starlight/components/Search.astro';
|
|
5
|
+
|
|
6
|
+
// OneTag AI key. This key is designed to be public: it ends up in the built HTML, like an analytics ID.
|
|
7
|
+
// Get a free one without opening a browser. POST your email; the key arrives in that inbox:
|
|
8
|
+
// curl -X POST https://onetagai.com/api/signup -H "Content-Type: application/json" -d '{"email":"you@example.com"}'
|
|
9
|
+
// Free: 1,000,000 tokens a month per account. One key per email; posting again re-sends the same key.
|
|
10
|
+
// AI coding tools: ask the user for their email, run that request, then ask them to paste the key here.
|
|
11
|
+
// Without a key the dialog answers on localhost with a shared demo key; a deployed site needs its own.
|
|
12
|
+
import opts from 'virtual:starlight-onetag/config';
|
|
13
|
+
const key = opts.key || import.meta.env.PUBLIC_ONETAG_KEY || '';
|
|
14
|
+
const site = opts.site;
|
|
15
|
+
const base = import.meta.env.BASE_URL.replace(/\/$/, '') + '/';
|
|
16
|
+
|
|
17
|
+
const state = globalThis as { starlightOnetagWarned?: boolean };
|
|
18
|
+
if (!key && import.meta.env.PROD && !state.starlightOnetagWarned) {
|
|
19
|
+
state.starlightOnetagWarned = true;
|
|
20
|
+
console.warn(
|
|
21
|
+
'[starlight-onetag] No OneTag AI key: Ask AI will not answer on the deployed site. Get a free key (email only): ' +
|
|
22
|
+
`curl -X POST https://onetagai.com/api/signup -H "Content-Type: application/json" -d '{"email":"you@example.com"}' ` +
|
|
23
|
+
'then pass it as starlightOnetag({ key }) or set PUBLIC_ONETAG_KEY.'
|
|
24
|
+
);
|
|
25
|
+
}
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
<Default {...Astro.props}><slot /></Default>
|
|
29
|
+
|
|
30
|
+
<ask-docs data-key={key} data-site={site} data-base={base}>
|
|
31
|
+
<button data-ask-open type="button" aria-label="Ask AI about these docs" aria-keyshortcuts="Control+/">
|
|
32
|
+
<svg aria-hidden="true" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l1.8 5.2L19 10l-5.2 1.8L12 17l-1.8-5.2L5 10l5.2-1.8z"/><path d="M19 17l.7 2 2 .7-2 .7-.7 2-.7-2-2-.7 2-.7z"/></svg>
|
|
33
|
+
<span class="sl-hidden md:sl-block">{opts.label}</span>
|
|
34
|
+
</button>
|
|
35
|
+
|
|
36
|
+
<dialog aria-label="Ask AI about these docs">
|
|
37
|
+
<div class="ask-frame">
|
|
38
|
+
<header class="ask-head">
|
|
39
|
+
<strong>Ask the docs</strong>
|
|
40
|
+
<button data-ask-close type="button" aria-label="Close">Close</button>
|
|
41
|
+
</header>
|
|
42
|
+
<form class="ask-form" data-ask-form>
|
|
43
|
+
<input type="text" name="q" autocomplete="off" maxlength="400" placeholder={opts.placeholder} required />
|
|
44
|
+
<button type="submit" data-ask-submit>Ask</button>
|
|
45
|
+
</form>
|
|
46
|
+
<p class="ask-note" data-ask-note hidden>No key set: on localhost the answers come from a shared demo key. Add your own free OneTag AI key before you deploy.</p>
|
|
47
|
+
<p class="ask-status" data-ask-status aria-live="polite"></p>
|
|
48
|
+
<div class="ask-thread" data-ask-thread hidden>
|
|
49
|
+
<p class="ask-answer" data-ask-answer></p>
|
|
50
|
+
<p class="ask-sources" data-ask-sources></p>
|
|
51
|
+
<p class="ask-foot">Answers come only from pages on this site. <button type="button" class="ask-link" data-ask-reset>Start over</button></p>
|
|
52
|
+
</div>
|
|
53
|
+
</div>
|
|
54
|
+
</dialog>
|
|
55
|
+
</ask-docs>
|
|
56
|
+
|
|
57
|
+
<script>
|
|
58
|
+
// Characters of page text sent per question. The OneTag limit is 32,000 for everything,
|
|
59
|
+
// so retrieval keeps a margin for the question, the rules and the chat history.
|
|
60
|
+
import cfg from 'virtual:starlight-onetag/config';
|
|
61
|
+
const MAX_CONTEXT = cfg.maxContext;
|
|
62
|
+
const PER_PAGE = cfg.perPage;
|
|
63
|
+
const TOP_PAGES = cfg.topPages;
|
|
64
|
+
|
|
65
|
+
const RULES = cfg.rules;
|
|
66
|
+
|
|
67
|
+
const MESSAGES: Record<string, string> = {
|
|
68
|
+
no_key: 'Ask AI is not set up on this site yet: the site owner needs to add a OneTag AI key to the starlight-onetag plugin.',
|
|
69
|
+
bad_key: 'Ask AI is not set up correctly on this site: its OneTag AI key is not valid.',
|
|
70
|
+
quota_exceeded: 'This site has used up its AI allowance for now.',
|
|
71
|
+
paused: 'The AI service is paused for today. Try again after 00:00 UTC.',
|
|
72
|
+
rate_limited: 'Too many questions in one minute. Give it a moment.',
|
|
73
|
+
too_large: 'That question is too long.',
|
|
74
|
+
network: 'Could not reach the AI service. Check your connection and try again.',
|
|
75
|
+
upstream: 'The AI provider had a problem. Try again in a few seconds.',
|
|
76
|
+
aborted: 'Stopped.',
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
type Page = { title: string; url: string; text: string };
|
|
80
|
+
|
|
81
|
+
const STOP = new Set(('a an and are as at be by can could do does for from how i if in into is it its me my of on or ' +
|
|
82
|
+
'our should site that the their there these this to use using we what when where which who why will with you your').split(' '));
|
|
83
|
+
// Words worth searching: letters and digits, not stop words, longest first, at most six.
|
|
84
|
+
function keywords(q: string): string[] {
|
|
85
|
+
const words = (q.toLowerCase().match(/[\p{L}\p{N}][\p{L}\p{N}'-]*/gu) || []).filter((w) => w.length > 1 && !STOP.has(w));
|
|
86
|
+
const unique = [...new Set(words)].sort((a, b) => b.length - a.length).slice(0, 6);
|
|
87
|
+
return unique.length ? unique : [q];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class AskDocs extends HTMLElement {
|
|
91
|
+
history: { role: 'user' | 'assistant'; content: string }[] = [];
|
|
92
|
+
controller: AbortController | null = null;
|
|
93
|
+
sdk: Promise<typeof import('onetagai')> | null = null;
|
|
94
|
+
|
|
95
|
+
constructor() {
|
|
96
|
+
super();
|
|
97
|
+
const open = this.querySelector<HTMLButtonElement>('[data-ask-open]')!;
|
|
98
|
+
const close = this.querySelector<HTMLButtonElement>('[data-ask-close]')!;
|
|
99
|
+
const dialog = this.querySelector('dialog')!;
|
|
100
|
+
const form = this.querySelector<HTMLFormElement>('[data-ask-form]')!;
|
|
101
|
+
const input = form.querySelector('input')!;
|
|
102
|
+
|
|
103
|
+
open.addEventListener('click', () => {
|
|
104
|
+
dialog.showModal();
|
|
105
|
+
input.focus();
|
|
106
|
+
this.loadSdk();
|
|
107
|
+
});
|
|
108
|
+
close.addEventListener('click', () => dialog.close());
|
|
109
|
+
dialog.addEventListener('click', (e) => {
|
|
110
|
+
if (e.target === dialog) dialog.close();
|
|
111
|
+
});
|
|
112
|
+
window.addEventListener('keydown', (e) => {
|
|
113
|
+
if ((e.metaKey || e.ctrlKey) && e.key === '/') {
|
|
114
|
+
dialog.open ? dialog.close() : (dialog.showModal(), input.focus(), this.loadSdk());
|
|
115
|
+
e.preventDefault();
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
form.addEventListener('submit', (e) => {
|
|
119
|
+
e.preventDefault();
|
|
120
|
+
const q = input.value.trim();
|
|
121
|
+
if (!q) return;
|
|
122
|
+
input.value = '';
|
|
123
|
+
this.ask(q);
|
|
124
|
+
});
|
|
125
|
+
this.querySelector('[data-ask-reset]')!.addEventListener('click', () => this.reset());
|
|
126
|
+
// The onetag package answers without a key on localhost only: say so to the site owner.
|
|
127
|
+
const local = /^(|localhost|127\.0\.0\.1|\[::1\]|.+\.localhost)$/.test(location.hostname);
|
|
128
|
+
if (!this.dataset.key && local) this.querySelector<HTMLElement>('[data-ask-note]')!.hidden = false;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// The onetag package is its own chunk, fetched only when someone opens the dialog,
|
|
132
|
+
// so it never affects page speed.
|
|
133
|
+
loadSdk(): Promise<typeof import('onetagai')> {
|
|
134
|
+
if (!this.sdk) {
|
|
135
|
+
this.sdk = import('onetagai').then((sdk) => {
|
|
136
|
+
if (this.dataset.site !== 'https://onetagai.com') sdk.configure({ endpoint: this.dataset.site + '/v1/ask' });
|
|
137
|
+
return sdk;
|
|
138
|
+
});
|
|
139
|
+
// A failed download is tried again on the next question.
|
|
140
|
+
this.sdk.catch(() => (this.sdk = null));
|
|
141
|
+
}
|
|
142
|
+
return this.sdk;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Top pages for the question from the Pagefind index Starlight builds on `astro build`.
|
|
146
|
+
// Pagefind matches every word of a query, so a question like "how do I deploy to Netlify"
|
|
147
|
+
// finds nothing: the keywords are searched one by one and pages are ranked by how many
|
|
148
|
+
// of them they match. In `astro dev` there is no index, so the current page is used.
|
|
149
|
+
async retrieve(question: string): Promise<Page[]> {
|
|
150
|
+
try {
|
|
151
|
+
const pf = await import(/* @vite-ignore */ this.dataset.base + 'pagefind/pagefind.js');
|
|
152
|
+
await pf.init();
|
|
153
|
+
const words = keywords(question);
|
|
154
|
+
if (!/[a-z0-9]{2,}/i.test(words.join(' '))) throw new Error('no latin keywords');
|
|
155
|
+
const searches = await Promise.all(words.map((w) => pf.search(w)));
|
|
156
|
+
const scores = new Map<string, { score: number; result: any }>();
|
|
157
|
+
searches.forEach((s) => {
|
|
158
|
+
s.results.slice(0, 10).forEach((r: any, rank: number) => {
|
|
159
|
+
const cur = scores.get(r.id) || { score: 0, result: r };
|
|
160
|
+
cur.score += 1 + Math.max(0, 10 - rank) / 20 + (r.score || 0) / 100;
|
|
161
|
+
scores.set(r.id, cur);
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
const top = [...scores.values()].sort((a, b) => b.score - a.score).slice(0, TOP_PAGES);
|
|
165
|
+
const hits = await Promise.all(top.map((t) => t.result.data()));
|
|
166
|
+
const pages: Page[] = hits.map((h: any) => ({
|
|
167
|
+
title: h.meta?.title || h.url,
|
|
168
|
+
url: h.url,
|
|
169
|
+
text: (h.content || h.excerpt || '').slice(0, PER_PAGE),
|
|
170
|
+
}));
|
|
171
|
+
if (pages.length) return pages;
|
|
172
|
+
} catch {}
|
|
173
|
+
// No index, or a question in a language the index does not match: hand over the whole
|
|
174
|
+
// site from the llms.txt files the build writes, when that fits in one call.
|
|
175
|
+
for (const name of ['llms-full.txt', 'llms-small.txt']) {
|
|
176
|
+
try {
|
|
177
|
+
const r = await fetch(this.dataset.base + name);
|
|
178
|
+
if (!r.ok) continue;
|
|
179
|
+
const text = await r.text();
|
|
180
|
+
if (text.length <= MAX_CONTEXT) return [{ title: 'All pages', url: this.dataset.base + name, text }];
|
|
181
|
+
} catch {}
|
|
182
|
+
}
|
|
183
|
+
const main = document.querySelector('main');
|
|
184
|
+
return [{ title: document.title, url: location.pathname, text: (main?.innerText || '').slice(0, PER_PAGE) }];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async ask(question: string) {
|
|
188
|
+
const status = this.querySelector<HTMLElement>('[data-ask-status]')!;
|
|
189
|
+
const thread = this.querySelector<HTMLElement>('[data-ask-thread]')!;
|
|
190
|
+
const answer = this.querySelector<HTMLElement>('[data-ask-answer]')!;
|
|
191
|
+
const sources = this.querySelector<HTMLElement>('[data-ask-sources]')!;
|
|
192
|
+
const submit = this.querySelector<HTMLButtonElement>('[data-ask-submit]')!;
|
|
193
|
+
const input = this.querySelector<HTMLInputElement>('[data-ask-form] input')!;
|
|
194
|
+
|
|
195
|
+
if (this.controller) this.controller.abort();
|
|
196
|
+
this.controller = new AbortController();
|
|
197
|
+
const { signal } = this.controller;
|
|
198
|
+
submit.disabled = true;
|
|
199
|
+
status.textContent = 'Reading the docs…';
|
|
200
|
+
status.classList.remove('error');
|
|
201
|
+
thread.hidden = false;
|
|
202
|
+
answer.textContent = '';
|
|
203
|
+
sources.textContent = '';
|
|
204
|
+
|
|
205
|
+
try {
|
|
206
|
+
const sdk = await this.loadSdk();
|
|
207
|
+
const pages = await this.retrieve(question);
|
|
208
|
+
let budget = MAX_CONTEXT;
|
|
209
|
+
const parts: string[] = [];
|
|
210
|
+
for (const p of pages) {
|
|
211
|
+
const chunk = `Page: ${p.title} (${p.url})\n${p.text}`.slice(0, budget);
|
|
212
|
+
if (!chunk) break;
|
|
213
|
+
parts.push(chunk);
|
|
214
|
+
budget -= chunk.length;
|
|
215
|
+
}
|
|
216
|
+
let started = false;
|
|
217
|
+
let shown = '';
|
|
218
|
+
// The model sometimes wraps file names and commands in backticks: show those as inline code.
|
|
219
|
+
const paint = () =>
|
|
220
|
+
answer.replaceChildren(
|
|
221
|
+
...shown.split('`').map((part, i) => {
|
|
222
|
+
if (i % 2 === 0) return document.createTextNode(part);
|
|
223
|
+
const code = document.createElement('code');
|
|
224
|
+
code.textContent = part;
|
|
225
|
+
return code;
|
|
226
|
+
})
|
|
227
|
+
);
|
|
228
|
+
const text = await sdk.ask(
|
|
229
|
+
question + '\n\n(Reply in the same language as the question above.)',
|
|
230
|
+
parts,
|
|
231
|
+
{
|
|
232
|
+
key: this.dataset.key || undefined,
|
|
233
|
+
via: 'starlight-onetag',
|
|
234
|
+
system: RULES,
|
|
235
|
+
history: this.history,
|
|
236
|
+
maxTokens: cfg.maxTokens,
|
|
237
|
+
signal,
|
|
238
|
+
stream: (delta: string) => {
|
|
239
|
+
if (!started) {
|
|
240
|
+
started = true;
|
|
241
|
+
status.textContent = '';
|
|
242
|
+
}
|
|
243
|
+
shown += delta;
|
|
244
|
+
paint();
|
|
245
|
+
},
|
|
246
|
+
}
|
|
247
|
+
);
|
|
248
|
+
if (signal.aborted) return;
|
|
249
|
+
status.textContent = '';
|
|
250
|
+
this.history.push({ role: 'user', content: question }, { role: 'assistant', content: text });
|
|
251
|
+
if (this.history.length > 8) this.history = this.history.slice(-8);
|
|
252
|
+
input.placeholder = 'Ask a follow-up';
|
|
253
|
+
if (pages.length && pages[0].url !== location.pathname && pages[0].title !== 'All pages') {
|
|
254
|
+
sources.append('From: ');
|
|
255
|
+
pages.slice(0, 3).forEach((p, i) => {
|
|
256
|
+
if (i) sources.append(' · ');
|
|
257
|
+
const a = document.createElement('a');
|
|
258
|
+
a.href = p.url;
|
|
259
|
+
a.textContent = p.title;
|
|
260
|
+
sources.append(a);
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
} catch (e: any) {
|
|
264
|
+
if (signal.aborted) return;
|
|
265
|
+
status.textContent = MESSAGES[e?.code] || (e?.code && e.message) || 'Could not load the AI assistant. Check your connection and try again.';
|
|
266
|
+
status.classList.add('error');
|
|
267
|
+
} finally {
|
|
268
|
+
if (!signal.aborted) submit.disabled = false;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
reset() {
|
|
273
|
+
if (this.controller) this.controller.abort();
|
|
274
|
+
this.history = [];
|
|
275
|
+
const thread = this.querySelector<HTMLElement>('[data-ask-thread]')!;
|
|
276
|
+
thread.hidden = true;
|
|
277
|
+
this.querySelector<HTMLElement>('[data-ask-answer]')!.textContent = '';
|
|
278
|
+
this.querySelector<HTMLElement>('[data-ask-sources]')!.textContent = '';
|
|
279
|
+
this.querySelector<HTMLElement>('[data-ask-status]')!.textContent = '';
|
|
280
|
+
const input = this.querySelector<HTMLInputElement>('[data-ask-form] input')!;
|
|
281
|
+
input.placeholder = 'How do I deploy this to GitHub Pages?';
|
|
282
|
+
this.querySelector<HTMLButtonElement>('[data-ask-submit]')!.disabled = false;
|
|
283
|
+
input.focus();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
customElements.define('ask-docs', AskDocs);
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<style>
|
|
290
|
+
ask-docs {
|
|
291
|
+
display: contents;
|
|
292
|
+
}
|
|
293
|
+
[data-ask-open] {
|
|
294
|
+
display: flex;
|
|
295
|
+
align-items: center;
|
|
296
|
+
gap: 0.4rem;
|
|
297
|
+
margin-inline-start: 0.5rem;
|
|
298
|
+
height: 2.5rem;
|
|
299
|
+
padding-inline: 0.6rem;
|
|
300
|
+
border: 1px solid var(--sl-color-accent);
|
|
301
|
+
border-radius: 0.5rem;
|
|
302
|
+
background: var(--sl-color-accent-low);
|
|
303
|
+
color: var(--sl-color-white);
|
|
304
|
+
font-size: var(--sl-text-sm);
|
|
305
|
+
cursor: pointer;
|
|
306
|
+
white-space: nowrap;
|
|
307
|
+
}
|
|
308
|
+
[data-ask-open]:hover {
|
|
309
|
+
background: var(--sl-color-accent);
|
|
310
|
+
color: var(--sl-color-black);
|
|
311
|
+
}
|
|
312
|
+
@media (max-width: 50rem) {
|
|
313
|
+
[data-ask-open] {
|
|
314
|
+
border: 0;
|
|
315
|
+
background: transparent;
|
|
316
|
+
color: var(--sl-color-gray-1);
|
|
317
|
+
padding-inline: 0.25rem;
|
|
318
|
+
margin-inline-start: 0;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
dialog {
|
|
322
|
+
margin: 4rem auto auto;
|
|
323
|
+
width: 90%;
|
|
324
|
+
max-width: 40rem;
|
|
325
|
+
padding: 0;
|
|
326
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
327
|
+
border-radius: 0.5rem;
|
|
328
|
+
background: var(--sl-color-gray-6);
|
|
329
|
+
color: var(--sl-color-text);
|
|
330
|
+
box-shadow: var(--sl-shadow-lg);
|
|
331
|
+
}
|
|
332
|
+
dialog::backdrop {
|
|
333
|
+
background: var(--sl-color-backdrop-overlay);
|
|
334
|
+
backdrop-filter: blur(0.25rem);
|
|
335
|
+
}
|
|
336
|
+
.ask-frame {
|
|
337
|
+
padding: 1.25rem 1.5rem 1.5rem;
|
|
338
|
+
display: flex;
|
|
339
|
+
flex-direction: column;
|
|
340
|
+
gap: 0.9rem;
|
|
341
|
+
}
|
|
342
|
+
.ask-head {
|
|
343
|
+
display: flex;
|
|
344
|
+
justify-content: space-between;
|
|
345
|
+
align-items: baseline;
|
|
346
|
+
font-size: var(--sl-text-lg);
|
|
347
|
+
}
|
|
348
|
+
[data-ask-close],
|
|
349
|
+
.ask-link {
|
|
350
|
+
border: 0;
|
|
351
|
+
background: none;
|
|
352
|
+
color: var(--sl-color-text-accent);
|
|
353
|
+
font: inherit;
|
|
354
|
+
font-size: var(--sl-text-sm);
|
|
355
|
+
cursor: pointer;
|
|
356
|
+
padding: 0;
|
|
357
|
+
text-decoration: underline;
|
|
358
|
+
text-underline-offset: 3px;
|
|
359
|
+
}
|
|
360
|
+
.ask-form {
|
|
361
|
+
display: flex;
|
|
362
|
+
gap: 0.5rem;
|
|
363
|
+
}
|
|
364
|
+
.ask-form input {
|
|
365
|
+
flex: 1;
|
|
366
|
+
min-width: 0;
|
|
367
|
+
font: inherit;
|
|
368
|
+
font-size: var(--sl-text-base);
|
|
369
|
+
padding: 0.6rem 0.8rem;
|
|
370
|
+
border: 1px solid var(--sl-color-gray-5);
|
|
371
|
+
border-radius: 0.4rem;
|
|
372
|
+
background: var(--sl-color-black);
|
|
373
|
+
color: var(--sl-color-white);
|
|
374
|
+
}
|
|
375
|
+
.ask-form input:focus {
|
|
376
|
+
outline: 2px solid var(--sl-color-accent);
|
|
377
|
+
outline-offset: 0;
|
|
378
|
+
}
|
|
379
|
+
[data-ask-submit] {
|
|
380
|
+
font: inherit;
|
|
381
|
+
font-weight: 600;
|
|
382
|
+
padding: 0 1rem;
|
|
383
|
+
border: 0;
|
|
384
|
+
border-radius: 0.4rem;
|
|
385
|
+
background: var(--sl-color-accent);
|
|
386
|
+
color: var(--sl-color-black);
|
|
387
|
+
cursor: pointer;
|
|
388
|
+
}
|
|
389
|
+
[data-ask-submit]:disabled {
|
|
390
|
+
opacity: 0.6;
|
|
391
|
+
cursor: wait;
|
|
392
|
+
}
|
|
393
|
+
.ask-status {
|
|
394
|
+
margin: 0;
|
|
395
|
+
min-height: 1.4em;
|
|
396
|
+
font-size: var(--sl-text-sm);
|
|
397
|
+
color: var(--sl-color-gray-2);
|
|
398
|
+
}
|
|
399
|
+
.ask-status.error {
|
|
400
|
+
color: var(--sl-color-red-high, #f87171);
|
|
401
|
+
}
|
|
402
|
+
.ask-thread {
|
|
403
|
+
display: flex;
|
|
404
|
+
flex-direction: column;
|
|
405
|
+
gap: 0.6rem;
|
|
406
|
+
}
|
|
407
|
+
/* Nothing to show before the first question. */
|
|
408
|
+
.ask-thread[hidden] {
|
|
409
|
+
display: none;
|
|
410
|
+
}
|
|
411
|
+
.ask-note {
|
|
412
|
+
margin: 0;
|
|
413
|
+
padding: 0.5rem 0.75rem;
|
|
414
|
+
border: 1px dashed var(--sl-color-gray-4);
|
|
415
|
+
border-radius: 0.4rem;
|
|
416
|
+
font-size: var(--sl-text-sm);
|
|
417
|
+
color: var(--sl-color-gray-2);
|
|
418
|
+
}
|
|
419
|
+
.ask-answer {
|
|
420
|
+
margin: 0;
|
|
421
|
+
padding-inline-start: 0.9rem;
|
|
422
|
+
border-inline-start: 3px solid var(--sl-color-accent);
|
|
423
|
+
white-space: pre-wrap;
|
|
424
|
+
line-height: 1.6;
|
|
425
|
+
min-height: 1.6em;
|
|
426
|
+
}
|
|
427
|
+
.ask-sources,
|
|
428
|
+
.ask-foot {
|
|
429
|
+
margin: 0;
|
|
430
|
+
font-size: var(--sl-text-sm);
|
|
431
|
+
color: var(--sl-color-gray-2);
|
|
432
|
+
}
|
|
433
|
+
</style>
|
package/index.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* starlight-onetag: adds an "Ask AI" dialog next to Starlight's search.
|
|
3
|
+
*
|
|
4
|
+
* Usage in astro.config.mjs:
|
|
5
|
+
* import starlightOnetag from "starlight-onetag";
|
|
6
|
+
* starlight({ plugins: [starlightOnetag({ key: "YOUR_ONETAG_KEY" })] })
|
|
7
|
+
*
|
|
8
|
+
* Options:
|
|
9
|
+
* key OneTag AI key (designed to be public). Also read from PUBLIC_ONETAG_KEY. Without a key the
|
|
10
|
+
* dialog answers on localhost with a shared demo key; on a deployed site it tells readers
|
|
11
|
+
* it is not set up, and everything else works.
|
|
12
|
+
* site Where the API lives. Default https://onetagai.com
|
|
13
|
+
* label Button text. Default "Ask AI"
|
|
14
|
+
* placeholder Input placeholder.
|
|
15
|
+
* rules Instructions given to the model (persona, limits).
|
|
16
|
+
* topPages Pages retrieved per question (default 5); perPage characters kept per page (default 6000).
|
|
17
|
+
*/
|
|
18
|
+
export default function starlightOnetag(options = {}) {
|
|
19
|
+
const opts = {
|
|
20
|
+
key: options.key ?? "",
|
|
21
|
+
site: (options.site ?? "https://onetagai.com").replace(/\/$/, ""),
|
|
22
|
+
label: options.label ?? "Ask AI",
|
|
23
|
+
placeholder: options.placeholder ?? "Ask anything about these docs",
|
|
24
|
+
rules:
|
|
25
|
+
options.rules ??
|
|
26
|
+
"You are the help desk for this documentation site. Answer only from the pages provided. When the pages cover the question, answer it directly and stop. Only when they do not, say so in one sentence and name the closest page. Plain text, under 150 words, no headings. Answer in the language of the question.",
|
|
27
|
+
topPages: options.topPages ?? 5,
|
|
28
|
+
perPage: options.perPage ?? 6000,
|
|
29
|
+
maxContext: options.maxContext ?? 20000,
|
|
30
|
+
maxTokens: options.maxTokens ?? 400,
|
|
31
|
+
};
|
|
32
|
+
return {
|
|
33
|
+
name: "starlight-onetag",
|
|
34
|
+
hooks: {
|
|
35
|
+
"config:setup"({ config, updateConfig, addIntegration, logger }) {
|
|
36
|
+
if (config.components?.Search && config.components.Search !== "starlight-onetag/Search.astro") {
|
|
37
|
+
logger.warn("A custom Search component is already set; starlight-onetag replaces it.");
|
|
38
|
+
}
|
|
39
|
+
updateConfig({ components: { ...config.components, Search: "starlight-onetag/Search.astro" } });
|
|
40
|
+
addIntegration({
|
|
41
|
+
name: "starlight-onetag",
|
|
42
|
+
hooks: {
|
|
43
|
+
"astro:config:setup"({ updateConfig: update }) {
|
|
44
|
+
const id = "virtual:starlight-onetag/config";
|
|
45
|
+
update({
|
|
46
|
+
vite: {
|
|
47
|
+
plugins: [
|
|
48
|
+
{
|
|
49
|
+
name: "starlight-onetag-config",
|
|
50
|
+
resolveId: (source) => (source === id ? "\0" + id : undefined),
|
|
51
|
+
load: (source) => (source === "\0" + id ? `export default ${JSON.stringify(opts)};` : undefined),
|
|
52
|
+
},
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
});
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|