mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-19 08:01:25 +00:00
9d61e4e59e
* feat: self-host docs search for chat, mcp and cli; remove inkeep
Embed a vendored docs snapshot (llms.txt/llms-full.txt) in the backend and
serve ranking + page rendering from GET /api/docs/{search,page}. The AI chat,
the MCP searchDocs/readDocsPage tools, and 'wmill docs' all consume it, so docs
search works with no runtime egress and is no longer EE-gated. Removes the
inkeep proxy. EE companion deletes inkeep_ee.rs (ee-repo-ref bumped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor: name read_docs_page param `url` instead of `path`
search_docs returns each hit's `Source` URL, so the read tool now takes a
`url` argument to match — the AI/MCP loop reads "search gives a Source URL,
read takes that url" rather than copying a `Source:` URL into a `path` slot.
A bare `/docs/...` path is still accepted and canonicalized before lookup.
Regenerated openapi-deref, the MCP endpoint tools, and the frontend client.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* ci: add scheduled workflow to refresh the vendored docs snapshot
The backend embeds docs_snapshot/*.gz at build time, so the in-product docs
corpus is otherwise only as fresh as the last manual fetch.sh run. This adds a
weekly (and manually dispatchable) job that re-runs fetch.sh, sanity-checks the
result against truncation/garbage, and opens a PR via the internal app when the
snapshot changed — so a human reviews the docs diff before it rides into the
next release build.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* refactor: make docs tool-result strings caller-neutral
The search/page endpoints back three differently-named consumers (the AI chat
`read_docs_page` tool, the MCP `readDocsPage` tool, and the `wmill docs` CLI),
so the shared rendered text shouldn't name one of them. Refer to "the docs
page-reading tool" and its `url` argument instead, and add tests pinning the
caller-neutral follow-up guidance.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: point ee-repo-ref at inkeep-removal companion rebased on EE main
The companion branch now carries only the inkeep_ee.rs deletion on top of EE
main (was based on the native-job-retry EE line, which polluted the EE PR diff).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* fix(docs): expose docs:read in token catalog; precompute lowercased corpus
Addresses two review nits on the self-hosted docs PR:
- docs:read was enforced (ScopeDomain::Docs) but missing from the token scope
catalog (token.rs ALL_SCOPES), so it couldn't be selected when creating a
standard scoped token in the UI — leaving scope-restricted CLI/MCP docs use
effectively ungrantable. Add a read-only "Documentation" group (no write
surface) and a test asserting it is exposed.
- search ran page.body.to_lowercase() on the whole corpus per query. Lowercase
body/title/description once at parse time (into the OnceLock corpus) and scan
the precomputed copies instead.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* chore: update ee-repo-ref to 27a4f41b8e5603d6e444efcfc420bd1c44a07eed
This commit updates the EE repository reference after PR #630 was merged in windmill-ee-private.
Previous ee-repo-ref: c7ec3a0c2fa38d4cb5e50bf0265eef4710de4860
New ee-repo-ref: 27a4f41b8e5603d6e444efcfc420bd1c44a07eed
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
125 lines
4.6 KiB
Rust
125 lines
4.6 KiB
Rust
//! Self-hosted documentation search.
|
|
//!
|
|
//! The backend embeds a vendored docs snapshot (see [`corpus`]) and exposes two
|
|
//! read-only endpoints over it, so docs search works with no runtime egress:
|
|
//! - `GET /api/docs/search?query=...` — full-text + index search
|
|
//! - `GET /api/docs/page?url=...§ion=...` — read one page (or a section)
|
|
//!
|
|
//! These back the AI chat `search_docs`/`read_docs_page` tools, the MCP
|
|
//! `searchDocs`/`readDocsPage` tools, and the `wmill docs` CLI. The routes are
|
|
//! nested behind the global authed service in `lib.rs`, so a valid token is
|
|
//! required but no workspace.
|
|
|
|
mod corpus;
|
|
mod search;
|
|
|
|
use axum::{extract::Query, routing::get, Json, Router};
|
|
use serde::{Deserialize, Serialize};
|
|
use windmill_common::error::JsonResult;
|
|
|
|
use search::DocsSearchResult;
|
|
|
|
pub fn global_service() -> Router {
|
|
Router::new()
|
|
.route("/search", get(search_docs))
|
|
.route("/page", get(read_docs_page))
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct SearchQuery {
|
|
query: String,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct SearchResponse {
|
|
/// Model-ready rendering of the results (the exact string the AI/MCP tool
|
|
/// returns). Built once here so every consumer is identical.
|
|
text: String,
|
|
/// Structured results for non-AI consumers (e.g. the CLI's pretty/`--json`).
|
|
results: Vec<DocsSearchResult>,
|
|
}
|
|
|
|
#[derive(Deserialize)]
|
|
struct PageQuery {
|
|
/// A page's `Source` URL (as returned by the docs search tool); a bare `/docs/...`
|
|
/// path is also accepted and canonicalized before lookup.
|
|
url: String,
|
|
section: Option<String>,
|
|
}
|
|
|
|
#[derive(Serialize)]
|
|
struct PageResponse {
|
|
text: String,
|
|
source_url: String,
|
|
}
|
|
|
|
async fn search_docs(Query(q): Query<SearchQuery>) -> JsonResult<SearchResponse> {
|
|
let query = q.query.trim().to_string();
|
|
if query.is_empty() {
|
|
return Ok(Json(SearchResponse {
|
|
text: "No search query was provided. Provide a `query` of one or more keywords."
|
|
.to_string(),
|
|
results: Vec::new(),
|
|
}));
|
|
}
|
|
|
|
// Lazy corpus init (gzip decompress + parse) and the per-query full-corpus scan
|
|
// are CPU-bound; keep them off the async runtime.
|
|
let (text, results) = tokio::task::spawn_blocking(move || {
|
|
let corpus = corpus::corpus();
|
|
// Body grep first (concrete content hits), then index titles/descriptions to
|
|
// surface named features body grep misses; merge dedupes by canonical URL.
|
|
let body = search::search_docs_pages(&corpus.pages, &query, 5);
|
|
let index = search::search_docs_index(&corpus.index, &query, 4);
|
|
let results = search::merge_docs_search_results(body, index, search::SEARCH_MAX_PAGES);
|
|
let text = search::format_docs_search_results(&query, &results);
|
|
(text, results)
|
|
})
|
|
.await
|
|
.map_err(|e| windmill_common::error::Error::InternalErr(format!("docs search task: {e}")))?;
|
|
|
|
Ok(Json(SearchResponse { text, results }))
|
|
}
|
|
|
|
async fn read_docs_page(Query(q): Query<PageQuery>) -> JsonResult<PageResponse> {
|
|
let url = q.url.trim();
|
|
if url.is_empty() {
|
|
return Ok(Json(PageResponse {
|
|
text: "No documentation page URL was provided. Provide a `url` — e.g. a `Source` URL returned by the docs search tool.".to_string(),
|
|
source_url: String::new(),
|
|
}));
|
|
}
|
|
|
|
let url = url.to_string();
|
|
let section = q.section.filter(|s| !s.trim().is_empty());
|
|
|
|
// Corpus init + page sanitize/render are CPU-bound; keep them off the runtime.
|
|
let resp = tokio::task::spawn_blocking(move || {
|
|
let corpus = corpus::corpus();
|
|
match corpus.find_page(&url) {
|
|
Some(page) => {
|
|
// Rewrite docusaurus source-file links to canonical published URLs
|
|
// so the model never echoes a broken `.mdx` path.
|
|
let sanitized = search::sanitize_docs_markdown_links(&page.body, &page.url);
|
|
let rendered = search::render_docs_page_result(&sanitized, section.as_deref());
|
|
let text = format!(
|
|
"Source page — cite this URL when referencing this page: {}\n\n{}",
|
|
page.url, rendered
|
|
);
|
|
PageResponse { text, source_url: page.url.clone() }
|
|
}
|
|
None => PageResponse {
|
|
text: format!(
|
|
"No documentation page found for \"{}\". Use the docs search tool to find the correct Source URL first.",
|
|
url
|
|
),
|
|
source_url: search::canonical_docs_page_url(&url),
|
|
},
|
|
}
|
|
})
|
|
.await
|
|
.map_err(|e| windmill_common::error::Error::InternalErr(format!("docs page task: {e}")))?;
|
|
|
|
Ok(Json(resp))
|
|
}
|