-
Notifications
You must be signed in to change notification settings - Fork 0
Add headless docs search and AI answers #4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,6 @@ | ||
| .output/ | ||
| content-fixtures/ | ||
| public/ | ||
| public-real/ | ||
| public-real2/ | ||
| test-results/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| --- | ||
| title: "Search and AI Answers" | ||
| description: "Generate a local docs search index and stream source-grounded AI answers." | ||
| --- | ||
|
|
||
| # Search and AI Answers | ||
|
|
||
| `@inth/docs` includes headless search logic for docs sites that want to bring their own UI. | ||
|
|
||
| <Callout title="Demo route"> | ||
| Open the live search example at [/search](/search). Typing runs local search only; the `Ask` button is the only action that can call the model. | ||
| </Callout> | ||
|
|
||
| ## Package Surfaces | ||
|
|
||
| <TypeTable | ||
| type={{ | ||
| "@inth/docs/search": { | ||
| type: "runtime", | ||
| description: "Edge-safe search, answer context, query validation, JSON body limits, and rate limiter helpers.", | ||
| required: true, | ||
| }, | ||
| "@inth/docs/search/node": { | ||
| type: "build time", | ||
| description: "Node-only `generateSearchIndex` helper that reads converted markdown and writes `docs/search-index.json`.", | ||
| required: true, | ||
| }, | ||
| "@inth/docs/search/ai": { | ||
| type: "runtime", | ||
| description: "Vercel AI SDK `streamText` wrapper for source-grounded plain text answer streams.", | ||
| }, | ||
| }} | ||
| /> | ||
|
|
||
| ## Build the Index | ||
|
|
||
| Run conversion first, then generate the search index from markdown: | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Capitalize “Markdown” for consistency. At Line 37, use “Markdown” (proper noun) for documentation style consistency. 🧰 Tools🪛 LanguageTool[uncategorized] ~37-~37: Did you mean the formatting language “Markdown” (= proper noun)? (MARKDOWN_NNP) 🤖 Prompt for AI Agents |
||
|
|
||
| ```ts | ||
| import { generateSearchIndex } from "@inth/docs/search/node"; | ||
|
|
||
| await generateSearchIndex({ | ||
| outDir: "public", | ||
| baseUrl: "https://docs.example.com", | ||
| }); | ||
| ``` | ||
|
|
||
| The generated index is static JSON. In the demo app, `scripts/search-generate.ts` copies it into `src/generated/docs-search-index.json` so routes can import it without reading from the file system at request time. | ||
|
|
||
| ## Runtime Search | ||
|
|
||
| Import the generated JSON and query it from your own route handler: | ||
|
|
||
| ```ts | ||
| import { searchDocs, type DocsSearchIndex } from "@inth/docs/search"; | ||
| import indexJson from "./generated/docs-search-index.json"; | ||
|
|
||
| const results = searchDocs(indexJson as DocsSearchIndex, "package tabs"); | ||
| ``` | ||
|
|
||
| Search results include `urlWithHash` and `absoluteUrlWithHash` so the UI can link directly to the matched heading. The demo renders matching heading `id`s with `slugifyDocsHeading`. | ||
|
|
||
| ## AI Answers | ||
|
|
||
| Use `streamDocsAnswer` when you want a simple Vercel AI SDK integration: | ||
|
|
||
| ```ts | ||
| import { streamDocsAnswer } from "@inth/docs/search/ai"; | ||
|
|
||
| const { response } = streamDocsAnswer({ | ||
| index, | ||
| query, | ||
| model: process.env.DOCS_SEARCH_MODEL ?? "moonshotai/kimi-k2.6", | ||
| productName: "@inth/docs", | ||
| }); | ||
| ``` | ||
|
|
||
| The answer prompt only includes retrieved docs context, tells the model to cite sources, and asks it to say when the docs do not contain enough context. | ||
|
|
||
| ## Abuse Protection | ||
|
|
||
| <Steps> | ||
| <Step title="Search is cheap"> | ||
| Debounced typing should call only the local `/api/docs/search` route. It does not call the model. | ||
| </Step> | ||
| <Step title="Answers are explicit"> | ||
| Keep model calls behind a button such as `Ask`, `Enter to ask`, or `Cmd+Enter`. | ||
| </Step> | ||
| <Step title="Limit request paths"> | ||
| Use `validateDocsQuery`, `readJsonWithLimit`, `getClientIdentifier`, and a `RateLimiter` implementation around public routes. | ||
| </Step> | ||
| </Steps> | ||
|
|
||
| The demo uses an in-memory limiter for local smoke coverage. Production apps should adapt the same `RateLimiter` interface to a shared store such as Redis, Vercel KV, Cloudflare KV, or Durable Objects. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| #!/usr/bin/env bun | ||
| /** | ||
| * Generates a static docs search index from converted markdown. | ||
| */ | ||
|
|
||
| import { copyFile, mkdir } from "node:fs/promises"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { generateSearchIndex } from "../../../packages/docs/src/search/node-index.ts"; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧹 Nitpick | 🔵 Trivial 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Check if other scripts in this app use relative imports to packages/docs
rg -n "from ['\"]\.\.\/\.\.\/\.\.\/packages" apps/docs-smoke/scripts/Repository: inthhq/docs Length of output: 169 🏁 Script executed: #!/bin/bash
# Fix: Search for imports from packages/docs using simpler pattern
echo "=== Searching for imports from packages/docs ==="
rg 'from.*packages/docs' apps/docs-smoke/scripts/ --type ts
echo -e "\n=== Checking package.json exports for `@inth/docs` ==="
# Find the docs package.json
find . -path "*/packages/docs/package.json" | head -1 | xargs cat | head -30
echo -e "\n=== Checking if node-index.ts is a proper export ==="
# Look at the docs package structure
ls -la packages/docs/src/search/ 2>/dev/null || echo "Path not found"
echo -e "\n=== Searching how other scripts import from docs package ==="
# Look at other apps' scripts
rg 'from.*docs' apps/*/scripts/ --type tsRepository: inthhq/docs Length of output: 4697 🏁 Script executed: #!/bin/bash
# Get complete package.json exports section
echo "=== Complete package.json exports ==="
packages/docs/package.json | jq '.exports' 2>/dev/null || sed -n '/"exports"/,/^ },*$/p' packages/docs/package.json
echo -e "\n=== Contents of node-index.ts ==="
cat packages/docs/src/search/node-index.ts
echo -e "\n=== Contents of index.ts (search) ==="
cat packages/docs/src/search/index.tsRepository: inthhq/docs Length of output: 1053 🏁 Script executed: #!/bin/bash
# Read package.json properly
echo "=== Package.json exports (search related) ==="
cat packages/docs/package.json | jq '.exports | select(. != null) | keys[] | select(contains("search"))'
echo -e "\n=== Full exports object ==="
cat packages/docs/package.json | jq '.exports'Repository: inthhq/docs Length of output: 994 Consider using the package export for consistency with Node.js deployments. The package exports Note: Other scripts in this app use relative imports (e.g., 🤖 Prompt for AI Agents |
||
|
|
||
| const scriptsRoot = dirname(fileURLToPath(import.meta.url)); | ||
| const appRoot = join(scriptsRoot, ".."); | ||
| const outDir = join(appRoot, "public"); | ||
| const generatedDir = join(appRoot, "src", "generated"); | ||
| const generatedIndexPath = join(generatedDir, "docs-search-index.json"); | ||
|
|
||
| const result = await generateSearchIndex({ | ||
| outDir, | ||
| baseUrl: "https://docs.example.com", | ||
| }); | ||
|
|
||
| await mkdir(generatedDir, { recursive: true }); | ||
| await copyFile(result.outputPath, generatedIndexPath); | ||
|
|
||
| process.stdout.write( | ||
| `Search index generated: ${result.docs} docs, ${result.chunks} chunks, ${result.terms} terms\n` | ||
| ); | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update package-surface count in the intro.
After adding
@inth/docs/search, the sentence at Line 8 (“has five package surfaces”) is stale and should be updated to six.📝 Suggested doc fix
🤖 Prompt for AI Agents