blume 1.4.2 → 1.4.3
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/CHANGELOG.md +54 -0
- package/dist/cli/index.js +694 -579
- package/dist/cli/index.js.map +56 -55
- package/dist/types/core/base-path.d.ts +8 -0
- package/dist/types/core/config-input.d.ts +8 -0
- package/dist/types/core/schema.d.ts +4 -0
- package/dist/types/core/sources/types.d.ts +9 -1
- package/dist/types/openapi/references.d.ts +8 -2
- package/docs/configuration/ai.mdx +26 -8
- package/docs/content/sources.mdx +1 -1
- package/package.json +11 -1
- package/src/ai/agent-readability.ts +3 -2
- package/src/ai/api-catalog.ts +2 -2
- package/src/ai/ask-context.ts +45 -12
- package/src/ai/llms.ts +2 -1
- package/src/ai/mcp/discovery.ts +25 -6
- package/src/ai/mcp/server.ts +108 -98
- package/src/ai/tar.ts +29 -70
- package/src/astro/examples.ts +7 -3
- package/src/astro/generate.ts +59 -34
- package/src/astro/islands.ts +7 -3
- package/src/astro/templates.ts +34 -9
- package/src/audit/agent.ts +14 -29
- package/src/audit/crawl.ts +41 -16
- package/src/audit/run.ts +10 -3
- package/src/audit/snapshot.ts +27 -2
- package/src/cli/commands/audit.ts +12 -17
- package/src/cli/commands/build.ts +15 -7
- package/src/cli/commands/dev.ts +13 -15
- package/src/cli/commands/eject.ts +4 -4
- package/src/cli/commands/eval.ts +17 -27
- package/src/cli/env.ts +13 -30
- package/src/cli/init/scaffold.ts +21 -0
- package/src/cli/report-format.ts +22 -0
- package/src/components/content/AccordionItem.astro +2 -9
- package/src/components/content/ColorItem.astro +5 -13
- package/src/components/content/Component.astro +12 -8
- package/src/components/content/Frame.astro +2 -12
- package/src/components/content/Prompt.astro +12 -31
- package/src/components/content/Tab.astro +2 -9
- package/src/components/content/Tooltip.astro +1 -9
- package/src/components/content/Update.astro +2 -9
- package/src/components/content/inline-markdown.ts +28 -0
- package/src/components/copy-feedback.ts +96 -0
- package/src/components/islands/ask-ai.tsx +78 -9
- package/src/components/layout/PageActions.astro +20 -32
- package/src/components/layout/PageLayout.astro +8 -28
- package/src/components/layout/RootLayout.astro +6 -48
- package/src/components/layout/Search.astro +56 -9
- package/src/components/layout/drawer-inert.ts +31 -0
- package/src/components/layout/search/pagefind.ts +6 -5
- package/src/components/layout/search/types.ts +32 -0
- package/src/components/openapi/panel.ts +11 -8
- package/src/components/raf-throttle.ts +21 -0
- package/src/components/slug.ts +14 -0
- package/src/core/base-path.ts +18 -1
- package/src/core/config-input.ts +8 -0
- package/src/core/frontmatter.ts +45 -1
- package/src/core/probe.ts +7 -19
- package/src/core/project-graph.ts +12 -1
- package/src/core/schema.ts +6 -0
- package/src/core/site-url.ts +27 -0
- package/src/core/sources/cache.ts +10 -8
- package/src/core/sources/github-releases.ts +21 -1
- package/src/core/sources/normalize.ts +26 -2
- package/src/core/sources/notion.ts +27 -5
- package/src/core/sources/portable-text.ts +16 -1
- package/src/core/sources/resolve.ts +1 -0
- package/src/core/sources/types.ts +13 -1
- package/src/deploy/cloudflare-negotiation.ts +15 -1
- package/src/deploy/robots.ts +2 -1
- package/src/deploy/rss.ts +2 -1
- package/src/deploy/sitemap.ts +56 -7
- package/src/eval/agents.ts +13 -10
- package/src/eval/report.ts +1 -14
- package/src/markdown/package-commands.ts +61 -54
- package/src/og/card.ts +24 -26
- package/src/openapi/model.ts +9 -9
- package/src/openapi/parse.ts +69 -28
- package/src/openapi/references.ts +35 -12
- package/src/openapi/render-mdx.ts +64 -25
- package/src/openapi/scalar.ts +2 -2
- package/src/openapi/source.ts +28 -1
- package/src/search/documents.ts +78 -34
- package/src/search/orama-index.ts +51 -12
- package/src/theme/palette.ts +6 -2
- package/src/translate/ledger.ts +4 -2
- package/src/translate/report.ts +1 -14
- package/src/translate/run.ts +20 -35
- package/src/cli/coalesce.ts +0 -43
|
@@ -22,6 +22,14 @@ export declare const normalizeBasePath: (input?: string) => string;
|
|
|
22
22
|
* `/docs` and `/docs/` as the same page) and collapse an empty path to `/`.
|
|
23
23
|
*/
|
|
24
24
|
export declare const normalizePath: (path: string) => string;
|
|
25
|
+
/**
|
|
26
|
+
* Canonicalize a route-ish string (a configured route, a page path, an agent-
|
|
27
|
+
* supplied route) to `/` or `/seg[/seg…]`: trimmed, exactly one leading slash,
|
|
28
|
+
* no trailing slash. The shared spelling of what openapi/references,
|
|
29
|
+
* ai/ask-context, and ai/mcp/server each hand-rolled with slightly different
|
|
30
|
+
* regexes.
|
|
31
|
+
*/
|
|
32
|
+
export declare const normalizeRoute: (input: string) => string;
|
|
25
33
|
/**
|
|
26
34
|
* Whether a link target is a root-relative internal path (`/x`) — the only
|
|
27
35
|
* shape a base path applies to. Protocol-relative (`//host`), absolute URLs,
|
|
@@ -171,6 +171,8 @@ export interface SanitySource {
|
|
|
171
171
|
/** A Notion database; pages become entries, blocks become MDX. */
|
|
172
172
|
export interface NotionSource {
|
|
173
173
|
type: "notion";
|
|
174
|
+
/** Max concurrent Notion API requests; default 3 (Notion's per-integration pace). */
|
|
175
|
+
concurrency?: number;
|
|
174
176
|
/** Notion database id. */
|
|
175
177
|
database: string;
|
|
176
178
|
/** Opt-in dev polling interval (seconds); omit to freeze for the session. */
|
|
@@ -565,6 +567,12 @@ export interface AskConfig {
|
|
|
565
567
|
* limiting, and streaming. Accepts an absolute URL or root-relative path.
|
|
566
568
|
*/
|
|
567
569
|
endpoint?: string;
|
|
570
|
+
/**
|
|
571
|
+
* Extra system-prompt text appended to the built-in instructions — use it
|
|
572
|
+
* for identity, language, or tone. The built-in grounding behavior (answer
|
|
573
|
+
* from the retrieved excerpts, cite pages as Markdown links) is preserved.
|
|
574
|
+
*/
|
|
575
|
+
instructions?: string;
|
|
568
576
|
/** Model id to use. Defaults to `openai/gpt-5.5`. */
|
|
569
577
|
model?: string;
|
|
570
578
|
/** Which backend routes the request. Defaults to `gateway`. */
|
|
@@ -189,6 +189,7 @@ declare const contentSourceSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
|
189
189
|
query: z.ZodString;
|
|
190
190
|
type: z.ZodLiteral<"sanity">;
|
|
191
191
|
}, z.core.$strip>, z.ZodObject<{
|
|
192
|
+
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
192
193
|
database: z.ZodString;
|
|
193
194
|
pollInterval: z.ZodOptional<z.ZodNumber>;
|
|
194
195
|
prefix: z.ZodOptional<z.ZodString>;
|
|
@@ -244,6 +245,7 @@ declare const aiConfigSchema: z.ZodObject<{
|
|
|
244
245
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
245
246
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
246
247
|
endpoint: z.ZodOptional<z.ZodString>;
|
|
248
|
+
instructions: z.ZodOptional<z.ZodString>;
|
|
247
249
|
model: z.ZodDefault<z.ZodString>;
|
|
248
250
|
provider: z.ZodDefault<z.ZodEnum<{
|
|
249
251
|
gateway: "gateway";
|
|
@@ -400,6 +402,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
400
402
|
baseUrl: z.ZodOptional<z.ZodURL>;
|
|
401
403
|
enabled: z.ZodDefault<z.ZodBoolean>;
|
|
402
404
|
endpoint: z.ZodOptional<z.ZodString>;
|
|
405
|
+
instructions: z.ZodOptional<z.ZodString>;
|
|
403
406
|
model: z.ZodDefault<z.ZodString>;
|
|
404
407
|
provider: z.ZodDefault<z.ZodEnum<{
|
|
405
408
|
gateway: "gateway";
|
|
@@ -527,6 +530,7 @@ export declare const blumeConfigSchema: z.ZodObject<{
|
|
|
527
530
|
query: z.ZodString;
|
|
528
531
|
type: z.ZodLiteral<"sanity">;
|
|
529
532
|
}, z.core.$strip>, z.ZodObject<{
|
|
533
|
+
concurrency: z.ZodOptional<z.ZodNumber>;
|
|
530
534
|
database: z.ZodString;
|
|
531
535
|
pollInterval: z.ZodOptional<z.ZodNumber>;
|
|
532
536
|
prefix: z.ZodOptional<z.ZodString>;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
|
|
1
|
+
import type { FolderMeta, FrontmatterExtend, ResolvedI18nConfig } from "../schema.ts";
|
|
2
2
|
import type { Diagnostic } from "../types.ts";
|
|
3
3
|
/**
|
|
4
4
|
* A single content item, normalized by a source adapter. Adapters lower their
|
|
@@ -41,6 +41,14 @@ export interface SourceLoadResult {
|
|
|
41
41
|
entries: SourceEntry[];
|
|
42
42
|
/** Source-level diagnostics (e.g. an offline cache fallback warning). */
|
|
43
43
|
diagnostics: Diagnostic[];
|
|
44
|
+
/**
|
|
45
|
+
* Folder meta the source derives for the sidebar groups its entries create,
|
|
46
|
+
* keyed by locale-stripped group path (the `meta.ts` key space). The OpenAPI
|
|
47
|
+
* source labels each tag directory with the spec's own tag name, so the
|
|
48
|
+
* sidebar shows `OAuth2`/`Größe` instead of a re-humanized slug. Merged
|
|
49
|
+
* beneath user-authored meta files, which always win.
|
|
50
|
+
*/
|
|
51
|
+
folderMeta?: Record<string, FolderMeta>;
|
|
44
52
|
}
|
|
45
53
|
/**
|
|
46
54
|
* Per-source runtime context, handed to an adapter factory at construction so
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
2
|
+
export { normalizeRoute } from "../core/base-path.ts";
|
|
2
3
|
/**
|
|
3
4
|
* Pure resolution of the configured API reference blocks into concrete routes,
|
|
4
5
|
* labels, and a renderer choice — no file IO, so the content source, the
|
|
@@ -54,9 +55,14 @@ export interface ReferenceSource {
|
|
|
54
55
|
*/
|
|
55
56
|
collisions?: string[];
|
|
56
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* Lowercase, hyphen-separated slug: `Add a Pet!` -> `add-a-pet`. Unicode
|
|
60
|
+
* letters are kept (`Größe` -> `größe`), NFC-normalized so canonically
|
|
61
|
+
* equivalent spellings (NFD input from macOS tooling) land on one slug.
|
|
62
|
+
* Non-ASCII slugs rely on the emitter percent-encoding the URL where a raw
|
|
63
|
+
* URI is required (sitemap, canonical).
|
|
64
|
+
*/
|
|
57
65
|
export declare const slugify: (text: string) => string;
|
|
58
|
-
/** Normalize a configured route to a single leading slash, no trailing slash. */
|
|
59
|
-
export declare const normalizeRoute: (route: string) => string;
|
|
60
66
|
/**
|
|
61
67
|
* Resolve every enabled reference. OpenAPI honors its `renderer` (Blume's own UI
|
|
62
68
|
* by default); AsyncAPI is always rendered by Scalar for now.
|
|
@@ -136,6 +136,22 @@ ai: {
|
|
|
136
136
|
|
|
137
137
|
`label` is the question that gets asked; `icon` is optional. Leave `suggestions` unset (or empty) and the panel opens to a plain input.
|
|
138
138
|
|
|
139
|
+
### Custom instructions
|
|
140
|
+
|
|
141
|
+
Add your own system-prompt text with `instructions` — identity, language, tone, or anything else the assistant should keep in mind:
|
|
142
|
+
|
|
143
|
+
```ts blume.config.ts lineNumbers
|
|
144
|
+
ai: {
|
|
145
|
+
ask: {
|
|
146
|
+
enabled: true,
|
|
147
|
+
instructions:
|
|
148
|
+
"You are Bloomy, the Acme docs assistant. Answer in the language the question was asked in, and keep answers under three paragraphs.",
|
|
149
|
+
},
|
|
150
|
+
}
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Your text is **appended to** the built-in instructions rather than replacing them: the built-in part carries the [grounding](#grounding) contract — answer only from the retrieved pages, cite them as Markdown links — that the chat panel's citations depend on, so it stays intact whatever you add.
|
|
154
|
+
|
|
139
155
|
### Grounding
|
|
140
156
|
|
|
141
157
|
Ask AI is **grounded in your docs**. For each question it retrieves the most relevant pages — using the same lexical [Orama](/docs/configuration/search) index that powers on-page search — and injects them into the model's system prompt, so answers come from your content instead of the model's own knowledge. The assistant is told to answer only from the retrieved pages, to say when something isn't covered, and to cite the pages it drew from.
|
|
@@ -185,15 +201,17 @@ A static build with Ask AI enabled and no external `endpoint` fails fast with a
|
|
|
185
201
|
|
|
186
202
|
By default Ask AI routes through the **Vercel AI Gateway**: `model` is a `provider/model` string, so you switch models by changing it (`openai/gpt-5.5`, `anthropic/claude-sonnet-4-5`, and so on) with no provider SDK to install. The gateway reads `AI_GATEWAY_API_KEY` from your environment and is wired up automatically when you deploy on Vercel.
|
|
187
203
|
|
|
188
|
-
Set `provider` to point Ask AI somewhere else. Each backend reads its API key from an environment variable
|
|
204
|
+
Set `provider` to point Ask AI somewhere else. Each backend reads its API key from an environment variable and streams through a provider SDK you install in your project — only the one you use:
|
|
205
|
+
|
|
206
|
+
| `provider` | `model` | API key env var | SDK to install |
|
|
207
|
+
| --- | --- | --- | --- |
|
|
208
|
+
| `gateway` (default) | a `provider/model` string via the AI Gateway | `AI_GATEWAY_API_KEY` | none — ships with Blume |
|
|
209
|
+
| `openrouter` | any [OpenRouter](https://openrouter.ai) model | `OPENROUTER_API_KEY` | `@openrouter/ai-sdk-provider` |
|
|
210
|
+
| `llmgateway` | any [LLMGateway](https://llmgateway.io) model | `LLMGATEWAY_API_KEY` | `@ai-sdk/openai-compatible` |
|
|
211
|
+
| `inkeep` | an [Inkeep](https://inkeep.com) QA model | `INKEEP_API_KEY` | `@ai-sdk/openai-compatible` |
|
|
212
|
+
| `openai-compatible` | whatever your endpoint serves | set with `apiKeyEnv` | `@ai-sdk/openai-compatible` |
|
|
189
213
|
|
|
190
|
-
|
|
191
|
-
| --- | --- | --- |
|
|
192
|
-
| `gateway` (default) | a `provider/model` string via the AI Gateway | `AI_GATEWAY_API_KEY` |
|
|
193
|
-
| `openrouter` | any [OpenRouter](https://openrouter.ai) model | `OPENROUTER_API_KEY` |
|
|
194
|
-
| `llmgateway` | any [LLMGateway](https://llmgateway.io) model | `LLMGATEWAY_API_KEY` |
|
|
195
|
-
| `inkeep` | an [Inkeep](https://inkeep.com) QA model | `INKEEP_API_KEY` |
|
|
196
|
-
| `openai-compatible` | whatever your endpoint serves | set with `apiKeyEnv` |
|
|
214
|
+
The SDKs are optional peer dependencies, so add the one your backend needs to your project (e.g. `npm install @openrouter/ai-sdk-provider`). If it's missing, the build warns with the exact package name before Vite would fail to resolve the import.
|
|
197
215
|
|
|
198
216
|
For example, to use OpenRouter:
|
|
199
217
|
|
package/docs/content/sources.mdx
CHANGED
|
@@ -146,7 +146,7 @@ export default defineConfig({
|
|
|
146
146
|
});
|
|
147
147
|
```
|
|
148
148
|
|
|
149
|
-
The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS image never rots a static build.
|
|
149
|
+
The integration token comes from the `NOTION_TOKEN` environment variable (share the database with your integration). By default every page is imported; set `publishedValue` to make the `Status` property a publish gate — any other value then maps to `draft: true`, which production builds drop. **Notion image URLs are signed and expire**, so the adapter downloads them at build time into the site's assets and rewrites the references — a CMS image never rots a static build. API calls are paced through a small request pool (3 at a time, matching Notion's per-integration rate limit) so databases with hundreds of pages import without tripping `429` responses; set `concurrency` on the source to tune it.
|
|
150
150
|
|
|
151
151
|
## Preview and sync
|
|
152
152
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "blume",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.3",
|
|
4
4
|
"description": "Documentation that's fast, AI-ready, and zero-config.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"astro",
|
|
@@ -86,6 +86,7 @@
|
|
|
86
86
|
"@shikijs/twoslash": "^4.2.0",
|
|
87
87
|
"@tailwindcss/typography": "^0.5.20",
|
|
88
88
|
"@tailwindcss/vite": "^4",
|
|
89
|
+
"@types/mdast": "^4.0.4",
|
|
89
90
|
"@vercel/analytics": "^2.0.1",
|
|
90
91
|
"ai": "^7.0.42",
|
|
91
92
|
"astro": "^7.1.0",
|
|
@@ -93,6 +94,7 @@
|
|
|
93
94
|
"chokidar": "^5.0.0",
|
|
94
95
|
"citty": "^0.1.6",
|
|
95
96
|
"consola": "^3.4.0",
|
|
97
|
+
"cross-spawn": "^7.0.6",
|
|
96
98
|
"dompurify": "^3.4.13",
|
|
97
99
|
"dotenv": "^17.4.2",
|
|
98
100
|
"epub-gen-memory": "^1.1.2",
|
|
@@ -113,10 +115,16 @@
|
|
|
113
115
|
"medium-zoom": "^1.1.0",
|
|
114
116
|
"mermaid": "^11.16.1",
|
|
115
117
|
"micromark-extension-gfm": "^3.0.0",
|
|
118
|
+
"nanotar": "^0.3.0",
|
|
116
119
|
"node-html-parser": "^9.0.0",
|
|
117
120
|
"openapi-sampler": "^1.7.4",
|
|
121
|
+
"p-limit": "^7.3.1",
|
|
122
|
+
"p-map": "^7.0.6",
|
|
123
|
+
"p-retry": "^8.0.0",
|
|
124
|
+
"package-manager-detector": "^1.8.0",
|
|
118
125
|
"pagefind": "^1.3.0",
|
|
119
126
|
"pathe": "^2.0.0",
|
|
127
|
+
"perfect-debounce": "^2.1.0",
|
|
120
128
|
"picomatch": "^4.0.5",
|
|
121
129
|
"react": "^19.0.0",
|
|
122
130
|
"react-dom": "^19.0.0",
|
|
@@ -131,11 +139,13 @@
|
|
|
131
139
|
"tinyglobby": "^0.2.10",
|
|
132
140
|
"twoslash": "^0.3.9",
|
|
133
141
|
"typescript": "^6.0.3",
|
|
142
|
+
"ufo": "^1.6.4",
|
|
134
143
|
"undici": "^8.9.0",
|
|
135
144
|
"write-file-atomic": "^8.0.0",
|
|
136
145
|
"zod": "^4.3.6"
|
|
137
146
|
},
|
|
138
147
|
"devDependencies": {
|
|
148
|
+
"@types/cross-spawn": "^6.0.6",
|
|
139
149
|
"@types/html-escaper": "^3.0.4",
|
|
140
150
|
"@types/js-yaml": "^4.0.9",
|
|
141
151
|
"@types/node": "^22.10.0",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
3
3
|
import type { ContentSignalPolicy, ContentSignals } from "../core/schema.ts";
|
|
4
|
+
import { absoluteUrl } from "../core/site-url.ts";
|
|
4
5
|
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
5
6
|
import { hasApiCatalog } from "./api-catalog.ts";
|
|
6
7
|
|
|
@@ -42,7 +43,7 @@ const askApiUrl = (
|
|
|
42
43
|
return abs("/api/ask");
|
|
43
44
|
}
|
|
44
45
|
return site && endpoint.startsWith("/")
|
|
45
|
-
?
|
|
46
|
+
? absoluteUrl(site, endpoint)
|
|
46
47
|
: endpoint;
|
|
47
48
|
};
|
|
48
49
|
|
|
@@ -88,7 +89,7 @@ export const buildAgentReadability = (
|
|
|
88
89
|
const deployBase = normalizeBasePath(config.deployment.base);
|
|
89
90
|
const abs = (path: string): string => {
|
|
90
91
|
const based = withBasePath(deployBase, path);
|
|
91
|
-
return site ?
|
|
92
|
+
return site ? absoluteUrl(site, based) : based;
|
|
92
93
|
};
|
|
93
94
|
|
|
94
95
|
// Advertise `Accept: text/markdown` negotiation only where the deployed site
|
package/src/ai/api-catalog.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
2
2
|
import type { ResolvedConfig } from "../core/schema.ts";
|
|
3
|
-
import {
|
|
3
|
+
import { absoluteUrl } from "../core/site-url.ts";
|
|
4
4
|
import { resolveReferences } from "../openapi/references.ts";
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -32,7 +32,7 @@ const linksetEntries = (config: ResolvedConfig): LinksetEntry[] => {
|
|
|
32
32
|
const deployBase = normalizeBasePath(config.deployment.base);
|
|
33
33
|
const abs = (path: string): string => {
|
|
34
34
|
const based = withBasePath(deployBase, path);
|
|
35
|
-
return site ?
|
|
35
|
+
return site ? absoluteUrl(site, based) : based;
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
const entries: LinksetEntry[] = [];
|
package/src/ai/ask-context.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { normalizeRoute } from "../core/base-path.ts";
|
|
1
2
|
import { buildOramaIndex, queryOramaIndex } from "../search/orama-index.ts";
|
|
2
3
|
import type { OramaDoc } from "../search/orama-index.ts";
|
|
3
4
|
|
|
@@ -90,11 +91,39 @@ const STOPWORDS = new Set([
|
|
|
90
91
|
"your",
|
|
91
92
|
]);
|
|
92
93
|
|
|
94
|
+
/** A run of letters, combining marks and digits inside a word-like segment. */
|
|
95
|
+
const TERM = /[\p{L}\p{M}\p{N}]+/gu;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Word-shaped pieces of a query, NFC-normalized and lowercased. Languages
|
|
99
|
+
* written without spaces (the CJK/Thai sites the Orama tokenizer goes out of
|
|
100
|
+
* its way to support) have no delimiter for a regex to split on, so the query
|
|
101
|
+
* is cut with `Intl.Segmenter` where available — otherwise every excerpt
|
|
102
|
+
* window silently degrades to the head of the page. The regex fallback covers
|
|
103
|
+
* runtimes without the segmenter and still handles spaced scripts correctly.
|
|
104
|
+
*/
|
|
105
|
+
const segmentQuery = (query: string): string[] => {
|
|
106
|
+
const lowered = query.normalize("NFC").toLowerCase();
|
|
107
|
+
if (typeof Intl.Segmenter !== "function") {
|
|
108
|
+
return lowered.match(TERM) ?? [];
|
|
109
|
+
}
|
|
110
|
+
const pieces: string[] = [];
|
|
111
|
+
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
|
|
112
|
+
for (const segment of segmenter.segment(lowered)) {
|
|
113
|
+
if (segment.isWordLike) {
|
|
114
|
+
pieces.push(segment.segment);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return pieces;
|
|
118
|
+
};
|
|
119
|
+
|
|
93
120
|
/** Distinct, meaningful lowercase terms from a query (drops stopwords). */
|
|
94
|
-
const queryTerms = (query: string): string[] =>
|
|
95
|
-
|
|
121
|
+
const queryTerms = (query: string): string[] => {
|
|
122
|
+
const terms = segmentQuery(query).flatMap((piece) => piece.match(TERM) ?? []);
|
|
123
|
+
return [...new Set(terms)].filter(
|
|
96
124
|
(term) => term.length >= 2 && !STOPWORDS.has(term)
|
|
97
125
|
);
|
|
126
|
+
};
|
|
98
127
|
|
|
99
128
|
/**
|
|
100
129
|
* The grounding preamble. The model is told to answer strictly from the injected
|
|
@@ -104,13 +133,6 @@ const queryTerms = (query: string): string[] =>
|
|
|
104
133
|
const BASE_INSTRUCTION =
|
|
105
134
|
"You are a helpful documentation assistant for this project. Answer the user's question using ONLY the documentation excerpts below. Each excerpt is headed by its page as `## Page Title (/route)`. If the answer is not covered by the excerpts, say you don't know and suggest where in the docs to look — do not invent details. Always cite the pages you drew from, and write every citation as a Markdown link to that page using its route, e.g. [Page Title](/route).";
|
|
106
135
|
|
|
107
|
-
/** Normalize a page path to a document `route` (`/`, `/a/b`, no trailing slash). */
|
|
108
|
-
const normalizeRoute = (input: string): string => {
|
|
109
|
-
const noTrailing = input.trim().replace(/\/+$/u, "");
|
|
110
|
-
const withSlash = noTrailing.startsWith("/") ? noTrailing : `/${noTrailing}`;
|
|
111
|
-
return withSlash === "" ? "/" : withSlash;
|
|
112
|
-
};
|
|
113
|
-
|
|
114
136
|
/** The most recent non-empty user message, used as the retrieval query. */
|
|
115
137
|
const lastUserMessage = (messages: AskMessage[]): string => {
|
|
116
138
|
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
@@ -137,7 +159,9 @@ export const relevantExcerpt = (
|
|
|
137
159
|
query: string,
|
|
138
160
|
max: number
|
|
139
161
|
): string => {
|
|
140
|
-
|
|
162
|
+
// NFC to match the normalized query terms; positions are computed on (and
|
|
163
|
+
// sliced from) this same string, so offsets stay aligned.
|
|
164
|
+
const trimmed = content.normalize("NFC").trim();
|
|
141
165
|
if (trimmed.length <= max) {
|
|
142
166
|
return trimmed;
|
|
143
167
|
}
|
|
@@ -199,9 +223,15 @@ export const relevantExcerpt = (
|
|
|
199
223
|
* grounded system prompt — the retrieved excerpts plus the page the user is
|
|
200
224
|
* viewing — or `undefined` when there is nothing to ground on, so the endpoint
|
|
201
225
|
* can fall back to its plain prompt.
|
|
226
|
+
*
|
|
227
|
+
* `options.instructions` (the `ai.ask.instructions` config) is appended after
|
|
228
|
+
* the base instruction rather than replacing it: the base carries the
|
|
229
|
+
* functional contract (answer only from the excerpts, cite pages as Markdown
|
|
230
|
+
* links) that the panel's citation rendering depends on.
|
|
202
231
|
*/
|
|
203
232
|
export const createAskContext = (
|
|
204
|
-
data: AskData
|
|
233
|
+
data: AskData,
|
|
234
|
+
options?: { instructions?: string }
|
|
205
235
|
): ((
|
|
206
236
|
messages: AskMessage[],
|
|
207
237
|
page?: AskPage
|
|
@@ -213,6 +243,9 @@ export const createAskContext = (
|
|
|
213
243
|
return dbPromise;
|
|
214
244
|
};
|
|
215
245
|
const byRoute = new Map(data.documents.map((doc) => [doc.route, doc]));
|
|
246
|
+
const instruction = options?.instructions
|
|
247
|
+
? `${BASE_INSTRUCTION}\n\n${options.instructions}`
|
|
248
|
+
: BASE_INSTRUCTION;
|
|
216
249
|
|
|
217
250
|
return async (messages, page) => {
|
|
218
251
|
const list = Array.isArray(messages) ? messages : [];
|
|
@@ -257,6 +290,6 @@ export const createAskContext = (
|
|
|
257
290
|
if (sections.length === 0) {
|
|
258
291
|
return;
|
|
259
292
|
}
|
|
260
|
-
return `${
|
|
293
|
+
return `${instruction}\n\n<docs>\n${sections.join("\n\n")}\n</docs>`;
|
|
261
294
|
};
|
|
262
295
|
};
|
package/src/ai/llms.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { normalizeBasePath, withBasePath } from "../core/base-path.ts";
|
|
|
2
2
|
import { rewriteRelativeImages } from "../core/content-assets.ts";
|
|
3
3
|
import matter from "../core/frontmatter.ts";
|
|
4
4
|
import type { BlumeProject } from "../core/project-graph.ts";
|
|
5
|
+
import { absoluteUrl } from "../core/site-url.ts";
|
|
5
6
|
import { readEntryText } from "../core/sources/read.ts";
|
|
6
7
|
import type { NavNode, Navigation, PageRecord } from "../core/types.ts";
|
|
7
8
|
import { buildRssFeeds } from "../deploy/rss.ts";
|
|
@@ -17,7 +18,7 @@ import { applyAgentVisibility } from "./visibility.ts";
|
|
|
17
18
|
// spaces or non-ASCII must still yield a valid Markdown link.
|
|
18
19
|
const pageUrl = (route: string, site?: string, base = ""): string => {
|
|
19
20
|
const path = withBasePath(base, route);
|
|
20
|
-
return encodeURI(site ?
|
|
21
|
+
return encodeURI(site ? absoluteUrl(site, path) : path);
|
|
21
22
|
};
|
|
22
23
|
|
|
23
24
|
// Drafts, hidden, and ordinary `noindex` pages are excluded. Generated API
|
package/src/ai/mcp/discovery.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withBasePath } from "../../core/base-path.ts";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { absoluteUrl, siteRoot } from "../../core/site-url.ts";
|
|
3
|
+
import { trimChar } from "../../core/trim.ts";
|
|
4
4
|
import { MCP_TOOLS } from "./tools.ts";
|
|
5
5
|
|
|
6
6
|
/** Inputs needed to describe the MCP server in discovery documents. */
|
|
@@ -20,7 +20,7 @@ const serverUrl = (input: McpDiscoveryInput): string => {
|
|
|
20
20
|
const path = withBasePath(input.base, input.route);
|
|
21
21
|
// Concatenate rather than `new URL(path, site)` — a root-absolute path
|
|
22
22
|
// would drop the base path of a subpath deployment (`acme.com/docs`).
|
|
23
|
-
return input.site ?
|
|
23
|
+
return input.site ? absoluteUrl(input.site, path) : path;
|
|
24
24
|
};
|
|
25
25
|
|
|
26
26
|
/**
|
|
@@ -49,10 +49,29 @@ const CARD_TEXT_MAX = 100;
|
|
|
49
49
|
const truncate = (text: string): string =>
|
|
50
50
|
text.length > CARD_TEXT_MAX ? `${text.slice(0, CARD_TEXT_MAX - 1)}…` : text;
|
|
51
51
|
|
|
52
|
+
// The server-card schema constrains `name` to `namespace/server` in ASCII
|
|
53
|
+
// (`^[a-zA-Z0-9.-]+/[a-zA-Z0-9._-]+$`), so the display name cannot reuse the
|
|
54
|
+
// route slugifier, which keeps Unicode letters. Decompose (NFKD) and drop
|
|
55
|
+
// combining marks first so an accented name transliterates (`Café` → `cafe`)
|
|
56
|
+
// instead of losing the letter.
|
|
57
|
+
const NON_ASCII_SLUG = /[^a-z0-9]+/gu;
|
|
58
|
+
const COMBINING_MARKS = /\p{M}+/gu;
|
|
59
|
+
|
|
60
|
+
const asciiSlugify = (text: string): string =>
|
|
61
|
+
trimChar(
|
|
62
|
+
text
|
|
63
|
+
.normalize("NFKD")
|
|
64
|
+
.replace(COMBINING_MARKS, "")
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.replace(NON_ASCII_SLUG, "-"),
|
|
67
|
+
"-"
|
|
68
|
+
);
|
|
69
|
+
|
|
52
70
|
/**
|
|
53
71
|
* The card's identity in the schema's reverse-DNS `namespace/server` form:
|
|
54
72
|
* the site hostname reversed (`useblume.dev` → `dev.useblume`), or
|
|
55
|
-
* `localhost` when no site is configured, plus the slugged display name
|
|
73
|
+
* `localhost` when no site is configured, plus the slugged display name (an
|
|
74
|
+
* entirely non-ASCII name falls back to `docs`).
|
|
56
75
|
*/
|
|
57
76
|
const reverseDnsName = (input: McpDiscoveryInput): string => {
|
|
58
77
|
let namespace = "localhost";
|
|
@@ -66,7 +85,7 @@ const reverseDnsName = (input: McpDiscoveryInput): string => {
|
|
|
66
85
|
// Not a parsable URL; the local namespace is honest enough.
|
|
67
86
|
}
|
|
68
87
|
}
|
|
69
|
-
return `${namespace}/${
|
|
88
|
+
return `${namespace}/${asciiSlugify(input.name) || "docs"}`;
|
|
70
89
|
};
|
|
71
90
|
|
|
72
91
|
const HTTP_URL = /^https?:\/\//u;
|
|
@@ -109,6 +128,6 @@ export const buildMcpServerCard = (
|
|
|
109
128
|
transports: [{ endpoint: url, type: "streamable-http" }],
|
|
110
129
|
url,
|
|
111
130
|
version: input.version,
|
|
112
|
-
...(input.site ? { websiteUrl:
|
|
131
|
+
...(input.site ? { websiteUrl: siteRoot(input.site) } : {}),
|
|
113
132
|
};
|
|
114
133
|
};
|