diff --git a/.env.example b/.env.example index bda78e3d..e0068c1f 100644 --- a/.env.example +++ b/.env.example @@ -4,3 +4,10 @@ # Gemini: LLM_API_KEY=AIza... # DeepSeek: LLM_API_KEY=sk-... LLM_API_KEY=your-key-here + +# Optional base URL override for OpenAI-compatible gateways +# OPENAI_API_BASE=https://api.deepseek.com + +# --- REST API server variables (read by `openkb-api` / `python -m openkb.api`) --- +# OPENKB_API_TOKEN=... # required bearer token clients send as Authorization: Bearer +# OPENKB_KB_ROOT=... # optional; where REST /init creates KBs (default: ~/.config/openkb/kbs) diff --git a/.gitignore b/.gitignore index c448d275..cd334f0b 100644 --- a/.gitignore +++ b/.gitignore @@ -14,7 +14,11 @@ raw/ wiki/ .openkb/ output/ +kbs/ # Local only docs/ .claude/ + +# Built Workbench web bundle (regenerate with `cd frontend && npm install && npm run build`) +web/ diff --git a/README.md b/README.md index 0fecbe11..ddbf85a8 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ # OpenKB: Open LLM Knowledge Base -

Scale to long documents  •  Reasoning-based retrieval  •  Native multi-modality  •  No Vector DB

+

Scale to long documents • Reasoning-based retrieval • Native multi-modality • No Vector DB

@@ -49,6 +49,7 @@ OpenKB has two layers: a **wiki foundation** that compiles and maintains your kn - **Skill Factory:** Distills redistributable agent skills from your wiki. - **OKF-ready:** Wiki pages follow the [Google OKF](https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing) specification for knowledge sharing. - **Obsidian-compatible:** The wiki is plain `.md` files with cross-links. Opens in Obsidian for graph view. +- **Knowledge Workbench (Web UI):** A bundled React SPA served at `/` turns the REST API into a full dark-themed three-pane workbench — browse stats, upload & compile documents, stream queries/chats with a live reasoning timeline, and run maintenance, all in the browser. No separate frontend server needed. # 🚀 Getting Started @@ -62,13 +63,13 @@ pip install openkb Other install options: - **Latest from GitHub:** - + ```bash pip install git+https://github.com/VectifyAI/OpenKB.git ``` - **Install from source** (editable, for development): - + ```bash git clone https://github.com/VectifyAI/OpenKB.git cd OpenKB @@ -115,6 +116,17 @@ Create a `.env` file with your LLM API key: LLM_API_KEY=your_llm_api_key ``` +### Knowledge Workbench (Web UI) + +OpenKB ships a bundled React SPA served by the REST API at `/`. Build it with `cd frontend && npm install && npm run build`, then start the server: + +```bash +pip install -e ".[api]" +OPENKB_API_TOKEN=test-token python -m openkb.api --host 127.0.0.1 --port 8000 +``` + +Open `http://127.0.0.1:8000/` for the Workbench, or see the [full Web UI guide](examples/rest-api/README.md#knowledge-workbench-web-ui). + # 🧩 How OpenKB Works ### Architecture @@ -125,12 +137,12 @@ LLM_API_KEY=your_llm_api_key ### Short vs Long Document Handling -| | Short documents | Long documents (PDF ≥ 20 pages) | -|---|---|---| -| **Convert** | markitdown → Markdown | PageIndex → tree index + summaries | -| **Images** | Extracted inline (pymupdf) | Extracted by PageIndex | -| **LLM reads** | Full text | Document trees | -| **Result** | summary + concepts | summary + concepts | +| | Short documents | Long documents (PDF ≥ 20 pages) | +| ------------- | -------------------------- | ---------------------------------- | +| **Convert** | markitdown → Markdown | PageIndex → tree index + summaries | +| **Images** | Extracted inline (pymupdf) | Extracted by PageIndex | +| **LLM reads** | Full text | Document trees | +| **Result** | summary + concepts | summary + concepts | Short documents are read in full by the LLM. Long PDFs are processed by [PageIndex](https://github.com/VectifyAI/PageIndex) into a hierarchical tree index. The LLM reads the tree instead of the full text, enabling accurate and scalable retrieval for long documents. @@ -152,24 +164,24 @@ OpenKB commands fall into two layers: the **wiki foundation** (compile + manage ## Layer 1: 🧱 Wiki Foundation — compile and maintain -| Command | Description | -|---|---| -| `openkb init` | Initialize a new knowledge base (interactive) | +| Command | Description | +| ------------------------------------------------------------ | --------------------------------------------------------------------------------------- | +| `openkb init` | Initialize a new knowledge base (interactive) | | openkb add <file_or_dir_or_URL> | Add files, directories, or URLs and compile to wiki (URL content type is auto-detected) | -| `openkb list` | List indexed documents and concepts | -| `openkb status` | Show knowledge base stats | -| `openkb watch` | Watch `raw/` and auto-compile new files | -| `openkb lint` | Run structural and knowledge health checks | +| `openkb list` | List indexed documents and concepts | +| `openkb status` | Show knowledge base stats | +| `openkb watch` | Watch `raw/` and auto-compile new files | +| `openkb lint` | Run structural and knowledge health checks |
More wiki commands:
-| Command | Description | -|---|---| -| openkb remove <doc> | Remove a document and clean up its wiki pages, images, registry, and PageIndex state (`--dry-run` to preview, `--keep-raw` / `--keep-empty` to retain artifacts) | +| Command | Description | +| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| openkb remove <doc> | Remove a document and clean up its wiki pages, images, registry, and PageIndex state (`--dry-run` to preview, `--keep-raw` / `--keep-empty` to retain artifacts) | | openkb recompile [<doc>] [--all] | Re-run the compile pipeline on already-indexed docs without re-indexing. Regenerates summaries and rewrites concept pages; manual edits are overwritten (`--dry-run` to preview, `--refresh-schema` to also update `wiki/AGENTS.md`) | -| openkb feedback ["msg"] | File feedback by opening a prefilled GitHub issue (`--type bug/feature/question` to tag it) | +| openkb feedback ["msg"] | File feedback by opening a prefilled GitHub issue (`--type bug/feature/question` to tag it) |
@@ -179,23 +191,23 @@ OpenKB commands fall into two layers: the **wiki foundation** (compile + manage A "generator" reads from the compiled wiki and produces something usable: an answer, a conversation, a skill folder. The wiki is the substrate; generators are the surfaces. -| Command | Output | Example | -|---|---|---| -| openkb query "question" | A grounded answer with citations (`--save` to persist to `wiki/explorations/`) | [query & save](examples/commands/) | -| openkb chat | Interactive multi-turn session over the wiki (`--resume`, `--list`, `--delete` to manage sessions) | [chat](examples/chat/) | -| openkb visualize | A self-contained interactive knowledge graph at `output/visualize/graph.html` — 3D, mind-map, and radial views | [visualize](examples/visualize/) | -| openkb skill new <skill-name> "<intent>" | Distill a redistributable agent skill from your wiki (see [Skill Factory](#skill-factory) below) | [skills](examples/skills/) | -| openkb deck new <name> "<intent>" | Generate a single-file HTML slide deck (`--skill` picks a theme, `--critique` runs a quality pass) | [slides](examples/slides/) | +| Command | Output | Example | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | ---------------------------------- | +| openkb query "question" | A grounded answer with citations (`--save` to persist to `wiki/explorations/`) | [query & save](examples/commands/) | +| openkb chat | Interactive multi-turn session over the wiki (`--resume`, `--list`, `--delete` to manage sessions) | [chat](examples/chat/) | +| openkb visualize | A self-contained interactive knowledge graph at `output/visualize/graph.html` — 3D, mind-map, and radial views | [visualize](examples/visualize/) | +| openkb skill new <skill-name> "<intent>" | Distill a redistributable agent skill from your wiki (see [Skill Factory](#skill-factory) below) | [skills](examples/skills/) | +| openkb deck new <name> "<intent>" | Generate a single-file HTML slide deck (`--skill` picks a theme, `--critique` runs a quality pass) | [slides](examples/slides/) |
More skill commands:
-| Command | Output | -|---|---| -| openkb skill validate [name] | Validate compiled skills (auto-runs after `skill new`) | -| openkb skill eval <name> | Check the skill triggers on the right prompts | -| openkb skill history <name> / openkb skill rollback <name> | Version history + rollback for skills | +| Command | Output | +| ------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------ | +| openkb skill validate [name] | Validate compiled skills (auto-runs after `skill new`) | +| openkb skill eval <name> | Check the skill triggers on the right prompts | +| openkb skill history <name> / openkb skill rollback <name> | Version history + rollback for skills |
@@ -299,19 +311,25 @@ gemini skills install https://github.com/VectifyAI/OpenKB.git --path skills/open The skill is read-only. It won't run `openkb add`, `remove`, or `lint --fix` without you asking. See [`skills/openkb/SKILL.md`](skills/openkb/SKILL.md) for the full instruction set. +# REST API + +OpenKB ships a FastAPI service for HTTP clients. Install with `pip install -e ".[api]"`, then start with `python -m openkb.api`. The interactive API reference is at [`/docs`](http://127.0.0.1:8000/docs) (importable into Postman). + +See the [full REST API reference](examples/rest-api/README.md#rest-api) for endpoints, auth, and SSE streaming. + # 🧭 Learn More ### Compared to Karpathy's Approach -| | Karpathy's workflow | OpenKB | -|---|---|---| -| Short documents | LLM reads directly | markitdown → LLM reads | -| Long documents | Context limits, context rot | PageIndex tree index | -| Input sources | Web clipper → .md | PDF, Word, PPT, Excel, HTML, text, CSV, .md, URLs | -| Wiki compilation | LLM agent | LLM agent (same) | -| Entity extraction | Manual | Automatic (people, orgs, places, products) | -| Q&A | Query over wiki | Wiki + PageIndex retrieval | -| Output | Wiki only | Wiki + Skill Factory + agent CLI integration | +| | Karpathy's workflow | OpenKB | +| ----------------- | --------------------------- | ------------------------------------------------- | +| Short documents | LLM reads directly | markitdown → LLM reads | +| Long documents | Context limits, context rot | PageIndex tree index | +| Input sources | Web clipper → .md | PDF, Word, PPT, Excel, HTML, text, CSV, .md, URLs | +| Wiki compilation | LLM agent | LLM agent (same) | +| Entity extraction | Manual | Automatic (people, orgs, places, products) | +| Q&A | Query over wiki | Wiki + PageIndex retrieval | +| Output | Wiki only | Wiki + Skill Factory + agent CLI integration | ### The Stack @@ -328,7 +346,7 @@ The skill is read-only. It won't run `openkb add`, `remove`, or `lint --fix` wit - [ ] Scale to large document collections with nested folder support - [ ] Hierarchical concept (topic) indexing for massive knowledge bases - [ ] Database-backed storage engine -- [ ] Web UI for browsing and managing wikis +- [x] Web UI for browsing and managing wikis (Knowledge Workbench, served at `/`) ### Contributing diff --git a/examples/rest-api/README.md b/examples/rest-api/README.md new file mode 100644 index 00000000..f7494b04 --- /dev/null +++ b/examples/rest-api/README.md @@ -0,0 +1,419 @@ +# REST API & Knowledge Workbench + +This guide covers the OpenKB REST API (FastAPI) and the bundled Knowledge Workbench web UI. + +> The interactive API reference is served live at [`/docs`](http://127.0.0.1:8000/docs) (OpenAPI/Swagger) once the server is running — you can import `/openapi.json` directly into Postman. + +## Knowledge Workbench (Web UI) + + +OpenKB ships a bundled React single-page app — the **Knowledge Workbench** — served directly by the REST server at `/`, so you get a full browser interface with no separate frontend process. + +```bash +# 1. build front web +cd frontend/ +npm install +npm run build + +# back project root +cd .. + +# 2. Set server variables (in .env or your shell) +OPENKB_API_TOKEN=test-token # bearer token the browser sends +OPENKB_KB_ROOT=/path/to/kbs # where REST /init creates KBs + +OR Edit .env and config.yaml + +# 3. Install with the API extra and start the server +pip install -e ".[api]" +python -m openkb.api --host 127.0.0.1 --port 8000 # serves the API + Workbench at http://127.0.0.1:8000/ +``` + +> **No build, no UI.** If you skip `npm run build`, the `web/` bundle is absent and `openkb-api` serves only the REST API under `/api/v1` — visiting `/` returns a 404. The Workbench web UI appears only after you build `frontend/` at least once. + +Open `http://127.0.0.1:8000/` in your browser. On first launch a **Connection Settings** dialog asks for the API base (leave blank for same-origin) and the bearer token. The Workbench then provides: + +- **Overview** — index/concept/summary/report stat cards, clickable concept chips, recent documents, and last-compile/lint activity. +- **Documents** — drag-and-drop multi-file upload with per-file SSE progress, hash table, and delete with confirmation. +- **Query** — streamed answers with `tool_call` reasoning shown live in the right-pane timeline; GFM Markdown rendering (bold, tables, code, etc.). +- **Chat** — multi-turn streaming with a persisted session list: load history, resume a session, delete it. +- **Maintenance** — lint (with optional auto-fix), recompile (all or single doc, SSE log), and a file-watcher toggle. + +A right-pane **Inspector** timeline shows the vectorless retrieval & reasoning steps for every streamed operation. Creating a new KB from the Workbench inherits the project-root `config.yaml` and LLM credentials (`.env`) so it runs queries out of the box. The UI is responsive — on narrow screens the three panes collapse to a single column with a hamburger nav. + + +## REST API + + +OpenKB also ships a FastAPI service for using a knowledge base from Postman, +frontends, or other HTTP clients. + +Install the API dependencies if needed: + +```bash +pip install -e ".[api]" +``` + +Start the API server: + +```powershell +$env:OPENKB_API_TOKEN="test-token" +$env:OPENKB_KB_ROOT="D:\project\OpenKB\kbs" +.\.venv\Scripts\python.exe -m openkb.api --host 127.0.0.1 --port 8000 +``` + +### Authentication and common behavior + +`OPENKB_API_TOKEN` is required. Send it on every request: + +```text +Authorization: Bearer test-token +``` + +`OPENKB_KB_ROOT` is optional. It controls where REST-created knowledge bases +are stored. If unset, OpenKB uses `~/.config/openkb/kbs`. + +REST clients identify a knowledge base with `kb`, not a filesystem path. For +example, `postman-kb` resolves to `$OPENKB_KB_ROOT/postman-kb`. + +Common status codes across all endpoints: + +- `200` — success. +- `400` — invalid request body, unknown `kb`, or a KB that isn't an OpenKB dir + (missing `.openkb/` or `wiki/`). +- `401` — missing or wrong bearer token. +- `404` — referenced document/watcher not found (remove/recompile/watch-stop). +- `409` — ambiguous identifier (remove/recompile) with `candidates` in `detail`. +- `500` — server error, or `OPENKB_API_TOKEN` not configured on the server. + +All JSON endpoints use `Content-Type: application/json`. `/api/v1/add` is the +only `multipart/form-data` endpoint. + +### Streaming (SSE) + +Endpoints that accept `"stream": true` (`query`, `chat`, `remove`, `recompile`), +plus `add` (via a `stream` form field) and `watch/events` (always), return +Server-Sent Events (`Content-Type: text/event-stream`). Each frame is: + +```text +event: +data: +``` + +SSE event names: + +- `start` — stream opened; `data` includes the `endpoint`. +- `delta` — incremental answer text (query/chat), `{"text": "..."}`. +- `tool_call` — agent tool invocation (query/chat), with the call details. +- `uploaded` / `file_start` / `file_done` — per-file progress (add). +- `plan` — execution plan (remove: full plan; recompile: target list). +- `progress` — stage progress (remove: `wiki_cleanup`). +- `doc` — one document recompiled (recompile stream). +- `final` — terminal success payload (matches the non-streaming JSON body). +- `error` — failure, `{"message": "..."}` (may carry `code` for remove). +- `done` — stream closed; always emitted last. + +### Endpoints + +All endpoints are under `/api/v1`. + +| Method | Path | Body | Streams | Purpose | +| ------ | ----------------------- | ------------ | -------- | -------------------------------------- | +| GET | `/kbs` | — | no | List knowledge bases under the KB root | +| POST | `/init` | JSON | no | Create a knowledge base | +| POST | `/add` | multipart | optional | Upload + compile documents | +| POST | `/query` | JSON | yes | Ask a one-shot question | +| POST | `/chat` | JSON | yes | Multi-turn chat session | +| POST | `/chat/sessions` | JSON | no | List persisted chat sessions | +| POST | `/chat/sessions/load` | JSON | no | Load a session's history | +| POST | `/chat/sessions/delete` | JSON | no | Delete a session | +| POST | `/list` | JSON | no | List documents, summaries, concepts | +| POST | `/status` | JSON | no | KB directory/index stats | +| POST | `/lint` | JSON | no | Structural + semantic lint report | +| POST | `/remove` | JSON | yes | Remove a document + cleanup | +| POST | `/recompile` | JSON | yes | Recompile one or all docs | +| POST | `/watch/start` | JSON | no | Start a filesystem watcher | +| POST | `/watch/stop` | JSON | no | Stop a watcher | +| POST | `/watch/status` | JSON | no | Watcher status + recent events | +| GET | `/watch/events` | query params | always | SSE feed of watcher events | + +#### Initialize a KB + +```http +POST /api/v1/init +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`InitRequest`): + +| Field | Type | Required | Default | Notes | +| ----------------- | ------ | -------- | ------- | ----------------------------- | +| `kb` | string | yes | — | new KB name | +| `model` | string | no | `null` | LLM model override | +| `api_key` | string | no | `null` | written to KB-local `.env` | +| `openai_api_base` | string | no | `null` | OpenAI-compatible gateway URL | + +`api_key` and `openai_api_base` are written to the KB-local `.env` when the KB +is created; secret values are not echoed back in the response. + +When `model`, `api_key`, and `openai_api_base` are all omitted (the Workbench's +default), the new KB inherits the operator's project-root `config.yaml` and LLM +credentials from the server's working-directory `.env` (server-level `OPENKB_*` +vars are filtered out), so a KB created from the UI runs queries out of the box. + +Response (`InitResponse`, `200`): `kb` (string), `created` (bool, `false` if the +KB already existed), `env_written` (`{api_key: bool, openai_api_base: bool}`), +`message` (string). Errors: `400` invalid/existing KB, `500` other. + +#### Add Documents + +```http +POST /api/v1/add +Authorization: Bearer test-token +``` + +`multipart/form-data`: + +| Field | Type | Value | +| -------- | ---- | --------------------- | +| `kb` | Text | `postman-kb` | +| `stream` | Text | `true` or `false` | +| `files` | File | one or more documents | + +Supported types match the CLI: `.pdf`, `.md`, `.markdown`, `.docx`, `.pptx`, +`.xlsx`, `.xls`, `.html`, `.htm`, `.txt`, `.csv`. `stream: true` emits per-file +SSE events (`uploaded`, `file_start`, `file_done`, `final`); `stream: false` +returns one JSON body. `400` if no files are uploaded. + +Response (`AddResponse`, `200`): `kb`, `files` (each +`{original_name, saved_path, status, message}`), `added_count`, `skipped_count` +(already indexed), `failed_count`. + +#### Query + +```http +POST /api/v1/query +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`QueryRequest`): + +| Field | Type | Required | Default | Notes | +| ---------- | ------ | -------- | ------- | ------------------------------------ | +| `kb` | string | yes | — | | +| `question` | string | yes | — | | +| `stream` | bool | no | `true` | SSE vs single JSON | +| `save` | bool | no | `false` | write answer to `wiki/explorations/` | + +Non-streaming response (`QueryResponse`, `200`): `answer` (string), `saved_path` +(string\|null, set when `save: true`). Stream events: `start`, `delta`, +`tool_call`, `final` (`{answer, saved_path}`), `error`, `done`. `500` on failure. + +#### Chat + +```http +POST /api/v1/chat +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`ChatRequest`): + +| Field | Type | Required | Default | Notes | +| ------------ | ------ | -------- | ------- | -------------------------- | +| `kb` | string | yes | — | | +| `message` | string | yes | — | | +| `session_id` | string | no | `null` | resume an existing session | +| `stream` | bool | no | `true` | SSE vs single JSON | + +Non-streaming response (`ChatResponse`, `200`): `session_id` (pass back to +continue), `answer`, `turn_count` (total turns in the session). Stream events: +`start` (includes `session_id`), `delta`, `tool_call`, `final`, `error`, `done`. + +#### Chat Sessions + +List, load, or delete persisted multi-turn sessions for a KB. These power the +session sidebar in the Workbench. + +```http +POST /api/v1/chat/sessions +Authorization: Bearer test-token +``` + +Request (`KbRequest`): `{ "kb": "postman-kb" }`. +Response (`ChatSessionListResponse`): `{ "kb", "sessions": [{ id, title, turn_count, updated_at, model }] }`, +ordered by `updated_at` descending. + +```http +POST /api/v1/chat/sessions/load +Authorization: Bearer test-token +``` + +Request (`ChatSessionLoadRequest`): `{ "kb", "session_id" }`. +Response (`ChatSessionLoadResponse`): `{ session_id, title, turn_count, user_turns: [...], assistant_texts: [...] }`. +Clients interleave `user_turns` and `assistant_texts` to render the history. +`404` if the session does not exist. + +```http +POST /api/v1/chat/sessions/delete +Authorization: Bearer test-token +``` + +Request (`ChatSessionDeleteRequest`): `{ "kb", "session_id" }`. +Response (`ChatSessionDeleteResponse`): `{ deleted: true }`. Returns `404` if not found. + +#### List + +```http +POST /api/v1/list +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`KbRequest`): `kb`. + +Response (`ListResponse`, `200`): `documents` +(`[{hash, name, type, display_type, pages}]`), `document_count`, `summaries`, +`concepts`, `reports` (lists of page names). + +#### Status + +```http +POST /api/v1/status +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`KbRequest`): `kb`. + +Response (`StatusResponse`, `200`): `directories` (per-folder file counts), +`raw_count`, `total_indexed`, `last_compile` and `last_lint` (ISO timestamps or +`null`). + +#### Lint + +```http +POST /api/v1/lint +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`LintRequest`): + +| Field | Type | Required | Default | Notes | +| ----- | ------ | -------- | ------- | ------------------------------------------ | +| `kb` | string | yes | — | | +| `fix` | bool | no | `false` | rewrite/strip broken `[[wikilinks]]` first | + +When `fix: true`, broken wikilinks are rewritten/stripped under the KB ingest +lock (mirroring `openkb lint --fix`) before the report runs, so the report +reflects the post-fix state. The semantic lint is a multi-turn LLM agent run, so +the response can take tens of seconds to minutes regardless of `fix`; the `fix` +pass itself is a local, millisecond file rewrite. + +Response (`LintResponse`, `200`): + +| Field | Type | Notes | +| --------------------- | ------------ | ------------------------------------------- | +| `skipped` | bool | `true` when no documents are indexed | +| `reason` | string\|null | e.g. `no_documents_indexed` | +| `message` | string | status + fix summary when `fix: true` | +| `structural_report` | string\|null | local structural lint markdown | +| `knowledge_report` | string\|null | LLM semantic lint markdown | +| `report_path` | string\|null | report under `wiki/reports/` | +| `lint_files_changed` | int\|null | files rewritten by `fix` (else `null`) | +| `lint_ghosts_removed` | int\|null | ghost links stripped by `fix` (else `null`) | + +#### Remove Documents + +Remove a document and clean up its wiki pages, images, registry, and PageIndex +state, the same pipeline as `openkb remove`. + +```http +POST /api/v1/remove +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`RemoveRequest`): + +| Field | Type | Required | Default | Notes | +| ------------ | ------ | -------- | ------- | --------------------------------------- | +| `kb` | string | yes | — | | +| `identifier` | string | yes | — | filename, `doc_name` slug, or substring | +| `keep_raw` | bool | no | `false` | keep the source file | +| `keep_empty` | bool | no | `false` | keep now-empty concept/entity pages | +| `dry_run` | bool | no | `false` | preview only | +| `stream` | bool | no | `false` | SSE vs single JSON | + +Response (`RemoveResponse`, `200`): `status` (`removed`, `partial`, `dry_run`), +`name`, `doc_name`, `actions` (each `{tag, target}`), `concepts_deleted`, +`entities_deleted`, `lint_files_changed` and `lint_ghosts_removed` (scoped +`lint --fix` counts after removal), `pageindex_message`/`pageindex_error`, +`message`, `candidates`. Errors: `404` no match, `409` multiple matches (with +`candidates`). Stream events: `start`, `plan`, `progress`, `final`, `error`, +`done`. + +#### Recompile + +Re-compile one or all documents, mirroring `openkb recompile`. + +```http +POST /api/v1/recompile +Content-Type: application/json +Authorization: Bearer test-token +``` + +Request (`RecompileRequest`): + +| Field | Type | Required | Default | Notes | +| ---------------- | ------ | -------- | ------- | --------------------------------- | +| `kb` | string | yes | — | | +| `doc_name` | string | no | `null` | one doc; omit with `all_docs` | +| `all_docs` | bool | no | `false` | recompile every document | +| `dry_run` | bool | no | `false` | preview only | +| `refresh_schema` | bool | no | `false` | re-extract PageIndex schema first | +| `stream` | bool | no | `false` | SSE vs single JSON | + +Response (`RecompileResponse`, `200`): `status` (`done`), `total`, `recompiled`, +`skipped`, `docs` (each `{name, doc_name, type, status, elapsed, message}`), +`targets`/`candidates` (present for plans/ambiguity). Errors: `404` no match, +`409` ambiguous (with `candidates`), `500` otherwise. Stream events: `start`, +`plan` (`{targets}`), `doc` (per-document result), `final`, `error`, `done`. + +#### Watch (auto-compile on file change) + +Start/stop/inspect a filesystem watcher that auto-compiles files dropped into +`raw/` (same as `openkb watch`), plus an SSE feed of watcher events. + +```http +POST /api/v1/watch/start +POST /api/v1/watch/stop +POST /api/v1/watch/status +GET /api/v1/watch/events +Content-Type: application/json +Authorization: Bearer test-token +``` + +`watch/start` (`WatchStartRequest`): `kb`, `debounce` (seconds, default `2.0`, +must be `> 0`). `watch/stop` and `watch/status` take only `kb`. + +```json +{ "kb": "postman-kb", "debounce": 2.0 } +``` + +All three return `WatchStatusResponse`: `kb`, `active` (bool), `started_at` +(epoch or `null`), `raw_dir`, `debounce`, `counters` (`{added, updated, failed, +...}`), `recent_events` (`[{ts, event, data}]`). `watch/stop` returns `404` if +no watcher is active for that KB. + +`GET /api/v1/watch/events` is always SSE. Query params: `kb` (required), +`max_events` (int, `>=1`, stop after N events), `timeout_seconds` (float, `>=0`, +stop after this many seconds). Stream events: `start`, the watcher's own events +(e.g. `added`, `updated`, `failed`, `final`), `error`, `done`. + +The full OpenAPI schema is at [`/openapi.json`](http://127.0.0.1:8000/openapi.json) — importable into Postman or any OpenAPI-compatible client. + + diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 00000000..804bd4a4 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,3 @@ +node_modules +dist +*.local diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 00000000..dca79a9e --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + OpenKB · Knowledge Workbench + + + +
+ + + diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 00000000..35230a48 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,3414 @@ +{ + "name": "openkb-web", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "openkb-web", + "version": "0.1.0", + "dependencies": { + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "rehype-highlight": "^7.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.2.tgz", + "integrity": "sha512-5jsZFwgR5rTdKwidH9Qmat75RKwqfpKlWWB1frDkljN127mwqBu8K0PYo7/hFpF03IEJpfVPpCQDY/eDx3iHvA==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.40", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", + "integrity": "sha512-BSSLZ9/Cjjv7Gtj5B68ZzXcXUg8iOf3fme+FCuh8rC/Go+Kmh8cox7M3A8dolou16s64QjLPOSdngh7GxXvkSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", + "integrity": "sha512-MTc8i/x9jBQd1iMw2CFGS+rwMa07eYjLR0CCTLDACl9xhxy+nIs3KeML/biicXtk9JrZ6dnnTatmc7ErPXIxqw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.38", + "caniuse-lite": "^1.0.30001799", + "electron-to-chromium": "^1.5.376", + "node-releases": "^2.0.48", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT", + "peer": true + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.380", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.380.tgz", + "integrity": "sha512-W6d5AbuEoRayO447cqrg6lKJIlscgRnnxOZl/08kfV71BQDoEBC7Wwis68z87LjyK6f4kWyTaubuDbhHKrZkbA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.469.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.469.0.tgz", + "integrity": "sha512-28vvUnnKQ/dBwiCQtwJw7QauYnE7yd2Cyp4tTTJpvglX4EMpbflcdBgrgToX2j71B3YvugK/NH3BGUk+E/p/Fw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.50", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", + "integrity": "sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/property-information": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", + "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-markdown": { + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-9.1.0.tgz", + "integrity": "sha512-xaijuJB0kzGiUdG7nc2MOMDUDBWPyGAjZtUrow9XxUeua8IqeP+VlIfAZ3bphpcLTnSZXz6z9jcVC/TCwbfgdw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rehype-highlight": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/rehype-highlight/-/rehype-highlight-7.0.2.tgz", + "integrity": "sha512-k158pK7wdC2qL3M5NcZROZ2tR/l7zOzjxXd5VGdcfIyoijjQqpHd3JKtYSBDpDZ38UI2WJWuFAtkMDxmx5kstA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-to-text": "^4.0.0", + "lowlight": "^3.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 00000000..27a62031 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,23 @@ +{ + "name": "openkb-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.469.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-markdown": "^9.0.1", + "rehype-highlight": "^7.0.1", + "remark-gfm": "^4.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.4", + "vite": "^6.0.7" + } +} diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx new file mode 100644 index 00000000..cf01ed72 --- /dev/null +++ b/frontend/src/App.jsx @@ -0,0 +1,88 @@ +import { useEffect, useCallback } from "react"; +import { Menu } from "lucide-react"; +import { AppProvider, useApp } from "./state/AppContext.jsx"; +import { I18nProvider, useI18n } from "./i18n.jsx"; +import { api, hasConnection } from "./api/client.js"; +import Sidebar from "./components/Sidebar.jsx"; +import Inspector from "./components/Inspector.jsx"; +import SettingsModal from "./components/SettingsModal.jsx"; +import Toast from "./components/Toast.jsx"; +import Overview from "./views/Overview.jsx"; +import Documents from "./views/Documents.jsx"; +import Query from "./views/Query.jsx"; +import Chat from "./views/Chat.jsx"; +import Maintenance from "./views/Maintenance.jsx"; + +function Shell() { + const { kb, view, setKbs, setKb, kbs, setSidebarOpen, setSettingsOpen, toastMsg } = useApp(); + const { t, lang } = useI18n(); + + const loadKbs = useCallback(async () => { + if (!hasConnection()) return; + try { + const data = await api.listKbs(); + setKbs(data.knowledge_bases || []); + setKb((prev) => prev || (data.knowledge_bases?.[0]?.name ?? null)); + } catch { + setKbs([]); + } + }, [setKbs, setKb]); + + useEffect(() => { + loadKbs(); + function onReload() { loadKbs(); } + function onToast(e) { toastMsg(e.detail.message, e.detail.kind); } + function onUnauthorized() { setSettingsOpen(true); } + window.addEventListener("openkb:reload-kbs", onReload); + window.addEventListener("openkb:toast", onToast); + window.addEventListener("openkb:unauthorized", onUnauthorized); + return () => { + window.removeEventListener("openkb:reload-kbs", onReload); + window.removeEventListener("openkb:toast", onToast); + window.removeEventListener("openkb:unauthorized", onUnauthorized); + }; + }, [loadKbs, toastMsg, setSettingsOpen]); + + // Keep the browser tab title in sync with the active language. + useEffect(() => { + document.title = t("appTitle"); + }, [lang, t]); + + function renderView() { + if (!kb) return

{t("noKbSelected")}

{t("selectOrCreate")}

; + if (view === "overview") return ; + if (view === "documents") return ; + if (view === "query") return ; + if (view === "chat") return ; + if (view === "maintenance") return ; + return null; + } + + return ( +
+ + +
+
+

{t(view)}

+
+
{renderView()}
+
+ + + +
+ ); +} + +export default function App() { + return ( + + + + + + ); +} diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js new file mode 100644 index 00000000..0704044d --- /dev/null +++ b/frontend/src/api/client.js @@ -0,0 +1,116 @@ +// Lightweight API client. Reads base URL + token from localStorage so the SPA +// can talk to the OpenKB REST API after the user configures the connection. + +const LS_BASE = "openkb_api_base"; +const LS_TOKEN = "openkb_token"; + +export function getApiBase() { + return localStorage.getItem(LS_BASE) || ""; +} + +export function getToken() { + return localStorage.getItem(LS_TOKEN) || ""; +} + +export function setConnection(apiBase, token) { + localStorage.setItem(LS_BASE, apiBase); + localStorage.setItem(LS_TOKEN, token); +} + +export function hasConnection() { + return !!getToken(); +} + +// Resolve the API base. In dev (Vite proxy) the relative "/api" paths are +// proxied; otherwise use the configured absolute base. Falls back to same-origin. +export function baseUrl() { + return getApiBase().replace(/\/$/, ""); +} + +// Warn when the configured base is cross-origin and not HTTPS. The bearer +// token is attached to every request, so a typo'd or malicious base would +// leak the token to an attacker-controlled host. This is advisory only — +// same-origin (empty base, the production mount at /) is always safe. +let _baseWarned = false; +export function isBaseUrlSafe() { + const base = baseUrl(); + if (!base) return true; // same-origin, always safe + try { + const url = new URL(base, window.location.origin); + if (url.origin === window.location.origin) return true; + return url.protocol === "https:"; + } catch { + return false; + } +} + +export function checkBaseSafety() { + if (_baseWarned) return; + const base = baseUrl(); + if (!base) return; // same-origin + try { + const url = new URL(base, window.location.origin); + if (url.origin !== window.location.origin && url.protocol !== "https:") { + console.warn( + `[OpenKB] API base "${base}" is cross-origin and non-HTTPS. ` + + "Your API token will be sent to this host on every request. " + + "Verify this URL is trusted before proceeding.", + ); + _baseWarned = true; + } + } catch { + // invalid URL — will fail at fetch time + } +} + +// Surface 401s to the UI so the connection modal can reopen for re-entry. +export function notifyUnauthorized() { + window.dispatchEvent(new CustomEvent("openkb:unauthorized")); +} + +export async function request(path, { method = "GET", body, headers } = {}) { + const token = getToken(); + const finalHeaders = { + ...(body && !(body instanceof FormData) ? { "Content-Type": "application/json" } : {}), + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...headers, + }; + const init = { method, headers: finalHeaders }; + if (body !== undefined) init.body = body instanceof FormData ? body : JSON.stringify(body); + checkBaseSafety(); + const res = await fetch(baseUrl() + path, init); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const j = await res.json(); + msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail || j); + } catch { + // keep default message + } + const err = new Error(msg); + err.status = res.status; + if (res.status === 401) notifyUnauthorized(); + throw err; +} + const ct = res.headers.get("content-type") || ""; + if (ct.includes("application/json")) return res.json(); + return res.text(); +} + +export const api = { + listKbs: () => request("/api/v1/kbs"), + initKb: (kb) => request("/api/v1/init", { method: "POST", body: { kb } }), + status: (kb) => request("/api/v1/status", { method: "POST", body: { kb } }), + list: (kb) => request("/api/v1/list", { method: "POST", body: { kb } }), + lint: (kb, fix) => request("/api/v1/lint", { method: "POST", body: { kb, fix } }), + watchStatus: (kb) => request("/api/v1/watch/status", { method: "POST", body: { kb } }), + watchStart: (kb, debounce) => + request("/api/v1/watch/start", { method: "POST", body: { kb, debounce } }), + watchStop: (kb) => request("/api/v1/watch/stop", { method: "POST", body: { kb } }), + chatSessions: (kb) => + request("/api/v1/chat/sessions", { method: "POST", body: { kb } }), + chatSessionLoad: (kb, sessionId) => + request("/api/v1/chat/sessions/load", { method: "POST", body: { kb, session_id: sessionId } }), + chatSessionDelete: (kb, sessionId) => + request("/api/v1/chat/sessions/delete", { method: "POST", body: { kb, session_id: sessionId } }), +}; diff --git a/frontend/src/api/sse.js b/frontend/src/api/sse.js new file mode 100644 index 00000000..ecc07278 --- /dev/null +++ b/frontend/src/api/sse.js @@ -0,0 +1,103 @@ +// SSE streaming over fetch (EventSource cannot set Authorization headers). +// Parses `event:` / `data:` blocks from a ReadableStream and invokes handlers. + +import { baseUrl, getToken, notifyUnauthorized, checkBaseSafety } from "./client.js"; + +// Parse raw text buffer into complete SSE blocks, returning [events, remainder]. +function parseBuffer(buf) { + const blocks = buf.split("\n\n"); + const remainder = blocks.pop(); + const events = []; + for (const blk of blocks) { + const evMatch = blk.match(/^event:\s*(.+)$/m); + const daMatch = blk.match(/^data:\s*(.+)$/m); + if (evMatch && daMatch) { + let data; + try { + data = JSON.parse(daMatch[1]); + } catch { + data = { raw: daMatch[1] }; + } + events.push({ event: evMatch[1].trim(), data }); + } + } + return [events, remainder]; +} + +async function readStream(res, onEvent) { + const reader = res.body.getReader(); + const dec = new TextDecoder(); + let buf = ""; + while (true) { + const { value, done } = await reader.read(); + if (done) break; + buf += dec.decode(value, { stream: true }); + const [events, remainder] = parseBuffer(buf); + buf = remainder; + for (const { event, data } of events) { + if (onEvent(event, data) === false) return; + } + } +} + +// Build headers with token. JSON bodies are stringified; FormData passed through. +function buildHeaders(body, extra = {}) { + const token = getToken(); + const isForm = body instanceof FormData; + return { + ...(body && !isForm ? { "Content-Type": "application/json" } : {}), + Accept: "text/event-stream", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + ...extra, + }; +} + +// Stream a JSON-POST endpoint. Returns an AbortController-like handle via opts.signal. +export async function streamSSE(path, payload, onEvent, opts = {}) { + checkBaseSafety(); + const res = await fetch(baseUrl() + path, { + method: "POST", + headers: buildHeaders(payload), + body: JSON.stringify(payload), + signal: opts.signal, + }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const j = await res.json(); + msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail || j); + } catch { + // keep default + } + const err = new Error(msg); + err.status = res.status; + if (res.status === 401) notifyUnauthorized(); + throw err; +} +await readStream(res, onEvent); +} + +// Stream a multipart upload (FormData) endpoint. +export async function streamUpload(path, form, onEvent, opts = {}) { + checkBaseSafety(); + const res = await fetch(baseUrl() + path, { + method: "POST", + headers: buildHeaders(form), + body: form, + signal: opts.signal, + }); + if (!res.ok) { + let msg = `${res.status} ${res.statusText}`; + try { + const j = await res.json(); + msg = typeof j.detail === "string" ? j.detail : JSON.stringify(j.detail || j); + } catch { + // keep default + } + const err = new Error(msg); + err.status = res.status; + if (res.status === 401) notifyUnauthorized(); + throw err; +} +await readStream(res, onEvent); +} diff --git a/frontend/src/components/EmptyState.jsx b/frontend/src/components/EmptyState.jsx new file mode 100644 index 00000000..e6bc5f73 --- /dev/null +++ b/frontend/src/components/EmptyState.jsx @@ -0,0 +1,9 @@ +export default function EmptyState({ title, desc, icon }) { + return ( +
+ {icon || null} + {title &&

{title}

} + {desc &&

{desc}

} +
+ ); +} diff --git a/frontend/src/components/Inspector.jsx b/frontend/src/components/Inspector.jsx new file mode 100644 index 00000000..70bdf1e5 --- /dev/null +++ b/frontend/src/components/Inspector.jsx @@ -0,0 +1,36 @@ +import { useEffect, useRef } from "react"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; + +export default function Inspector() { + const { inspItems, inspBusy } = useApp(); + const { t } = useI18n(); + const bodyRef = useRef(null); + + useEffect(() => { + if (bodyRef.current) bodyRef.current.scrollTop = bodyRef.current.scrollHeight; + }, [inspItems]); + + return ( + + ); +} diff --git a/frontend/src/components/Markdown.jsx b/frontend/src/components/Markdown.jsx new file mode 100644 index 00000000..b9e54193 --- /dev/null +++ b/frontend/src/components/Markdown.jsx @@ -0,0 +1,26 @@ +import { memo } from "react"; +import ReactMarkdown from "react-markdown"; +import remarkGfm from "remark-gfm"; +import rehypeHighlight from "rehype-highlight"; + +// Replaces the hand-rolled regex renderer (which dropped **bold**, tables, +// blockquotes, etc.). react-markdown + remark-gfm covers the full GFM spec +// (bold/italic/strikethrough/tables/task-lists/autolinks); rehype-highlight +// adds syntax highlighting to fenced code blocks. +const MarkdownImpl = ({ children }) => { + return ( + + ); +}; + +export default memo(MarkdownImpl); diff --git a/frontend/src/components/SettingsModal.jsx b/frontend/src/components/SettingsModal.jsx new file mode 100644 index 00000000..d0956a31 --- /dev/null +++ b/frontend/src/components/SettingsModal.jsx @@ -0,0 +1,54 @@ +import { useState, useEffect } from "react"; +import { X } from "lucide-react"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; + +export default function SettingsModal() { + const { settingsOpen, setSettingsOpen, saveConnection, apiBase, token, toastMsg } = useApp(); + const { t } = useI18n(); + const [base, setBase] = useState(apiBase); + const [tok, setTok] = useState(token); + + useEffect(() => { + setBase(apiBase); + setTok(token); + }, [apiBase, token, settingsOpen]); + + if (!settingsOpen) return null; + + function handleSave() { + const cleaned = (base || "").trim().replace(/\/$/, ""); + saveConnection(cleaned, tok); + setSettingsOpen(false); + toastMsg(t("connect"), "ok"); + window.dispatchEvent(new CustomEvent("openkb:reload-kbs")); + } + + return ( +
setSettingsOpen(false)}> +
e.stopPropagation()}> +
+

{t("settings")}

+ +
+
+ + +

{t("insecureWarn")}

+
+
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx new file mode 100644 index 00000000..6263bd5d --- /dev/null +++ b/frontend/src/components/Sidebar.jsx @@ -0,0 +1,106 @@ +import { useState, useRef, useEffect } from "react"; +import { LayoutGrid, FileText, Search, MessageSquare, Wrench, Settings, Plus, ChevronDown, Languages } from "lucide-react"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; +import { api } from "../api/client.js"; + +const NAV = [ + { view: "overview", icon: LayoutGrid }, + { view: "documents", icon: FileText }, + { view: "query", icon: Search }, + { view: "chat", icon: MessageSquare }, + { view: "maintenance", icon: Wrench }, +]; + +export default function Sidebar() { + const { kbs, kb, setKb, view, setView, setSettingsOpen, sidebarOpen, setSidebarOpen, toastMsg } = useApp(); + const { t, toggleLang, lang } = useI18n(); + const [menuOpen, setMenuOpen] = useState(false); + const [creating, setCreating] = useState(false); + const menuRef = useRef(null); + + useEffect(() => { + function onClick(e) { + if (menuRef.current && !menuRef.current.contains(e.target)) setMenuOpen(false); + } + document.addEventListener("mousedown", onClick); + return () => document.removeEventListener("mousedown", onClick); + }, []); + + async function handleCreate() { + const name = window.prompt(t("newKbPrompt")); + if (!name) return; + setCreating(true); + try { + await api.initKb(name.trim()); + setKb(name.trim()); + setMenuOpen(false); + window.dispatchEvent(new CustomEvent("openkb:reload-kbs")); + toastMsg(t("created") + ": " + name.trim(), "ok"); + } catch (e) { + toastMsg(e.message, "err"); + } finally { + setCreating(false); + } + } + + return ( + <> + {sidebarOpen &&
setSidebarOpen(false)} />} + + + ); +} diff --git a/frontend/src/components/Spinner.jsx b/frontend/src/components/Spinner.jsx new file mode 100644 index 00000000..3549b1fb --- /dev/null +++ b/frontend/src/components/Spinner.jsx @@ -0,0 +1,6 @@ +import { useI18n } from "../i18n.jsx"; + +export default function Spinner({ size = 18 }) { + const { t } = useI18n(); + return ; +} diff --git a/frontend/src/components/Toast.jsx b/frontend/src/components/Toast.jsx new file mode 100644 index 00000000..89b30d31 --- /dev/null +++ b/frontend/src/components/Toast.jsx @@ -0,0 +1,7 @@ +import { useApp } from "../state/AppContext.jsx"; + +export default function Toast() { + const { toast } = useApp(); + if (!toast) return null; + return
{toast.message}
; +} diff --git a/frontend/src/hooks/useSSEStream.js b/frontend/src/hooks/useSSEStream.js new file mode 100644 index 00000000..a9bb397e --- /dev/null +++ b/frontend/src/hooks/useSSEStream.js @@ -0,0 +1,66 @@ +// Unified SSE lifecycle hook: a single busy state + abortable streams. +// start() forwards every SSE event to onEvent and surfaces fetch/network +// errors as a synthetic {error} event so callers only handle one shape. +// stop() aborts the in-flight request via AbortController; onAbort fires +// so the caller can flip a pending bubble to a settled state. + +import { useCallback, useEffect, useRef, useState } from "react"; +import { streamSSE, streamUpload } from "../api/sse.js"; +import { useI18n } from "../i18n.jsx"; + +export function useSSEStream() { + const [busy, setBusy] = useState(false); + const ctrlRef = useRef(null); + const { t } = useI18n(); + + // Abort any in-flight stream when the component using this hook unmounts. + // Without this, leaving a Chat/Query view mid-stream keeps the fetch alive + // and fires setMsgs/inspector callbacks after unmount, writing deltas for + // the old KB/session captured in the start() closure. + useEffect(() => { + return () => { + if (ctrlRef.current) { + ctrlRef.current.abort(); + ctrlRef.current = null; + } + }; + }, []); + + const stop = useCallback(() => { + if (ctrlRef.current) { + ctrlRef.current.abort(); + ctrlRef.current = null; + } + }, []); + + const start = useCallback(async (cfg, onEvent, onAbort) => { + // If a prior stream is somehow still in-flight, abort it before starting + // a new one instead of silently dropping the call. + if (ctrlRef.current) { + ctrlRef.current.abort(); + ctrlRef.current = null; + } + const ctrl = new AbortController(); + ctrlRef.current = ctrl; + setBusy(true); + try { + if (cfg.form) { + await streamUpload(cfg.path, cfg.form, onEvent, { signal: ctrl.signal }); + } else { + await streamSSE(cfg.path, cfg.payload, onEvent, { signal: ctrl.signal }); + } + } catch (err) { + if (ctrl.signal.aborted) { + // User-initiated stop; do not surface as an error. + if (onAbort) onAbort(); + } else { + onEvent("error", { message: err?.message || t("requestFailed") }); + } + } finally { + ctrlRef.current = null; + setBusy(false); + } + }, [t]); + + return { busy, start, stop }; +} diff --git a/frontend/src/i18n.jsx b/frontend/src/i18n.jsx new file mode 100644 index 00000000..81e43710 --- /dev/null +++ b/frontend/src/i18n.jsx @@ -0,0 +1,192 @@ +// Lightweight i18n: two languages (en default, zh) with a localStorage toggle. +// Every user-visible string lives in the STRINGS map below. Components call +// useI18n() to get a `t(key)` function bound to the current language. + +import { createContext, useContext, useCallback, useState } from "react"; + +const LS_LANG = "openkb_lang"; + +const STRINGS = { + // Sidebar / nav + overview: { en: "Overview", zh: "概述" }, + documents: { en: "Documents", zh: "文档" }, + query: { en: "Query", zh: "查询" }, + chat: { en: "Chat", zh: "对话" }, + maintenance: { en: "Maintenance", zh: "维护" }, + noKbSelected: { en: "No KB selected", zh: "未选择知识库" }, + selectOrCreate: { en: "Select or create a knowledge base from the sidebar.", zh: "请在左侧选择或新建一个知识库。" }, + noKbs: { en: "No knowledge bases yet", zh: "暂无知识库" }, + docs: { en: "docs", zh: "篇" }, + newKb: { en: "New KB", zh: "新建知识库" }, + newKbPrompt: { en: "New KB name (letters/numbers/-/_):", zh: "新知识库名称(字母/数字/下划线/连字符):" }, + created: { en: "Created", zh: "已创建" }, + connectionSettings: { en: "Connection settings", zh: "连接设置" }, + menu: { en: "Menu", zh: "菜单" }, + appTitle: { en: "OpenKB · Knowledge Workbench", zh: "OpenKB · 知识工作台" }, + // Documents + dragDrop: { en: "Drag files here, or click to browse", zh: "拖拽文件到此处,或点击选择" }, + supportedTypes: { en: "Supported: PDF, DOCX, MD, TXT, and more", zh: "支持:PDF、DOCX、MD、TXT 等" }, + docName: { en: "Name", zh: "名称" }, + docStatus: { en: "Status", zh: "状态" }, + docType: { en: "Type", zh: "类型" }, + docActions: { en: "Actions", zh: "操作" }, + delete: { en: "Delete", zh: "删除" }, + confirmDelete: { en: "Delete this document?", zh: "删除此文档?" }, + uploadSuccess: { en: "Upload complete", zh: "上传完成" }, + // Query / Chat + askKb: { en: "Ask the knowledge base", zh: "向知识库提问" }, + vectorlessRetrieval: { en: "Reasoning-based retrieval", zh: "基于无向量推理检索" }, + answerWithReasoning: { en: "Answer with retrieval timeline", zh: "答案附推理过程" }, + typeQuestion: { en: "Ask anything...", zh: "例如:这篇文章的主要结论是什么?" }, + typeToContinue: { en: "Continue the conversation...", zh: "继续对话…" }, + you: { en: "You", zh: "你" }, + multiTurn: { en: "Multi-turn chat", zh: "多轮对话" }, + chatPersist: { en: "Sessions persist and can be resumed.", zh: "会话自动持久化,可跨次恢复。" }, + newSession: { en: "New session", zh: "本次新会话" }, + retrieve: { en: "Retrieval", zh: "检索" }, + turnCount: { en: "Turn {n}", zh: "第 {n} 轮" }, + stopGeneration: { en: "Stop", zh: "停止生成" }, + send: { en: "Send", zh: "发送" }, + ask: { en: "Ask", zh: "提问" }, + stopped: { en: "Stopped", zh: "已停止" }, + userInterrupted: { en: "User interrupted", zh: "用户中断生成" }, + deleteSession: { en: "Delete this session?", zh: "删除此会话?" }, + deleted: { en: "Deleted", zh: "已删除" }, + completed: { en: "Done", zh: "完成" }, + turn: { en: "turn", zh: "轮" }, + error: { en: "Error", zh: "错误" }, + reasoningDone: { en: "Retrieval complete", zh: "推理检索结束" }, + // Maintenance + runLint: { en: "Run Lint", zh: "运行检查" }, + lintFix: { en: "Auto-fix", zh: "自动修复" }, + recompileAll: { en: "Recompile All", zh: "全部重编译" }, + recompile: { en: "Recompile", zh: "重编译" }, + watcher: { en: "File Watcher", zh: "文件监听" }, + start: { en: "Start", zh: "启动" }, + stop: { en: "Stop", zh: "停止" }, + lintResults: { en: "Lint Results", zh: "检查结果" }, + structuralReport: { en: "Structural Report", zh: "结构检查报告" }, + knowledgeReport: { en: "Knowledge Report", zh: "知识检查报告" }, + noReports: { en: "No lint reports yet.", zh: "暂无检查报告。" }, + // Overview + documentsCol: { en: "Documents", zh: "文档" }, + concepts: { en: "Concepts", zh: "概念" }, + summaries: { en: "Summaries", zh: "摘要" }, + recentDocs: { en: "Recent Documents", zh: "最近文档" }, + activity: { en: "Activity", zh: "活动" }, + lastCompile: { en: "Last compile", zh: "最近编译" }, + clickToQuery: { en: "Click to query", zh: "点击提问" }, + // Inspector + inspectorTitle: { en: "Reasoning Timeline", zh: "推理时间线" }, + inspBusy: { en: "Reasoning…", zh: "推理中…" }, + inspIdle: { en: "Idle", zh: "空闲" }, + inspEmptyHint: { en: "After a query or chat, the vectorless retrieval and reasoning steps appear here in real time.", zh: "发起查询或对话后,无向量检索与推理过程将在此实时呈现。" }, + startRetrieval: { en: "Started retrieval", zh: "启动推理检索" }, + toolCall: { en: "Tool", zh: "工具" }, + inspect: { en: "Inspect", zh: "检视" }, + // Settings + settings: { en: "Connection", zh: "连接" }, + apiBase: { en: "API Base URL", zh: "API 地址" }, + apiToken: { en: "API Token", zh: "API 令牌" }, + connect: { en: "Connect", zh: "连接" }, + cancel: { en: "Cancel", zh: "取消" }, + tokenRequired: { en: "Token is required.", zh: "请填写令牌。" }, + insecureWarn: { en: "Warning: non-HTTPS cross-origin URL will send your token unencrypted.", zh: "警告:非 HTTPS 跨域地址将以明文发送令牌。" }, + + // Documents extended + uploading: { en: "Uploading", zh: "上传中" }, + compiling: { en: "Compiling", zh: "编译中" }, + added: { en: "Added", zh: "已添加" }, + uploadFailed: { en: "Upload failed", zh: "上传失败" }, + failed: { en: "Failed", zh: "失败" }, + processing: { en: "Processing", zh: "处理" }, + deletedDoc: { en: "Deleted", zh: "已删除" }, + deleteFailed: { en: "Delete failed", zh: "删除失败" }, + confirmDeleteDoc: { en: "Delete document and clean its wiki pages?", zh: "确定删除文档并清理其 wiki 页面?" }, + plan: { en: "Plan", zh: "计划" }, + refresh: { en: "Refresh", zh: "刷新" }, + noDocsYet: { en: "No documents yet", zh: "暂无文档" }, + uploadHint: { en: "Files will be compiled into wiki automatically.", zh: "上传文件后将自动编译为 wiki。" }, + dragOrClick: { en: "Drag files or click to upload", zh: "拖入文件或点击上传" }, + uploadProgress: { en: "Upload Progress", zh: "上传进度" }, + indexedDocs: { en: "Indexed Documents", zh: "已索引文档" }, + pages: { en: "Pages", zh: "页数" }, + hash: { en: "Hash", zh: "哈希" }, + flowDone: { en: "Compile flow finished", zh: "编译流程结束" }, + // Maintenance extended + running: { en: "Running", zh: "运行中" }, + autoFixSuffix: { en: "(auto-fix)", zh: "(自动修复)" }, + skipped: { en: "Skipped", zh: "已跳过" }, + filesChanged: { en: "Files changed", zh: "修改文件" }, + ghostsRemoved: { en: "Ghost links cleaned", zh: "清理幽灵链接" }, + lintComplete: { en: "Lint complete", zh: "检查完成" }, + recompiling: { en: "Recompiling...", zh: "重编译中…" }, + targets: { en: "Targets", zh: "目标" }, + recompileDone: { en: "Recompile done", zh: "重编译完成" }, + recompiled: { en: "Recompiled", zh: "重编译" }, + watcherOn: { en: "Watcher started", zh: "已开启监听" }, + watcherOff: { en: "Watcher stopped", zh: "已停止监听" }, + healthLint: { en: "Health Check - Lint", zh: "健康检查 · Lint" }, + lintDesc: { en: "Checks structural integrity and knowledge consistency. Can auto-fix broken wikilinks.", zh: "检测结构完整性与知识一致性,可自动修复失效的 wikilink。" }, + recompileSection: { en: "Recompile", zh: "重新编译 · Recompile" }, + recompileDesc: { en: "Re-run compilation on indexed documents. Regenerates summaries and rewrites concept pages (manual edits will be overwritten).", zh: "对已索引文档重跑编译,重生成摘要并改写概念页(手动编辑会被覆盖)。" }, + scope: { en: "Scope", zh: "范围" }, + allDocs: { en: "All documents", zh: "全部文档" }, + oneDoc: { en: "Specific document", zh: "指定文档" }, + noDocOption: { en: "(no documents)", zh: "(无文档)" }, + startRecompile: { en: "Start Recompile", zh: "开始重编译" }, + watchSection: { en: "File Watch", zh: "文件监听 · Watch" }, + watchDesc: { en: "Watches raw/ directory. New files are compiled into wiki automatically.", zh: "监听 raw/ 目录,新增文件自动编译为 wiki。" }, + watchStatus: { en: "Watcher status", zh: "监听状态" }, + kbStatus: { en: "Knowledge Base Status", zh: "知识库状态" }, + kbStatusDesc: { en: "Directory structure and index overview.", zh: "目录结构与索引概况。" }, + rawFiles: { en: "Raw files", zh: "原始文件" }, + indexed: { en: "Indexed", zh: "已索引" }, + lastLint: { en: "Last lint", zh: "上次检查" }, + autoFixLabel: { en: "Auto-fix", zh: "自动修复" }, + // Additional keys for full i18n coverage + askKbDesc: { en: "Vectorless reasoning-based retrieval; answers include the reasoning timeline.", zh: "基于无向量推理检索,答案附推理过程。" }, + prefillTemplate: { en: 'What is "{concept}"? Please explain based on the knowledge base.', zh: '什么是「{concept}」?请基于知识库解释。' }, + noDocsYetDesc: { en: "Go to the Documents page to add files and start compiling.", zh: "去「文档」页添加文件开始编译。" }, + apiBasePlaceholder: { en: "Leave blank for same-origin (e.g. http://127.0.0.1:8000)", zh: "留空则同源访问(如 http://127.0.0.1:8000)" }, + save: { en: "Save", zh: "保存" }, + loading: { en: "Loading", zh: "加载中" }, + requestFailed: { en: "Request failed", zh: "请求失败" }, +}; + +export function getStoredLang() { + return localStorage.getItem(LS_LANG) || "en"; +} + +export function storeLang(lang) { + localStorage.setItem(LS_LANG, lang); +} + +const I18nContext = createContext(null); + +export function I18nProvider({ children }) { + const [lang, setLang] = useState(getStoredLang); + const t = useCallback((key) => { + const entry = STRINGS[key]; + if (!entry) return key; + return entry[lang] || entry.en || key; + }, [lang]); + const toggleLang = useCallback(() => { + setLang((prev) => { + const next = prev === "en" ? "zh" : "en"; + storeLang(next); + return next; + }); + }, []); + return ( + + {children} + + ); +} + +export function useI18n() { + const ctx = useContext(I18nContext); + if (!ctx) throw new Error("useI18n must be used within I18nProvider"); + return ctx; +} diff --git a/frontend/src/main.jsx b/frontend/src/main.jsx new file mode 100644 index 00000000..b03927c4 --- /dev/null +++ b/frontend/src/main.jsx @@ -0,0 +1,16 @@ +import React from "react"; +import ReactDOM from "react-dom/client"; +import App from "./App.jsx"; +import { getStoredLang } from "./i18n.jsx"; +import "./styles.css"; + +// Vite mangles the zh-CN on Windows; set it at runtime, in the stored +// language, to stay correct before React mounts. Shell syncs it on toggle. +const _APP_TITLES = { en: "OpenKB · Knowledge Workbench", zh: "OpenKB · 知识工作台" }; +document.title = _APP_TITLES[getStoredLang()] || _APP_TITLES.en; + +ReactDOM.createRoot(document.getElementById("root")).render( + <React.StrictMode> + <App /> + </React.StrictMode> +); diff --git a/frontend/src/state/AppContext.jsx b/frontend/src/state/AppContext.jsx new file mode 100644 index 00000000..01bce60c --- /dev/null +++ b/frontend/src/state/AppContext.jsx @@ -0,0 +1,86 @@ +import { createContext, useContext, useState, useCallback, useRef } from "react"; +import { getApiBase, getToken, setConnection, hasConnection } from "../api/client.js"; +import { useI18n } from "../i18n.jsx"; + +const AppContext = createContext(null); + +export function AppProvider({ children }) { + const { t } = useI18n(); + const [apiBase, setApiBaseState] = useState(getApiBase()); + const [token, setTokenState] = useState(getToken()); + const [kbs, setKbs] = useState([]); + const [kb, setKb] = useState(null); + const [view, setView] = useState("overview"); + const [settingsOpen, setSettingsOpen] = useState(!hasConnection()); + // Right-pane reasoning timeline. + const [inspItems, setInspItems] = useState([]); + const [inspBusy, setInspBusy] = useState(false); + const [toast, setToastState] = useState(null); + const toastTimer = useRef(null); + const [sidebarOpen, setSidebarOpen] = useState(false); + + const saveConnection = useCallback((base, tok) => { + const cleaned = (base || "").trim().replace(/\/$/, ""); + setConnection(cleaned, (tok || "").trim()); + setApiBaseState(cleaned); + setTokenState((tok || "").trim()); + }, []); + + const setViewSafe = useCallback((v) => { + setView(v); + setSidebarOpen(false); + }, []); + + const toastMsg = useCallback((message, kind = "") => { + setToastState({ message, kind }); + clearTimeout(toastTimer.current); + toastTimer.current = setTimeout(() => setToastState(null), 3200); + }, []); + + // Inspector timeline helpers. + const inspReset = useCallback((busy) => { + setInspItems(busy ? [{ kind: "start", tag: t("start"), body: t("startRetrieval") }] : []); + setInspBusy(!!busy); + }, [t]); + + const inspAdd = useCallback((kind, tag, body) => { + // delta is too granular for the timeline; render in the answer pane instead. + if (kind === "delta") return; + setInspItems((prev) => [...prev, { kind, tag, body }]); + }, []); + + const inspDone = useCallback(() => { + setInspBusy(false); + }, []); + + const value = { + apiBase, + token, + kbs, + setKbs, + kb, + setKb, + view, + setView: setViewSafe, + settingsOpen, + setSettingsOpen, + sidebarOpen, + setSidebarOpen, + saveConnection, + toast, + toastMsg, + inspItems, + inspBusy, + inspReset, + inspAdd, + inspDone, + }; + + return <AppContext.Provider value={value}>{children}</AppContext.Provider>; +} + +export function useApp() { + const ctx = useContext(AppContext); + if (!ctx) throw new Error("useApp must be used within AppProvider"); + return ctx; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css new file mode 100644 index 00000000..8b67a3fb --- /dev/null +++ b/frontend/src/styles.css @@ -0,0 +1,345 @@ +:root { + --bg: #0e1015; + --bg-1: #14171f; + --bg-2: #1a1e28; + --bg-3: #222734; + --border: #2a2f3c; + --border-soft: #232834; + --text: #e7eaf0; + --text-2: #9aa3b2; + --text-3: #6b7280; + --accent: #5b8def; + --accent-soft: rgba(91, 141, 239, 0.14); + --cyan: #22d3ee; + --green: #34d399; + --amber: #fbbf24; + --red: #f87171; + --purple: #a78bfa; + --radius: 10px; + --radius-sm: 7px; + --shadow: 0 8px 30px rgba(0, 0, 0, 0.35); + --font: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", Roboto, Helvetica, Arial, sans-serif; + --mono: "SF Mono", "JetBrains Mono", "Cascadia Code", Consolas, monospace; +} +* { box-sizing: border-box; margin: 0; padding: 0; } +html, body, #root { height: 100%; } +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + font-size: 14px; + line-height: 1.55; + letter-spacing: 0; + -webkit-font-smoothing: antialiased; +} +.app { display: grid; grid-template-columns: 232px 1fr 340px; height: 100vh; overflow: hidden; } +.hidden { display: none !important; } +button { font-family: inherit; cursor: pointer; border: none; background: none; color: inherit; } +input, textarea, select { font-family: inherit; font-size: 14px; } +svg { display: block; flex-shrink: 0; } +a { color: var(--accent); } + +/* ===== Sidebar ===== */ +.sidebar { + background: var(--bg-1); + border-right: 1px solid var(--border); + display: flex; flex-direction: column; + padding: 16px 12px; + gap: 16px; + min-height: 0; +} +.brand { display: flex; align-items: center; gap: 9px; padding: 2px 6px; } +.brand-mark { + width: 26px; height: 26px; border-radius: 7px; + background: linear-gradient(135deg, var(--accent), var(--cyan)); + color: #fff; font-weight: 700; font-size: 12px; + display: grid; place-items: center; letter-spacing: 0.5px; +} +.brand-name { font-weight: 600; font-size: 15px; } +.kb-switcher { position: relative; } +.kb-current { + width: 100%; display: flex; align-items: center; gap: 9px; + padding: 9px 10px; border-radius: var(--radius-sm); + background: var(--bg-2); border: 1px solid var(--border); + transition: background 0.15s; +} +.kb-current:hover { background: var(--bg-3); } +.kb-current-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; box-shadow: 0 0 8px var(--green); } +.kb-current-name { flex: 1; text-align: left; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.kb-current .kb-chev { color: var(--text-2); } +.kb-menu { + position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 40; + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius); + box-shadow: var(--shadow); padding: 6px; max-height: 320px; overflow-y: auto; +} +.kb-menu-item { + width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 8px 10px; border-radius: var(--radius-sm); text-align: left; transition: background 0.15s; +} +.kb-menu-item:hover { background: var(--bg-3); } +.kb-menu-item.active { background: var(--accent-soft); color: var(--accent); } +.kb-menu-item .mi-name { font-weight: 500; } +.kb-menu-item .mi-meta { font-size: 11px; color: var(--text-3); } +.kb-menu-item.active .mi-meta { color: var(--accent); opacity: 0.8; } +.kb-menu-new { + width: 100%; display: flex; align-items: center; gap: 7px; + padding: 8px 10px; border-radius: var(--radius-sm); color: var(--accent); + border-top: 1px solid var(--border-soft); margin-top: 4px; font-weight: 500; +} +.kb-menu-new:hover { background: var(--bg-3); } +.kb-menu-new:disabled { opacity: 0.5; cursor: not-allowed; } +.nav { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; } +.nav-item { + display: flex; align-items: center; gap: 10px; + padding: 9px 11px; border-radius: var(--radius-sm); text-align: left; + color: var(--text-2); font-weight: 500; transition: background 0.15s, color 0.15s; +} +.nav-item:hover { background: var(--bg-2); color: var(--text); } +.nav-item.active { background: var(--accent-soft); color: var(--accent); } +.sidebar-foot { display: flex; justify-content: flex-end; } +.icon-btn { + display: grid; place-items: center; width: 32px; height: 32px; + border-radius: var(--radius-sm); color: var(--text-2); transition: background 0.15s, color 0.15s; +} +.icon-btn:hover { background: var(--bg-2); color: var(--text); } + +/* ===== Workspace ===== */ +.workspace { display: flex; flex-direction: column; min-width: 0; min-height: 0; background: var(--bg); } +.ws-header { + display: flex; align-items: center; justify-content: space-between; gap: 16px; + padding: 18px 28px; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.ws-title { font-size: 19px; font-weight: 650; letter-spacing: -0.01em; } +.ws-body { flex: 1; overflow-y: auto; padding: 24px 28px 40px; } + +/* ===== Inspector ===== */ +.inspector { + background: var(--bg-1); border-left: 1px solid var(--border); + display: flex; flex-direction: column; min-height: 0; +} +.insp-head { + display: flex; align-items: center; justify-content: space-between; + padding: 16px 18px; border-bottom: 1px solid var(--border); flex-shrink: 0; +} +.insp-title { font-weight: 600; font-size: 13px; } +.insp-status { font-size: 12px; color: var(--text-3); } +.insp-status.busy { color: var(--cyan); } +.insp-body { flex: 1; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 8px; } +.insp-empty { color: var(--text-3); font-size: 13px; text-align: center; margin: auto; line-height: 1.7; } +.timeline-item { + background: var(--bg-2); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); + padding: 10px 12px; font-size: 13px; animation: fadein 0.2s ease; +} +.timeline-item.tool { border-left: 2px solid var(--cyan); } +.timeline-item.done { border-left-color: var(--green); } +.timeline-item.error { border-left-color: var(--red); } +.timeline-item.start { border-left: 2px solid var(--accent); } +.timeline-item .tl-tag { font-size: 11px; font-weight: 600; color: var(--cyan); text-transform: uppercase; letter-spacing: 0.04em; margin-bottom: 4px; display: block; } +.timeline-item.done .tl-tag { color: var(--green); } +.timeline-item.error .tl-tag { color: var(--red); } +.timeline-item.start .tl-tag { color: var(--accent); } +.timeline-item .tl-body { color: var(--text-2); word-break: break-word; } +.timeline-item .tl-body code { font-family: var(--mono); font-size: 11px; background: var(--bg-3); padding: 1px 5px; border-radius: 4px; } +@keyframes fadein { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } } + +/* ===== Buttons / controls ===== */ +.btn { + display: inline-flex; align-items: center; gap: 7px; justify-content: center; + padding: 8px 14px; border-radius: var(--radius-sm); font-weight: 550; font-size: 13px; + border: 1px solid var(--border); background: var(--bg-2); color: var(--text); transition: background 0.15s, border-color 0.15s, opacity 0.15s; +} +.btn:hover { background: var(--bg-3); } +.btn:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; } +.btn-primary:hover { background: #4f80e6; } +.btn-ghost { background: transparent; border-color: var(--border); } +.btn-ghost:hover { background: var(--bg-2); } +.btn-danger { background: transparent; border-color: var(--border); color: var(--red); } +.btn-danger:hover { background: rgba(248, 113, 113, 0.1); border-color: var(--red); } +.btn-sm { padding: 5px 10px; font-size: 12px; } + +/* ===== Stat cards ===== */ +.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 24px; } +.stat-card { + background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); + padding: 18px; +} +.stat-label { font-size: 12px; color: var(--text-2); margin-bottom: 8px; } +.stat-value { font-size: 28px; font-weight: 680; letter-spacing: -0.02em; } +.stat-sub { font-size: 11px; color: var(--text-3); margin-top: 4px; } +.stat-card.accent .stat-value { color: var(--accent); } +.stat-card.cyan .stat-value { color: var(--cyan); } +.stat-card.green .stat-value { color: var(--green); } +.stat-card.purple .stat-value { color: var(--purple); } + +.panel { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 18px; } +.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--border-soft); } +.panel-title { font-weight: 600; font-size: 14px; } +.panel-body { padding: 8px 0; } + +/* ===== Table ===== */ +.table { width: 100%; border-collapse: collapse; } +.table th, .table td { text-align: left; padding: 10px 16px; font-size: 13px; border-bottom: 1px solid var(--border-soft); } +.table th { color: var(--text-2); font-weight: 550; font-size: 12px; } +.table tr:last-child td { border-bottom: none; } +.table tbody tr { transition: background 0.12s; cursor: default; } +.table tbody tr:hover { background: var(--bg-2); } +.cell-name { font-weight: 550; } +.cell-meta { color: var(--text-2); font-size: 12px; } +.tag { + display: inline-flex; align-items: center; gap: 4px; font-size: 11px; font-weight: 550; + padding: 2px 8px; border-radius: 20px; background: var(--bg-3); color: var(--text-2); +} +.tag.green { background: rgba(52, 211, 153, 0.14); color: var(--green); } +.tag.cyan { background: rgba(34, 211, 238, 0.14); color: var(--cyan); } +.tag.amber { background: rgba(251, 191, 36, 0.14); color: var(--amber); } +.tag.red { background: rgba(248, 113, 113, 0.14); color: var(--red); } +.tag.purple { background: rgba(167, 139, 250, 0.14); color: var(--purple); } +.icon-cell { display: inline-flex; align-items: center; gap: 8px; } +.file-ico { width: 28px; height: 28px; border-radius: 6px; background: var(--bg-3); display: grid; place-items: center; color: var(--text-2); font-size: 10px; font-weight: 600; } + +/* ===== Dropzone / upload ===== */ +.dropzone { + border: 1.5px dashed var(--border); border-radius: var(--radius); padding: 26px; + text-align: center; color: var(--text-2); transition: border-color 0.15s, background 0.15s; cursor: pointer; + margin-bottom: 18px; +} +.dropzone:hover, .dropzone.drag { border-color: var(--accent); background: var(--accent-soft); color: var(--text); } +.dropzone .dz-title { font-weight: 550; margin-bottom: 4px; color: var(--text); } +.upload-item { display: flex; align-items: center; gap: 10px; padding: 8px 16px; font-size: 13px; } +.upload-item .up-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.progress { height: 4px; background: var(--bg-3); border-radius: 4px; overflow: hidden; flex: 1; } +.progress > span { display: block; height: 100%; background: var(--accent); transition: width 0.3s; } + +/* ===== Query / Chat ===== */ +.qa-wrap { display: flex; flex-direction: column; height: 100%; gap: 16px; } +.qa-input-bar { display: flex; gap: 10px; flex-shrink: 0; align-items: flex-end; } +.qa-input { + flex: 1; background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); + padding: 12px 14px; color: var(--text); resize: none; min-height: 46px; transition: border-color 0.15s; +} +.qa-input:focus { outline: none; border-color: var(--accent); } +.qa-stream { flex: 1; overflow-y: auto; } +.msg { margin-bottom: 18px; animation: fadein 0.2s ease; } +.msg-role { font-size: 12px; font-weight: 600; color: var(--text-2); margin-bottom: 6px; display: flex; align-items: center; gap: 7px; } +.msg-role .role-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent); } +.msg-role.user .role-dot { background: var(--text-2); } +.msg-bubble { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px 16px; } +.msg-bubble.user { background: var(--accent-soft); border-color: transparent; } +.spinner-wrap { display: inline-flex; align-items: center; gap: 8px; } +.chat-sessions { display: flex; flex-direction: column; gap: 2px; max-height: 160px; overflow-y: auto; flex-shrink: 0; } +.session-item { padding: 7px 10px; border-radius: var(--radius-sm); font-size: 12px; color: var(--text-2); text-align: left; display: flex; gap: 8px; align-items: center; justify-content: space-between; } +.session-item:hover { background: var(--bg-2); color: var(--text); } +.session-item.active { background: var(--accent-soft); color: var(--accent); } +.session-title { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.session-del { color: var(--text-3); flex-shrink: 0; opacity: 0; transition: opacity 0.15s; } +.session-item:hover .session-del { opacity: 1; } +.session-del:hover { color: var(--red); } + +/* ===== Markdown ===== */ +.md { font-size: 14px; line-height: 1.65; color: var(--text); word-break: break-word; } +.md > :first-child { margin-top: 0; } +.md > :last-child { margin-bottom: 0; } +.md p { margin: 0 0 10px; } +.md h1, .md h2, .md h3, .md h4 { margin: 16px 0 8px; font-weight: 650; line-height: 1.3; } +.md h1 { font-size: 18px; } .md h2 { font-size: 16px; } .md h3 { font-size: 15px; } .md h4 { font-size: 14px; } +.md ul, .md ol { margin: 0 0 10px; padding-left: 22px; } +.md li { margin: 2px 0; } +.md a { color: var(--accent); text-decoration: none; } .md a:hover { text-decoration: underline; } +.md code { font-family: var(--mono); font-size: 12.5px; background: var(--bg-3); padding: 1px 6px; border-radius: 4px; color: var(--cyan); } +.md pre { background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 12px 14px; overflow-x: auto; margin: 8px 0; } +.md pre code { background: none; padding: 0; color: var(--text); font-size: 12.5px; line-height: 1.5; } +.md blockquote { border-left: 3px solid var(--border); margin: 8px 0; padding: 4px 14px; color: var(--text-2); } +.md table { border-collapse: collapse; margin: 8px 0; width: 100%; font-size: 13px; } +.md th, .md td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; } +.md th { background: var(--bg-2); font-weight: 600; } +.md hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; } +.md img { max-width: 100%; border-radius: var(--radius-sm); } +.md input[type="checkbox"] { margin-right: 6px; accent-color: var(--accent); } + +/* ===== Maintenance ===== */ +.maint-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; } +.maint-card { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; } +.maint-card h3 { font-size: 14px; font-weight: 600; margin-bottom: 4px; } +.maint-card p { font-size: 12.5px; color: var(--text-2); margin-bottom: 14px; } +.maint-log { + background: var(--bg); border: 1px solid var(--border-soft); border-radius: var(--radius-sm); + padding: 12px 14px; font-family: var(--mono); font-size: 12px; color: var(--text-2); + max-height: 240px; overflow-y: auto; margin-top: 12px; white-space: pre-wrap; +} +.maint-log .log-line { padding: 1px 0; } +.maint-log .log-line.ok { color: var(--green); } +.maint-log .log-line.warn { color: var(--amber); } +.maint-log .log-line.err { color: var(--red); } +.toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; } +.toggle { position: relative; width: 38px; height: 22px; background: var(--bg-3); border-radius: 20px; cursor: pointer; transition: background 0.18s; flex-shrink: 0; } +.toggle::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; background: var(--text); border-radius: 50%; transition: transform 0.18s; } +.toggle.on { background: var(--green); } +.toggle.on::after { transform: translateX(16px); } + +/* ===== Overlay / modal / toast ===== */ +.overlay { position: fixed; inset: 0; background: rgba(8, 9, 12, 0.65); backdrop-filter: blur(3px); display: grid; place-items: center; z-index: 100; } +.modal { background: var(--bg-1); border: 1px solid var(--border); border-radius: var(--radius); width: 420px; max-width: 92vw; box-shadow: var(--shadow); } +.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 16px 18px; border-bottom: 1px solid var(--border-soft); } +.modal-head h2 { font-size: 16px; font-weight: 650; } +.modal-body { padding: 18px; display: flex; flex-direction: column; gap: 14px; } +.modal-foot { display: flex; justify-content: flex-end; gap: 10px; padding: 14px 18px; border-top: 1px solid var(--border-soft); } +.field { display: flex; flex-direction: column; gap: 6px; } +.field-label { font-size: 12px; color: var(--text-2); font-weight: 550; } +.field input, .field textarea, .field select { + background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); + padding: 9px 11px; color: var(--text); transition: border-color 0.15s; +} +.field input:focus, .field textarea:focus { outline: none; border-color: var(--accent); } +.field-hint { font-size: 11px; color: var(--text-3); } +.toast { + position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%); + background: var(--bg-3); border: 1px solid var(--border); color: var(--text); + padding: 11px 18px; border-radius: var(--radius-sm); font-size: 13px; box-shadow: var(--shadow); z-index: 200; + animation: fadein 0.2s ease; +} +.toast.ok { border-color: var(--green); } +.toast.err { border-color: var(--red); } + +/* ===== Misc ===== */ +.empty-state { text-align: center; color: var(--text-3); padding: 60px 20px; } +.empty-state svg { margin: 0 auto 14px; color: var(--text-3); } +.empty-state h3 { font-size: 15px; font-weight: 600; margin-bottom: 6px; color: var(--text-2); } +.empty-state p { font-size: 13px; } +.spinner { width: 18px; height: 18px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.7s linear infinite; } +@keyframes spin { to { transform: rotate(360deg); } } +.concept-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 14px 16px; } +.concept-chip { font-size: 12px; padding: 4px 11px; border-radius: 20px; background: var(--bg-2); border: 1px solid var(--border-soft); color: var(--text-2); cursor: pointer; transition: background 0.15s, color 0.15s; } +.concept-chip:hover { background: var(--accent-soft); color: var(--accent); border-color: transparent; } +.row-actions { display: flex; gap: 6px; justify-content: flex-end; } +.select { background: var(--bg-2); border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 11px; color: var(--text); font-size: 13px; } +.select:focus { outline: none; border-color: var(--accent); } + +/* ===== Mobile ===== */ +.mobile-menu-btn { display: none; } +.sidebar-overlay { display: none; } +@media (max-width: 960px) { + .app { grid-template-columns: 1fr; } + .sidebar { + position: fixed; top: 0; left: 0; bottom: 0; width: 232px; z-index: 60; + transform: translateX(-100%); transition: transform 0.22s ease; + } + .sidebar.open { transform: translateX(0); } + .sidebar-overlay { display: block; position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 55; } + .mobile-menu-btn { + display: grid; place-items: center; position: fixed; top: 14px; left: 14px; z-index: 50; + width: 38px; height: 38px; border-radius: var(--radius-sm); background: var(--bg-2); border: 1px solid var(--border); color: var(--text); + } + .workspace { padding-top: 44px; } + .inspector { display: none; } + .stat-grid { grid-template-columns: repeat(2, 1fr); } + .maint-grid { grid-template-columns: 1fr; } + .ws-header { padding: 14px 20px 14px 64px; } + .ws-body { padding: 18px 20px 32px; } +} + +/* ===== Scrollbar ===== */ +::-webkit-scrollbar { width: 9px; height: 9px; } +::-webkit-scrollbar-thumb { background: var(--bg-3); border-radius: 8px; border: 2px solid transparent; background-clip: padding-box; } +::-webkit-scrollbar-thumb:hover { background: #2e3445; } +::-webkit-scrollbar-track { background: transparent; } diff --git a/frontend/src/views/Chat.jsx b/frontend/src/views/Chat.jsx new file mode 100644 index 00000000..637f4e8f --- /dev/null +++ b/frontend/src/views/Chat.jsx @@ -0,0 +1,168 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { MessageSquare, Send, Plus, Trash2, StopCircle } from "lucide-react"; +import { useSSEStream } from "../hooks/useSSEStream.js"; +import { api } from "../api/client.js"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; +import EmptyState from "../components/EmptyState.jsx"; +import Markdown from "../components/Markdown.jsx"; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); +} + +export default function Chat({ kb }) { + const { inspReset, inspAdd, inspDone, toastMsg } = useApp(); + const { t } = useI18n(); + const { busy, start, stop } = useSSEStream(); + const [sessions, setSessions] = useState([]); + const [sessionId, setSessionId] = useState(null); + const [msgs, setMsgs] = useState([]); + const msgIdRef = useRef(0); + const [input, setInput] = useState(""); + const taRef = useRef(null); + const scrollRef = useRef(null); + + const loadSessions = useCallback(() => { + api.chatSessions(kb).then((r) => setSessions(r.sessions || [])).catch(() => setSessions([])); + }, [kb]); + + useEffect(loadSessions, [loadSessions]); + + // Stop any in-flight stream when the KB changes so deltas for the old + // KB do not write into the new KB's message list. + useEffect(() => { return () => stop(); }, [kb]); + + useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [msgs]); + + function newSession() { + setSessionId(null); + setMsgs([]); + } + + async function loadHistory(sid) { + setSessionId(sid); + setMsgs([]); + try { + const r = await api.chatSessionLoad(kb, sid); + const hist = []; + const n = Math.max(r.user_turns.length, r.assistant_texts.length); + for (let i = 0; i < n; i++) { + if (i < r.user_turns.length) hist.push({ role: "user", text: r.user_turns[i] }); + if (i < r.assistant_texts.length) hist.push({ role: "assistant", text: r.assistant_texts[i] }); + } + setMsgs(hist); + } catch (e) { + toastMsg(e.message, "err"); + } + } + + async function delSession(sid, e) { + e.stopPropagation(); + if (!window.confirm(t("deleteSession"))) return; + try { + await api.chatSessionDelete(kb, sid); + if (sid === sessionId) newSession(); + loadSessions(); + toastMsg(t("deleted"), "ok"); + } catch (e2) { + toastMsg(e2.message, "err"); + } + } + + function autosize() { + const ta = taRef.current; + if (ta) { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 140) + "px"; } + } + + async function send() { + const text = input.trim(); + if (!text || busy) return; + setInput(""); + if (taRef.current) taRef.current.style.height = "auto"; + inspReset(true); + const aiId = ++msgIdRef.current; + const userMsg = { id: ++msgIdRef.current, role: "user", text }; + const aiMsg = { id: aiId, role: "assistant", text: "", pending: true }; + setMsgs((m) => [...m, userMsg, aiMsg]); + let acc = ""; + const onAbort = () => { + setMsgs((m) => m.map((x) => x.id === aiId ? { ...x, text: acc, pending: false, aborted: true } : x)); + inspAdd("tool", t("stopped"), t("userInterrupted")); + }; + try { + await start( + { path: "/api/v1/chat", payload: { kb, message: text, session_id: sessionId, stream: true } }, + (ev, d) => { + if (ev === "start" && d.session_id) setSessionId(d.session_id); + else if (ev === "tool_call") inspAdd("tool", t("retrieve") + " · " + (d.name || "tool"), `<code>${esc((d.arguments || "").slice(0, 120))}</code>`); + else if (ev === "delta") { + acc += d.text || ""; + setMsgs((m) => m.map((x) => x.id === aiId ? { ...x, text: acc, pending: true } : x)); + } else if (ev === "final") { + acc = d.answer || acc; + setMsgs((m) => m.map((x) => x.id === aiId ? { ...x, text: acc, pending: false } : x)); + inspAdd("done", t("completed"), t("turnCount").replace("{n}", d.turn_count || "")); + } else if (ev === "error") { + setMsgs((m) => m.map((x) => x.id === aiId ? { ...x, text: `<span style="color:var(--red)">${esc(d.message)}</span>`, pending: false } : x)); + inspAdd("error", t("error"), esc(d.message)); + } + }, + onAbort + ); + loadSessions(); + } finally { + inspDone(); + } + } + + return ( + <div className="qa-wrap"> + <div className="chat-sessions"> + <button className={`session-item ${!sessionId ? "active" : ""}`} onClick={newSession}> + <Plus size={13} /> {t("newSession")} + </button> + {sessions.map((s) => ( + <button key={s.id} className={`session-item ${s.id === sessionId ? "active" : ""}`} onClick={() => loadHistory(s.id)}> + <span className="session-title">{s.title || s.id}</span> + <Trash2 size={13} className="session-del" onClick={(e) => delSession(s.id, e)} /> + </button> + ))} + </div> + <div className="qa-stream" ref={scrollRef}> + {msgs.length === 0 ? ( + <EmptyState + icon={<MessageSquare size={40} strokeWidth={1.5} />} + title={t("multiTurn")} + desc={t("chatPersist")} + /> + ) : ( + msgs.map((m, i) => ( + <div className="msg" key={i}> + <div className={`msg-role ${m.role === "user" ? "user" : ""}`}><span className="role-dot" />{m.role === "user" ? t("you") : "OpenKB"}</div> + <div className={`msg-bubble ${m.role === "user" ? "user" : ""}`}> + {m.role === "user" ? m.text : (m.text ? <Markdown>{m.text}</Markdown> : (m.aborted ? <span className="cell-meta">{t("stopped")}</span> : <span className="spinner-wrap"><span className="spinner" /></span>))} + </div> + </div> + )) + )} + </div> + <div className="qa-input-bar"> + <button className="btn btn-ghost btn-sm" onClick={newSession}><Plus size={14} /> {t("newSession")}</button> + <textarea + ref={taRef} + className="qa-input" + placeholder={t("typeToContinue")} + rows={1} + value={input} + onChange={(e) => { setInput(e.target.value); autosize(); }} + // isComposing: 避免拼音输入法确认候选词时误触发提交 + onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); send(); } }} + /> + {busy + ? <button className="btn btn-ghost" onClick={stop}><StopCircle size={15} /> {t("stopGeneration")}</button> + : <button className="btn btn-primary" onClick={send}><Send size={15} /> {t("send")}</button>} + </div> + </div> + ); +} diff --git a/frontend/src/views/Documents.jsx b/frontend/src/views/Documents.jsx new file mode 100644 index 00000000..2ede28e1 --- /dev/null +++ b/frontend/src/views/Documents.jsx @@ -0,0 +1,136 @@ +import { useEffect, useState, useRef, useCallback } from "react"; +import { UploadCloud, Trash2, RefreshCw } from "lucide-react"; +import { api } from "../api/client.js"; +import { streamUpload, streamSSE } from "../api/sse.js"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; +import EmptyState from "../components/EmptyState.jsx"; +import Spinner from "../components/Spinner.jsx"; + +export default function Documents({ kb }) { + const { inspReset, inspAdd, inspDone, toastMsg } = useApp(); + const { t } = useI18n(); + const [docs, setDocs] = useState(null); + const [err, setErr] = useState(null); + const [uploads, setUploads] = useState([]); + const [dragging, setDragging] = useState(false); + const fileRef = useRef(null); + + const load = useCallback(() => { + setDocs(null); + setErr(null); + api.list(kb).then((r) => setDocs(r.documents || [])).catch((e) => setErr(e.message)); + }, [kb]); + + useEffect(load, [load]); + + function onFiles(files) { + if (!files || !files.length) return; + const items = Array.from(files).map((f) => ({ name: f.name, pct: 10, status: t("uploading") })); + setUploads(items); + inspReset(true); + const form = new FormData(); + form.append("kb", kb); + form.append("stream", "true"); + Array.from(files).forEach((f) => form.append("files", f)); + streamUpload("/api/v1/add", form, (ev, d) => { + if (ev === "file_start") { + setUploads((prev) => prev.map((u) => (u.name === d.original_name ? { ...u, pct: 50, status: t("compiling") } : u))); + inspAdd("tool", t("compiling"), `${t("processing")} <code>${d.original_name}</code>`); + } else if (ev === "file_done") { + const ok = d.status === "added"; + setUploads((prev) => prev.map((u) => (u.name === d.original_name ? { ...u, pct: 100, status: ok ? t("added") : d.status } : u))); + inspAdd(ok ? "done" : "tool", ok ? t("completed") : t("skipped"), `${d.original_name}:${d.message || d.status}`); + } else if (ev === "final") { + toastMsg(`${t("added")} ${d.added_count}, ${t("skipped")} ${d.skipped_count}, ${t("failed")}: ${d.failed_count}`, d.failed_count ? "err" : "ok"); + } else if (ev === "error") { + toastMsg(d.message || t("uploadFailed"), "err"); + inspAdd("error", t("error"), d.message || ""); + } + }).then(() => { inspAdd("done", t("flowDone"), t("flowDone")); load(); }) + .catch((e) => { toastMsg(e.message, "err"); inspAdd("error", t("error"), e.message); }) + .finally(() => { inspDone(); }); + } + + async function removeDoc(name) { + if (!window.confirm(t("confirmDeleteDoc") + ` ${name}`)) return; + inspReset(true); + try { + await streamSSE("/api/v1/remove", { kb, identifier: name, stream: true }, (ev, d) => { + if (ev === "plan") inspAdd("tool", t("plan"), `${d.name || name}`); + else if (ev === "final") inspAdd("done", t("completed"), `${t("deletedDoc")} ${d.name || name}`); + else if (ev === "error") { toastMsg(d.message || t("deleteFailed"), "err"); inspAdd("error", t("error"), d.message || ""); } + }); + toastMsg(t("deletedDoc"), "ok"); + load(); + } catch (e) { + toastMsg(e.message, "err"); + inspAdd("error", t("error"), e.message); + } finally { + inspDone(); + } + } + + return ( + <> + <div + className={`dropzone ${dragging ? "drag" : ""}`} + onClick={() => fileRef.current && fileRef.current.click()} + onDragOver={(e) => { e.preventDefault(); setDragging(true); }} + onDragLeave={() => setDragging(false)} + onDrop={(e) => { e.preventDefault(); setDragging(false); if (e.dataTransfer.files.length) onFiles(e.dataTransfer.files); }} + > + <UploadCloud size={28} style={{ margin: "0 auto 8px", color: "var(--text-2)" }} /> + <div className="dz-title">{t("dragOrClick")}</div> + <div>PDF · Word · Markdown · PPT · HTML · Excel · CSV · TXT · URL</div> + <input ref={fileRef} type="file" multiple style={{ display: "none" }} onChange={(e) => { if (e.target.files.length) onFiles(e.target.files); e.target.value = ""; }} /> + </div> + + {uploads.length > 0 && ( + <div className="panel"> + <div className="panel-head"><span className="panel-title">{t("uploadProgress")}</span></div> + <div className="panel-body"> + {uploads.map((u, i) => ( + <div className="upload-item" key={i}> + <span className="up-name">{u.name}</span> + <div className="progress"><span style={{ width: `${u.pct}%` }} /></div> + <span className="cell-meta">{u.status}</span> + </div> + ))} + </div> + </div> + )} + + <div className="panel"> + <div className="panel-head"> + <span className="panel-title">{t("indexedDocs")}</span> + <button className="btn btn-ghost btn-sm" onClick={load}><RefreshCw size={14} /> {t("refresh")}</button> + </div> + <div className="panel-body"> + {err ? <EmptyState title={t("error")} desc={err} /> : + docs === null ? <div className="empty-state"><Spinner /></div> : + docs.length === 0 ? <EmptyState title={t("noDocsYet")} desc={t("uploadHint")} /> : ( + <table className="table"> + <thead><tr><th>{t("docName")}</th><th>{t("docType")}</th><th>{t("pages")}</th><th>{t("hash")}</th><th></th></tr></thead> + <tbody> + {docs.map((d) => { + const ext = (d.name.split(".").pop() || "").toLowerCase(); + const ico = d.type === "url" ? "URL" : ext.toUpperCase().slice(0, 4) || "FILE"; + return ( + <tr key={d.hash} onClick={(e) => { const btn = e.target.closest("[data-rm]"); if (btn) removeDoc(btn.getAttribute("data-rm")); }}> + <td><span className="icon-cell"><span className="file-ico">{ico}</span><span className="cell-name">{d.name}</span></span></td> + <td><span className="tag">{d.display_type || d.type || "—"}</span></td> + <td className="cell-meta">{d.pages != null ? d.pages : "—"}</td> + <td className="cell-meta">{d.hash.slice(0, 8)}</td> + <td><div className="row-actions"><button className="btn btn-danger btn-sm" data-rm={d.name}>{t("delete")}</button></div></td> + </tr> + ); + })} + </tbody> + </table> + )} + </div> + </div> + </> + ); +} diff --git a/frontend/src/views/Maintenance.jsx b/frontend/src/views/Maintenance.jsx new file mode 100644 index 00000000..c8b3e6fe --- /dev/null +++ b/frontend/src/views/Maintenance.jsx @@ -0,0 +1,177 @@ +import { useState, useEffect, useRef } from "react"; +import { api } from "../api/client.js"; +import { streamSSE } from "../api/sse.js"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); +} + +function fmtTime(iso) { + if (!iso) return null; + try { const d = new Date(iso); return isNaN(d) ? iso : d.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); } + catch { return iso; } +} + +export default function Maintenance({ kb }) { + const { inspReset, inspAdd, inspDone, toastMsg } = useApp(); + const { t } = useI18n(); + const [fix, setFix] = useState(false); + const [lintLog, setLintLog] = useState([]); + const [rcScope, setRcScope] = useState("all"); + const [rcDocs, setRcDocs] = useState([]); + const [rcDoc, setRcDoc] = useState(""); + const [rcLog, setRcLog] = useState([]); + const [watchOn, setWatchOn] = useState(false); + const [status, setStatus] = useState(null); + const rcScroll = useRef(null); + const lintRef = useRef([]); + const rcRef = useRef([]); + + useEffect(() => { + api.status(kb).then(setStatus).catch(() => setStatus(null)); + api.watchStatus(kb).then((r) => setWatchOn(!!r.active)).catch(() => {}); + }, [kb]); + + // Populate the document list for the "指定文档" recompile scope. + useEffect(() => { + let alive = true; + api.list(kb) + .then((r) => { if (!alive) return; const docs = (r.documents || []).map((d) => d.name).filter(Boolean); setRcDocs(docs); setRcDoc((prev) => (prev && docs.includes(prev)) ? prev : (docs[0] || "")); }) + .catch(() => { if (!alive) return; setRcDocs([]); setRcDoc(""); }); + return () => { alive = false; }; + }, [kb]); + + useEffect(() => { if (rcScroll.current) rcScroll.current.scrollTop = rcScroll.current.scrollHeight; }, [rcLog]); + + function pushLint(line) { lintRef.current = [...lintRef.current, line]; setLintLog(lintRef.current); } + function pushRc(line) { rcRef.current = [...rcRef.current, line]; setRcLog(rcRef.current); } + + async function runLint() { + lintRef.current = [{ kind: "plain", text: `${t("running")}${fix ? t("autoFixSuffix") : ""}...` }]; + setLintLog(lintRef.current); + inspReset(true); + try { + const r = await api.lint(kb, fix); + lintRef.current = []; + if (r.skipped) pushLint({ kind: "warn", text: r.reason || t("skipped") }); + pushLint({ kind: "ok", text: r.message }); + if (r.lint_files_changed != null) pushLint({ kind: "plain", text: `${t("filesChanged")}: ${r.lint_files_changed}, ${t("ghostsRemoved")}: ${r.lint_ghosts_removed}` }); + if (r.structural_report) pushLint({ kind: "plain", text: esc(r.structural_report).slice(0, 600) }); + inspAdd("done", "Lint", esc(r.message)); + toastMsg(t("lintComplete"), "ok"); + } catch (e) { + pushLint({ kind: "err", text: esc(e.message) }); + toastMsg(e.message, "err"); + inspAdd("error", t("error"), esc(e.message)); + } finally { + inspDone(); + } + } + + async function runRecompile() { + rcRef.current = [{ kind: "plain", text: t("recompiling") }]; + setRcLog(rcRef.current); + inspReset(true); + // all -> all_docs:true; one -> doc_name (backend resolves a single doc). + const payload = rcScope === "one" + ? { kb, doc_name: rcDoc, stream: true } + : { kb, all_docs: true, stream: true }; + try { + await streamSSE("/api/v1/recompile", payload, (ev, d) => { + if (ev === "plan") pushRc({ kind: "plain", text: `${t("targets")} ${d.targets ? d.targets.length : 0}` }); + else if (ev === "doc") pushRc({ kind: d.status === "ok" ? "ok" : d.status === "error" ? "err" : "warn", text: `${d.name || d.doc_name || ""} → ${d.status}${d.message ? "," + d.message : ""}` }); + else if (ev === "final") { pushRc({ kind: "ok", text: `${t("recompileDone")}: ${t("recompiled")} ${d.recompiled}, ${t("skipped")} ${d.skipped}` }); toastMsg(t("recompileDone"), "ok"); inspAdd("done", t("completed"), `${t("recompiled")} ${d.recompiled}, ${t("skipped")} ${d.skipped}`); } + else if (ev === "error") { pushRc({ kind: "err", text: d.message }); toastMsg(d.message, "err"); inspAdd("error", t("error"), esc(d.message)); } + }); + } catch (e) { + pushRc({ kind: "err", text: esc(e.message) }); + toastMsg(e.message, "err"); + inspAdd("error", t("error"), esc(e.message)); + } finally { + inspDone(); + api.status(kb).then(setStatus).catch(() => {}); + } + } + + async function toggleWatch() { + const turnOn = !watchOn; + try { + if (turnOn) await api.watchStart(kb, 2); + else await api.watchStop(kb); + setWatchOn(turnOn); + toastMsg(turnOn ? t("watcherOn") : t("watcherOff"), "ok"); + } catch (e) { + toastMsg(e.message, "err"); + } + } + + const dirs = status?.directories || {}; + const rcDisabled = rcScope === "one" && !rcDoc; + + return ( + <div className="maint-grid"> + <div className="maint-card"> + <h3>{t("healthLint")}</h3> + <p>{t("lintDesc")}</p> + <div className="toggle-row"> + <span className="cell-meta">{t("autoFixLabel")}</span> + <div className={`toggle ${fix ? "on" : ""}`} onClick={() => setFix((f) => !f)} /> + </div> + <div className="row-actions"> + <button className="btn btn-primary btn-sm" onClick={runLint}>{t("runLint")}</button> + </div> + <div className="maint-log"> + {lintLog.map((l, i) => <div key={i} className={`log-line ${l.kind}`}>{l.text}</div>)} + </div> + </div> + + <div className="maint-card"> + <h3>{t("recompileSection")}</h3> + <p>{t("recompileDesc")}</p> + <div className="toggle-row"> + <span className="cell-meta">{t("scope")}</span> + <select className="select" style={{ width: "auto" }} value={rcScope} onChange={(e) => setRcScope(e.target.value)}> + <option value="all">{t("allDocs")}</option> + <option value="one">{t("oneDoc")}</option> + </select> + {rcScope === "one" && ( + <select className="select" style={{ width: "auto", marginLeft: 8 }} value={rcDoc} onChange={(e) => setRcDoc(e.target.value)} disabled={rcDocs.length === 0}> + {rcDocs.length === 0 + ? <option value="">{t("noDocOption")}</option> + : rcDocs.map((n) => <option key={n} value={n}>{n}</option>)} + </select> + )} + </div> + <div className="row-actions"> + <button className="btn btn-ghost btn-sm" onClick={runRecompile} disabled={rcDisabled}>{t("startRecompile")}</button> + </div> + <div className="maint-log" ref={rcScroll}> + {rcLog.map((l, i) => <div key={i} className={`log-line ${l.kind}`}>{l.text}</div>)} + </div> + </div> + + <div className="maint-card"> + <h3>{t("watchSection")}</h3> + <p>{t("watchDesc")}</p> + <div className="toggle-row"> + <span className="cell-meta">{t("watchStatus")}</span> + <div className={`toggle ${watchOn ? "on" : ""}`} onClick={toggleWatch} /> + </div> + </div> + + <div className="maint-card"> + <h3>{t("kbStatus")}</h3> + <p>{t("kbStatusDesc")}</p> + <div className="maint-log"> + {Object.entries(dirs).map(([k, v]) => <div key={k} className="log-line">{k}: {v}</div>)} + {status && <div className="log-line">{`${t("rawFiles")}: ${status.raw_count}`}</div>} + {status && <div className="log-line">{`${t("indexed")}: ${status.total_indexed}`}</div>} + {status?.last_compile && <div className="log-line ok">{t("lastCompile")}: {fmtTime(status.last_compile)}</div>} + {status?.last_lint && <div className="log-line ok">{t("lastLint")}: {fmtTime(status.last_lint)}</div>} + </div> + </div> + </div> + ); +} diff --git a/frontend/src/views/Overview.jsx b/frontend/src/views/Overview.jsx new file mode 100644 index 00000000..1cfe5054 --- /dev/null +++ b/frontend/src/views/Overview.jsx @@ -0,0 +1,114 @@ +import { useEffect, useState } from "react"; +import { api } from "../api/client.js"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; +import EmptyState from "../components/EmptyState.jsx"; +import Spinner from "../components/Spinner.jsx"; + +function StatCard({ label, value, sub, color }) { + return ( + <div className={`stat-card ${color || ""}`}> + <div className="stat-label">{label}</div> + <div className="stat-value">{value}</div> + <div className="stat-sub">{sub}</div> + </div> + ); +} + +function fmtTime(iso) { + if (!iso) return null; + try { + const d = new Date(iso); + if (isNaN(d)) return iso; + const lang = localStorage.getItem("openkb_lang") || "en"; + return d.toLocaleString(lang === "zh" ? "zh-CN" : "en-US", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }); + } catch { + return iso; + } +} + +export default function Overview({ kb }) { + const { setView, toastMsg } = useApp(); + const { t } = useI18n(); + const [data, setData] = useState(null); + const [err, setErr] = useState(null); + + useEffect(() => { + let alive = true; + setData(null); + setErr(null); + Promise.all([api.status(kb), api.list(kb)]) + .then(([status, list]) => { if (alive) setData({ status, list }); }) + .catch((e) => { if (alive) setErr(e.message); }); + return () => { alive = false; }; + }, [kb]); + + if (err) return <EmptyState title={t("error")} desc={err} />; + if (!data) return <div className="empty-state"><Spinner /></div>; + + const { status, list } = data; + const dirs = status.directories || {}; + + return ( + <> + <div className="stat-grid"> + <StatCard label={t("documentsCol")} value={status.total_indexed} sub={`${t("rawFiles")} ${status.raw_count}`} color="accent" /> + <StatCard label={t("concepts")} value={dirs.concepts || 0} color="cyan" /> + <StatCard label={t("summaries")} value={dirs.summaries || 0} color="green" /> + <StatCard label={t("lintResults")} value={dirs.reports || 0} color="purple" /> + </div> + + {list.concepts && list.concepts.length > 0 && ( + <div className="panel"> + <div className="panel-head"> + <span className="panel-title">{t("concepts")}</span> + <span className="tag">{list.concepts.length}</span> + </div> + <div className="concept-chips"> + {list.concepts.slice(0, 40).map((c) => ( + <button + key={c} + className="concept-chip" + onClick={() => { setView("query"); setTimeout(() => window.dispatchEvent(new CustomEvent("openkb:prefill-query", { detail: t("prefillTemplate").replace("{concept}", c) })), 30); }} + > + {c} + </button> + ))} + </div> + </div> + )} + + <div className="panel"> + <div className="panel-head"><span className="panel-title">{t("recentDocs")}</span></div> + <div className="panel-body"> + {list.documents && list.documents.length > 0 ? ( + <table className="table"> + <thead><tr><th>{t("docName")}</th><th>{t("docType")}</th><th>{t("pages")}</th></tr></thead> + <tbody> + {list.documents.slice(-8).reverse().map((d) => ( + <tr key={d.hash}> + <td><span className="icon-cell"><span className="file-ico">{d.display_type || d.type || "FILE"}</span><span className="cell-name">{d.name}</span></span></td> + <td><span className="tag">{d.display_type || d.type || "—"}</span></td> + <td className="cell-meta">{d.pages != null ? d.pages : "—"}</td> + </tr> + ))} + </tbody> + </table> + ) : ( + <EmptyState title={t("noDocsYet")} desc={t("noDocsYetDesc")} /> + )} + </div> + </div> + + {(status.last_compile || status.last_lint) && ( + <div className="panel"> + <div className="panel-head"><span className="panel-title">{t("activity")}</span></div> + <div className="panel-body"> + {status.last_compile && <div className="log-line ok" style={{ padding: "4px 16px" }}>{t("lastCompile")}: {fmtTime(status.last_compile)}</div>} + {status.last_lint && <div className="log-line ok" style={{ padding: "4px 16px" }}>{t("lastLint")}: {fmtTime(status.last_lint)}</div>} + </div> + </div> + )} + </> + ); +} diff --git a/frontend/src/views/Query.jsx b/frontend/src/views/Query.jsx new file mode 100644 index 00000000..cd457ef1 --- /dev/null +++ b/frontend/src/views/Query.jsx @@ -0,0 +1,114 @@ +import { useState, useRef, useEffect } from "react"; +import { Search, Send, StopCircle } from "lucide-react"; +import { useSSEStream } from "../hooks/useSSEStream.js"; +import { useApp } from "../state/AppContext.jsx"; +import { useI18n } from "../i18n.jsx"; +import EmptyState from "../components/EmptyState.jsx"; +import Markdown from "../components/Markdown.jsx"; + +function esc(s) { + return String(s == null ? "" : s).replace(/[&<>]/g, (c) => ({ "&": "&", "<": "<", ">": ">" }[c])); +} + +export default function Query({ kb }) { + const { inspReset, inspAdd, inspDone, toastMsg } = useApp(); + const { t } = useI18n(); + const { busy, start, stop } = useSSEStream(); + const [q, setQ] = useState(""); + const [msgs, setMsgs] = useState([]); + const msgIdRef = useRef(0); + const taRef = useRef(null); + const scrollRef = useRef(null); + + useEffect(() => { + function onPrefill(e) { setQ(e.detail); if (taRef.current) taRef.current.focus(); } + window.addEventListener("openkb:prefill-query", onPrefill); + return () => window.removeEventListener("openkb:prefill-query", onPrefill); + }, []); + + useEffect(() => { if (scrollRef.current) scrollRef.current.scrollTop = scrollRef.current.scrollHeight; }, [msgs]); + + // Stop any in-flight stream when the KB changes. + useEffect(() => { return () => stop(); }, [kb]); + + function autosize() { + const ta = taRef.current; + if (ta) { ta.style.height = "auto"; ta.style.height = Math.min(ta.scrollHeight, 140) + "px"; } + } + + async function run() { + const question = q.trim(); + if (!question || busy) return; + setQ(""); + if (taRef.current) taRef.current.style.height = "auto"; + inspReset(true); + const pairId = ++msgIdRef.current; + const pair = { id: pairId, user: question, acc: "" }; + setMsgs((m) => [...m, pair]); + const onAbort = () => { + setMsgs((m) => m.map((x) => x.id === pairId ? { ...x, aborted: true } : x)); + inspAdd("tool", t("stopped"), t("userInterrupted")); + }; + try { + await start( + { path: "/api/v1/query", payload: { kb, question, stream: true } }, + (ev, d) => { + if (ev === "tool_call") inspAdd("tool", t("retrieve") + " · " + (d.name || "tool"), `<code>${esc((d.arguments || "").slice(0, 120))}</code>`); + else if (ev === "delta") { + pair.acc += d.text || ""; + setMsgs((m) => m.map((x) => x.id === pairId ? { ...pair } : x)); + } else if (ev === "final") { + pair.acc = d.answer || pair.acc; + setMsgs((m) => m.map((x) => x.id === pairId ? { ...pair } : x)); + inspAdd("done", t("completed"), t("reasoningDone")); + } else if (ev === "error") { + pair.acc = `<span style="color:var(--red)">${esc(d.message)}</span>`; + setMsgs((m) => m.map((x) => x.id === pairId ? { ...pair } : x)); + inspAdd("error", t("error"), esc(d.message)); + } + }, + onAbort + ); + } finally { + inspDone(); + } + } + + return ( + <div className="qa-wrap"> + <div className="qa-stream" ref={scrollRef}> + {msgs.length === 0 ? ( + <EmptyState + icon={<Search size={40} strokeWidth={1.5} />} + title={t("askKb")} + desc={t("askKbDesc")} + /> + ) : ( + msgs.map((m, i) => ( + <div className="msg" key={i}> + <div className="msg-role user"><span className="role-dot" />{t("you")}</div> + <div className="msg-bubble user">{m.user}</div> + <div className="msg-role"><span className="role-dot" />OpenKB</div> + <div className="msg-bubble">{m.acc ? <Markdown>{m.acc}</Markdown> : (m.aborted ? <span className="cell-meta">{t("stopped")}</span> : <span className="spinner-wrap"><span className="spinner" /></span>)}</div> + </div> + )) + )} + </div> + <div className="qa-input-bar"> + <textarea + ref={taRef} + className="qa-input" + placeholder={t("typeQuestion")} + rows={1} + value={q} + onChange={(e) => { setQ(e.target.value); autosize(); }} + // isComposing: 避免拼音输入法确认候选词时误触发提交 + onKeyDown={(e) => { if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) { e.preventDefault(); run(); } }} + /> + {busy + ? <button className="btn btn-ghost" onClick={stop}><StopCircle size={15} /> {t("stopGeneration")}</button> + : <button className="btn btn-primary" onClick={run}><Send size={15} /> {t("ask")}</button>} + </div> + </div> + ); +} diff --git a/frontend/vite.config.js b/frontend/vite.config.js new file mode 100644 index 00000000..b3176491 --- /dev/null +++ b/frontend/vite.config.js @@ -0,0 +1,22 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +// Dev server proxies /api to the OpenKB REST API on :8000; production serves +// the built bundle from the same origin via FastAPI StaticFiles. +export default defineConfig({ + plugins: [react()], + base: "/", + build: { + outDir: "../web", + emptyOutDir: true, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8000", + changeOrigin: true, + }, + }, + }, +}); diff --git a/openkb/agent/chat.py b/openkb/agent/chat.py index 3d275306..6177d993 100644 --- a/openkb/agent/chat.py +++ b/openkb/agent/chat.py @@ -13,7 +13,7 @@ import sys import time from pathlib import Path -from typing import Any +from typing import Any, AsyncIterator from prompt_toolkit import PromptSession from prompt_toolkit.completion import Completer, Completion, PathCompleter @@ -23,7 +23,12 @@ from prompt_toolkit.styles import Style from openkb.agent.chat_session import ChatSession -from openkb.agent.query import MAX_TURNS, build_chat_agent +from openkb.agent.query import ( + MAX_TURNS, + build_chat_agent, + build_query_agent, + iter_agent_response_events, +) from openkb.log import append_log @@ -847,6 +852,59 @@ async def _handle_slash_critique(arg: str, kb_dir: Path, style: Style) -> None: _fmt(style, ("class:slash.ok", f"Critique pass complete: {rel_str}\n")) +def build_chat_session_agent( + kb_dir: Path, + session: ChatSession, + bundle: "LlmCredentialBundle | None" = None, +) -> Any: + """Build the query-backed agent for one chat session (REST ``/chat``). + + Thin wrapper that resolves language from the session/config and delegates + to ``build_query_agent`` with the session's model. Kept in chat.py (not + query.py) so the query module's ``build_chat_agent`` signature stays + unchanged for the CLI; the API uses this session-aware variant instead. + """ + from openkb.config import load_config + + config = load_config(kb_dir / ".openkb" / "config.yaml") + language = session.language or config.get("language", "en") + wiki_root = str(kb_dir / "wiki") + return build_query_agent(wiki_root, session.model, language=language, bundle=bundle) + + +async def iter_chat_turn_events( + agent: Any, + session: ChatSession, + user_input: str, + *, + run_config: Any = None, +) -> AsyncIterator[dict[str, Any]]: + """Yield non-TTY events for one chat turn and persist the final turn. + + Used by the REST ``/chat`` SSE stream: forwards delta/tool_call events from + ``iter_agent_response_events`` unchanged, and on the final event records + the turn into the session (history + counts) before re-emitting it with + ``session_id``/``turn_count``. + """ + new_input = session.history + [{"role": "user", "content": user_input}] + + async for event in iter_agent_response_events(agent, new_input, max_turns=MAX_TURNS, run_config=run_config): + if event["event"] != "final": + yield event + continue + + data = event["data"] + answer = data["answer"] + session.record_turn(user_input, answer, data["history"]) + yield { + "event": "final", + "data": { + "answer": answer, + "session_id": session.id, + "turn_count": session.turn_count, + }, + } + async def run_chat( kb_dir: Path, session: ChatSession, diff --git a/openkb/agent/compiler.py b/openkb/agent/compiler.py index af3ac1bd..93ed7ee9 100644 --- a/openkb/agent/compiler.py +++ b/openkb/agent/compiler.py @@ -326,14 +326,24 @@ def _fmt_messages(messages: list[dict], max_content: int = 200) -> str: return "\n".join(parts) -def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str: +def _llm_call(model, messages, step_name, *, bundle=None, **kwargs) -> str: """Single LLM call with animated progress and debug logging.""" - extra_headers = get_extra_headers() - if extra_headers: - kwargs.setdefault("extra_headers", extra_headers) - timeout = get_timeout() - if timeout is not None: - kwargs.setdefault("timeout", timeout) + if bundle is not None: + extra_headers = bundle.extra_headers + timeout = bundle.timeout + if extra_headers: + kwargs.setdefault("extra_headers", dict(extra_headers)) + if timeout is not None: + kwargs.setdefault("timeout", timeout) + kwargs.setdefault("api_key", bundle.api_key) + kwargs.setdefault("base_url", bundle.base_url) + else: + extra_headers = get_extra_headers() + if extra_headers: + kwargs.setdefault("extra_headers", extra_headers) + timeout = get_timeout() + if timeout is not None: + kwargs.setdefault("timeout", timeout) logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages)) if kwargs: logger.debug("LLM kwargs [%s]: %s", step_name, kwargs) @@ -351,14 +361,24 @@ def _llm_call(model: str, messages: list[dict], step_name: str, **kwargs) -> str return content.strip() -async def _llm_call_async(model: str, messages: list[dict], step_name: str, **kwargs) -> str: +async def _llm_call_async(model, messages, step_name, *, bundle=None, **kwargs) -> str: """Async LLM call with timing output and debug logging.""" - extra_headers = get_extra_headers() - if extra_headers: - kwargs.setdefault("extra_headers", extra_headers) - timeout = get_timeout() - if timeout is not None: - kwargs.setdefault("timeout", timeout) + if bundle is not None: + extra_headers = bundle.extra_headers + timeout = bundle.timeout + if extra_headers: + kwargs.setdefault("extra_headers", dict(extra_headers)) + if timeout is not None: + kwargs.setdefault("timeout", timeout) + kwargs.setdefault("api_key", bundle.api_key) + kwargs.setdefault("base_url", bundle.base_url) + else: + extra_headers = get_extra_headers() + if extra_headers: + kwargs.setdefault("extra_headers", extra_headers) + timeout = get_timeout() + if timeout is not None: + kwargs.setdefault("timeout", timeout) logger.debug("LLM request [%s]:\n%s", step_name, _fmt_messages(messages)) if kwargs: logger.debug("LLM kwargs [%s]: %s", step_name, kwargs) @@ -1391,6 +1411,7 @@ async def _compile_concepts( doc_type: str = "short", rewrite_summary: bool = False, entity_types: list[str] | None = None, + bundle=None, ) -> None: """Shared Steps 2-4: concepts plan → generate/update → index. @@ -1426,7 +1447,7 @@ async def _compile_concepts( concept_briefs=concept_briefs, entity_briefs=entity_briefs, ).replace("__ENTITY_TYPES__", types_str)}, - ], "concepts-plan", response_format=_JSON_RESPONSE_FORMAT) + ], "concepts-plan", response_format=_JSON_RESPONSE_FORMAT, bundle=bundle) def _write_v1_summary_stripped() -> None: """Fallback writer for the v1 summary on early-return paths. @@ -1613,7 +1634,7 @@ async def _gen_create(concept: dict) -> tuple[str, str, bool, str]: title=title, doc_name=doc_name, update_instruction="", )}, - ], f"concept: {name}", response_format=_JSON_RESPONSE_FORMAT) + ], f"concept: {name}", response_format=_JSON_RESPONSE_FORMAT, bundle=bundle) try: parsed = _parse_json(raw) brief = parsed.get("description", "") @@ -1648,7 +1669,7 @@ async def _gen_update(concept: dict) -> tuple[str, str, bool, str]: title=title, doc_name=doc_name, existing_content=existing_content, )}, - ], f"update: {name}", response_format=_JSON_RESPONSE_FORMAT) + ], f"update: {name}", response_format=_JSON_RESPONSE_FORMAT, bundle=bundle) try: parsed = _parse_json(raw) brief = parsed.get("description", "") @@ -1673,7 +1694,7 @@ async def _gen_entity_create(ent: dict) -> tuple[str, str, str, str]: {"role": "user", "content": _ENTITY_PAGE_USER.format( title=title, type=etype, doc_name=doc_name, ).replace("__ENTITY_TYPES__", types_str)}, - ], f"entity: {name}", response_format=_JSON_RESPONSE_FORMAT) + ], f"entity: {name}", response_format=_JSON_RESPONSE_FORMAT, bundle=bundle) try: parsed = _parse_json(raw) brief = parsed.get("description", "") @@ -1707,7 +1728,7 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: title=title, type=etype, doc_name=doc_name, existing_content=existing_content, ).replace("__ENTITY_TYPES__", types_str)}, - ], f"entity-update: {name}", response_format=_JSON_RESPONSE_FORMAT) + ], f"entity-update: {name}", response_format=_JSON_RESPONSE_FORMAT, bundle=bundle) try: parsed = _parse_json(raw) brief = parsed.get("description", "") @@ -1855,7 +1876,7 @@ async def _gen_entity_update(ent: dict) -> tuple[str, str, str, str]: summary_msg, # cached (BP2) — contains the v1 summary text known_targets_msg, # cached (BP3) — whitelist {"role": "user", "content": _SUMMARY_REWRITE_USER}, - ], "summary-rewrite") + ], "summary-rewrite", bundle=bundle) candidate = rewrite_raw.strip() # Strip frontmatter if the model added one anyway. cand_parts = frontmatter.split(candidate) @@ -1943,6 +1964,7 @@ async def compile_short_doc( kb_dir: Path, model: str, max_concurrency: int = DEFAULT_COMPILE_CONCURRENCY, + bundle=None, ) -> None: """Compile a short document using a multi-step LLM pipeline with caching. @@ -1976,7 +1998,8 @@ async def compile_short_doc( # v2 (with a whitelist of known wikilink targets) inside # _compile_concepts before being written to disk. summary_raw = _llm_call(model, [system_msg, doc_msg], "summary", - response_format=_JSON_RESPONSE_FORMAT) + response_format=_JSON_RESPONSE_FORMAT, + bundle=bundle) try: summary_parsed = _parse_json(summary_raw) doc_brief = summary_parsed.get("description", "") @@ -1991,6 +2014,7 @@ async def compile_short_doc( wiki_dir, kb_dir, model, system_msg, doc_msg, summary, doc_name, max_concurrency, doc_brief=doc_brief, doc_type="short", rewrite_summary=True, entity_types=entity_types, + bundle=bundle, ) finally: # Close per-loop litellm async clients before asyncio.run tears this @@ -2006,6 +2030,7 @@ async def compile_long_doc( model: str, doc_description: str = "", max_concurrency: int = DEFAULT_COMPILE_CONCURRENCY, + bundle=None, ) -> None: """Compile a long (PageIndex) document's concepts and index. @@ -2047,7 +2072,7 @@ async def compile_long_doc( ))} # --- Step 1: Generate overview --- - overview = _llm_call(model, [system_msg, doc_msg], "overview") + overview = _llm_call(model, [system_msg, doc_msg], "overview", bundle=bundle) # --- Steps 2-4: Concept plan → generate/update → index --- try: @@ -2055,6 +2080,7 @@ async def compile_long_doc( wiki_dir, kb_dir, model, system_msg, doc_msg, overview, doc_name, max_concurrency, doc_brief=doc_description, doc_type="pageindex", entity_types=entity_types, + bundle=bundle, ) finally: # Close per-loop litellm async clients before asyncio.run tears this diff --git a/openkb/agent/linter.py b/openkb/agent/linter.py index 0519003a..5f583a8b 100644 --- a/openkb/agent/linter.py +++ b/openkb/agent/linter.py @@ -7,7 +7,7 @@ from agents.model_settings import ModelSettings from openkb.agent.tools import list_wiki_files, read_wiki_file -from openkb.config import get_extra_headers, get_timeout_extra_args +from openkb.config import get_extra_headers, get_timeout_extra_args, LlmCredentialBundle MAX_TURNS = 50 from openkb.schema import get_agents_md @@ -43,7 +43,12 @@ """ -def build_lint_agent(wiki_root: str, model: str, language: str = "en") -> Agent: +def build_lint_agent( + wiki_root: str, + model: str, + language: str = "en", + bundle: LlmCredentialBundle | None = None, +) -> Agent: """Build the semantic knowledge-lint agent. Args: @@ -82,13 +87,13 @@ def read_file(path: str) -> str: tools=[list_files, read_file], model=f"litellm/{model}", model_settings=ModelSettings( - extra_headers=get_extra_headers() or None, - extra_args=get_timeout_extra_args(), + extra_headers=(bundle.extra_headers if bundle else get_extra_headers()) or None, + extra_args=({"timeout": bundle.timeout} if bundle and bundle.timeout is not None else get_timeout_extra_args()), ), ) -async def run_knowledge_lint(kb_dir: Path, model: str) -> str: +async def run_knowledge_lint(kb_dir: Path, model: str, *, bundle: LlmCredentialBundle | None = None, run_config=None) -> str: """Run the semantic knowledge lint agent against the wiki. Args: @@ -105,7 +110,7 @@ async def run_knowledge_lint(kb_dir: Path, model: str) -> str: language: str = config.get("language", "en") wiki_root = str(kb_dir / "wiki") - agent = build_lint_agent(wiki_root, model, language=language) + agent = build_lint_agent(wiki_root, model, language=language, bundle=bundle) prompt = ( "Please audit this knowledge base wiki for semantic quality issues: " @@ -114,5 +119,5 @@ async def run_knowledge_lint(kb_dir: Path, model: str) -> str: "entities as needed. Produce a structured Markdown report." ) - result = await Runner.run(agent, prompt, max_turns=MAX_TURNS) + result = await Runner.run(agent, prompt, max_turns=MAX_TURNS, run_config=run_config) if run_config else await Runner.run(agent, prompt, max_turns=MAX_TURNS) return result.final_output or "Knowledge lint completed. No output produced." diff --git a/openkb/agent/query.py b/openkb/agent/query.py index 5a755d76..1b4d2f92 100644 --- a/openkb/agent/query.py +++ b/openkb/agent/query.py @@ -2,11 +2,13 @@ from __future__ import annotations from pathlib import Path +from typing import Any, AsyncIterator from agents import Agent, Runner, function_tool from agents import ToolOutputImage, ToolOutputText from openkb.config import get_extra_headers, get_timeout_extra_args +from openkb.config import LlmCredentialBundle from openkb.agent.tools import ( get_wiki_page_content, read_wiki_file, @@ -48,7 +50,12 @@ """ -def build_query_agent(wiki_root: str, model: str, language: str = "en") -> Agent: +def build_query_agent( + wiki_root: str, + model: str, + language: str = "en", + bundle: "LlmCredentialBundle | None" = None, +) -> Agent: """Build and return the Q&A agent.""" schema_md = get_agents_md(Path(wiki_root)) instructions = _QUERY_INSTRUCTIONS_TEMPLATE.format(schema_md=schema_md) @@ -90,6 +97,9 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: from agents.model_settings import ModelSettings + _extra_headers = bundle.extra_headers if bundle else get_extra_headers() + _timeout_args = {"timeout": bundle.timeout} if bundle and bundle.timeout is not None else get_timeout_extra_args() + return Agent( name="wiki-query", instructions=instructions, @@ -97,12 +107,65 @@ def get_image(image_path: str) -> ToolOutputImage | ToolOutputText: model=f"litellm/{model}", model_settings=ModelSettings( parallel_tool_calls=False, - extra_headers=get_extra_headers() or None, - extra_args=get_timeout_extra_args(), + extra_headers=_extra_headers or None, + extra_args=_timeout_args, ), ) +async def iter_agent_response_events( + agent: Agent, + input_data: str | list[dict[str, Any]], + *, + max_turns: int = MAX_TURNS, + run_config: Any = None, +) -> AsyncIterator[dict[str, Any]]: + """Yield non-TTY events for a streamed agent response. + + The CLI renders these events to stdout; the REST API serializes the same + events as SSE. Events: ``{"event": "delta", "data": {"text": ...}}`` for + each response-text delta, ``{"event": "tool_call", "data": {...}}`` for + tool invocations, and a final ``{"event": "final", "data": {"answer": ..., + "history": [...]}}`` carrying the complete answer and reusable Agents SDK + history. + """ + from agents import RawResponsesStreamEvent, RunItemStreamEvent + from openai.types.responses import ResponseTextDeltaEvent + + result = Runner.run_streamed(agent, input_data, max_turns=max_turns, run_config=run_config) if run_config else Runner.run_streamed(agent, input_data, max_turns=max_turns) + collected: list[str] = [] + + async for event in result.stream_events(): + if isinstance(event, RawResponsesStreamEvent): + if isinstance(event.data, ResponseTextDeltaEvent): + text = event.data.delta + if text: + collected.append(text) + yield {"event": "delta", "data": {"text": text}} + elif isinstance(event, RunItemStreamEvent): + item = event.item + if item.type == "tool_call_item": + raw_item = item.raw_item + yield { + "event": "tool_call", + "data": { + "name": getattr(raw_item, "name", "?"), + "arguments": getattr(raw_item, "arguments", "") or "", + }, + } + + answer = "".join(collected).strip() + if not answer: + answer = (result.final_output or "").strip() + yield { + "event": "final", + "data": { + "answer": answer, + "history": result.to_input_list(), + }, + } + + def build_chat_agent( kb_dir: Path, model: str, @@ -254,6 +317,8 @@ async def run_query( stream: bool = False, *, raw: bool = False, + run_config: Any = None, + bundle: LlmCredentialBundle | None = None, ) -> str: """Run a Q&A query against the knowledge base. @@ -279,10 +344,10 @@ async def run_query( wiki_root = str(kb_dir / "wiki") - agent = build_query_agent(wiki_root, model, language=language) + agent = build_query_agent(wiki_root, model, language=language, bundle=bundle) if not stream: - result = await Runner.run(agent, question, max_turns=MAX_TURNS) + result = await Runner.run(agent, question, max_turns=MAX_TURNS, run_config=run_config) if run_config else await Runner.run(agent, question, max_turns=MAX_TURNS) return result.final_output or "" import os @@ -315,7 +380,7 @@ def _start_live() -> Live | None: live: Live | None = None last_was_text = False need_blank_before_text = False - result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS) + result = Runner.run_streamed(agent, question, max_turns=MAX_TURNS, run_config=run_config) if run_config else Runner.run_streamed(agent, question, max_turns=MAX_TURNS) collected: list[str] = [] segment: list[str] = [] try: @@ -375,3 +440,32 @@ def _start_live() -> Live | None: live.stop() print() return "".join(collected) if collected else result.final_output or "" + + +def build_run_config_from_bundle(model: str, bundle: "LlmCredentialBundle | None") -> Any: + """Build an Agents-SDK `RunConfig` from a credential bundle. + + When *bundle* is `None` (CLI path), returns `None` so the runner falls + back to the default provider (process-wide `litellm.api_key` / env vars). + When a bundle is supplied, a dedicated `LitellmModel` instance is created + with the per-KB `api_key` and `base_url` so concurrent requests on the + shared event-loop thread never read each other's credentials. + + The model is passed to `LitellmModel` *verbatim* (e.g. ``openai/gpt-4o``) + because `LitellmModel` feeds it straight to ``litellm.acompletion``. The + ``litellm/`` prefix is an Agent-layer convention to select the backend and + must NOT be added here -- doing so yields ``litellm/openai/...`` which + litellm rejects as an unknown provider. + """ + if bundle is None: + return None + from agents import RunConfig + from agents.extensions.models.litellm_model import LitellmModel + + litellm_model = LitellmModel( + model=model, + base_url=bundle.base_url, + api_key=bundle.api_key, + ) + return RunConfig(model=litellm_model) + diff --git a/openkb/api.py b/openkb/api.py new file mode 100644 index 00000000..f3766fb2 --- /dev/null +++ b/openkb/api.py @@ -0,0 +1,1284 @@ +"""FastAPI REST service for OpenKB query and chat.""" +from __future__ import annotations + +import argparse +import asyncio +import json +import os +import time +import re +import hmac +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncIterator + +import litellm +from agents import set_tracing_disabled +from dotenv import load_dotenv +from fastapi import Depends, FastAPI, File, Form, HTTPException, Query, Request, UploadFile, status +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse +from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer +from pydantic import BaseModel, Field +from starlette.concurrency import run_in_threadpool +from starlette.staticfiles import StaticFiles + +from openkb.agent.chat import build_chat_session_agent, iter_chat_turn_events +from openkb.agent.chat_session import ChatSession, delete_session, list_sessions, load_session +from openkb.agent.query import build_query_agent, iter_agent_response_events, run_query, build_run_config_from_bundle +from openkb.config import resolve_credential_bundle +from openkb.cli import ( + SUPPORTED_EXTENSIONS, + _add_for_api, + _setup_llm_key, + get_kb_list, + get_kb_status, + initialize_kb, + run_remove_for_api, + run_lint_report, + iter_recompile, +) +from openkb.config import ( + DEFAULT_CONFIG, + kb_root_dir, + load_config, + register_kb_alias, + resolve_kb_alias, + validate_kb_name, +) +from openkb.log import append_log +from openkb.watch_service import WatchRegistry + +set_tracing_disabled(True) +os.environ.setdefault("LITELLM_LOCAL_MODEL_COST_MAP", "true") +litellm.suppress_debug_info = True +load_dotenv() + +security = HTTPBearer(auto_error=False) +UPLOAD_CHUNK_BYTES = 1024 * 1024 +MAX_UPLOAD_FILE_BYTES = int( + os.environ.get("OPENKB_MAX_UPLOAD_FILE_BYTES", str(100 * 1024 * 1024)) +) +MAX_UPLOAD_REQUEST_BYTES = int( + os.environ.get("OPENKB_MAX_UPLOAD_REQUEST_BYTES", str(500 * 1024 * 1024)) +) + + +class QueryRequest(BaseModel): + kb: str = Field(..., min_length=1) + question: str = Field(..., min_length=1) + stream: bool = True + save: bool = False + + +class QueryResponse(BaseModel): + answer: str + saved_path: str | None = None + + +class ChatRequest(BaseModel): + kb: str = Field(..., min_length=1) + message: str = Field(..., min_length=1) + session_id: str | None = None + stream: bool = True + + +class ChatResponse(BaseModel): + session_id: str + answer: str + turn_count: int + + +class ChatSessionItem(BaseModel): + id: str + title: str + turn_count: int + updated_at: str + model: str + + +class ChatSessionListResponse(BaseModel): + kb: str + sessions: list[ChatSessionItem] + + +class ChatSessionLoadResponse(BaseModel): + session_id: str + title: str + turn_count: int + user_turns: list[str] + assistant_texts: list[str] + + +class ChatSessionLoadRequest(BaseModel): + kb: str = Field(..., min_length=1) + session_id: str = Field(..., min_length=1) + + +class ChatSessionDeleteRequest(BaseModel): + kb: str = Field(..., min_length=1) + session_id: str = Field(..., min_length=1) + + +class ChatSessionDeleteResponse(BaseModel): + deleted: bool + + +class InitRequest(BaseModel): + kb: str = Field(..., min_length=1) + model: str | None = None + api_key: str | None = None + openai_api_base: str | None = None + + +class EnvWritten(BaseModel): + api_key: bool + openai_api_base: bool + + +class InitResponse(BaseModel): + kb: str + created: bool + env_written: EnvWritten + message: str + + +class AddFileItem(BaseModel): + original_name: str + saved_path: str | None = None + status: str + message: str + + +class AddResponse(BaseModel): + kb: str + files: list[AddFileItem] + added_count: int + skipped_count: int + failed_count: int + + +class KbRequest(BaseModel): + kb: str = Field(..., min_length=1) + + +class LintRequest(BaseModel): + kb: str = Field(..., min_length=1) + fix: bool = False + + +class DocumentItem(BaseModel): + hash: str + name: str + type: str + display_type: str + pages: int | None = None + + +class ListResponse(BaseModel): + documents: list[DocumentItem] + document_count: int + summaries: list[str] + concepts: list[str] + reports: list[str] + + +class StatusResponse(BaseModel): + directories: dict[str, int] + raw_count: int + total_indexed: int + last_compile: str | None = None + last_lint: str | None = None + + +class LintResponse(BaseModel): + skipped: bool + reason: str | None = None + message: str + structural_report: str | None = None + knowledge_report: str | None = None + report_path: str | None = None + lint_files_changed: int | None = None + lint_ghosts_removed: int | None = None + + +class RemoveRequest(BaseModel): + kb: str = Field(..., min_length=1) + identifier: str = Field(..., min_length=1) + keep_raw: bool = False + keep_empty: bool = False + dry_run: bool = False + stream: bool = False + + +class RemoveActionItem(BaseModel): + tag: str + target: str + + +class RemoveResponse(BaseModel): + status: str + name: str | None = None + doc_name: str | None = None + actions: list[RemoveActionItem] = [] + concepts_deleted: list[str] = [] + entities_deleted: list[str] = [] + lint_files_changed: int | None = None + lint_ghosts_removed: int | None = None + pageindex_message: str | None = None + pageindex_error: str | None = None + message: str | None = None + candidates: list[dict[str, str]] = [] + + +class RecompileDocItem(BaseModel): + name: str | None = None + doc_name: str | None = None + type: str + status: str + elapsed: float | None = None + message: str | None = None + + +class RecompileRequest(BaseModel): + kb: str = Field(..., min_length=1) + doc_name: str | None = None + all_docs: bool = False + dry_run: bool = False + refresh_schema: bool = False + stream: bool = False + + +class RecompileTargetItem(BaseModel): + name: str + doc_name: str + type: str + + +class RecompileResponse(BaseModel): + status: str + total: int + recompiled: int + skipped: int + docs: list[RecompileDocItem] = [] + targets: list[RecompileTargetItem] | None = None + candidates: list[dict[str, str]] | None = None + message: str | None = None + + +class WatchStartRequest(BaseModel): + kb: str = Field(..., min_length=1) + debounce: float = Field(default=2.0, gt=0) + + +class WatchEventItem(BaseModel): + ts: float + event: str + data: dict[str, Any] = {} + + +class WatchStatusResponse(BaseModel): + kb: str + active: bool + started_at: float | None = None + raw_dir: str | None = None + debounce: float | None = None + counters: dict[str, int] = {} + recent_events: list[WatchEventItem] = [] + + +class KbSummaryItem(BaseModel): + name: str + document_count: int = 0 + last_compile: str | None = None + has_raw: bool = False + + +class KbListResponse(BaseModel): + root: str + knowledge_bases: list[KbSummaryItem] + + +def create_app() -> FastAPI: + # One registry per app instance so each TestClient is isolated. + registry = WatchRegistry() + + # Per-KB asyncio locks for async mutation endpoints (lint/recompile). + # kb_ingest_lock tracks reentrancy in threading.local, but the event loop + # runs all requests on one thread, so concurrent same-KB mutations are + # mis-counted as re-entrant and bypass mutual exclusion. An asyncio.Lock + # serializes same-KB mutations *before* they enter the threading lock, + # eliminating the hazard without touching locks.py. + kb_mutation_locks: dict[str, asyncio.Lock] = {} + + def _kb_mutation_lock(kb: str) -> asyncio.Lock: + lock = kb_mutation_locks.get(kb) + if lock is None: + lock = asyncio.Lock() + kb_mutation_locks[kb] = lock + return lock + + @asynccontextmanager + async def lifespan(app: FastAPI): + try: + yield + finally: + registry.stop_all() + + app = FastAPI(title="OpenKB API", lifespan=lifespan) + + _configure_cors(app) + + @app.get("/api/v1/kbs", response_model=KbListResponse) + async def list_kbs_endpoint( + _: None = Depends(require_bearer_token), + ) -> KbListResponse: + return KbListResponse(**_list_knowledge_bases()) + + @app.post("/api/v1/init", response_model=InitResponse) + async def init_endpoint( + request: InitRequest, + _: None = Depends(require_bearer_token), + ) -> InitResponse: + try: + kb_name = validate_kb_name(request.kb) + kb_dir = (kb_root_dir() / kb_name).resolve() + # Run lock-holding work in a threadpool so each request gets its + # own threading.local (kb_ingest_lock reentrancy is per-thread) + # and the event loop is not blocked by file I/O / flock. + result = await run_in_threadpool( + _init_kb_for_api, + kb_dir, + kb_name, + model=request.model, + api_key=request.api_key, + openai_api_base=request.openai_api_base, + ) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except FileExistsError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Init failed: {exc}", + ) from exc + + return InitResponse( + kb=kb_name, + created=bool(result["created"]), + env_written=EnvWritten(**result["env_written"]), + message=str(result["message"]), + ) + + @app.post("/api/v1/add", response_model=AddResponse) + async def add_endpoint( + kb: str = Form(...), + stream: str = Form("true"), + files: list[UploadFile] = File(default=[]), + _: None = Depends(require_bearer_token), + ) -> Any: + resolved_kb_dir = _resolve_kb(kb) + bundle = resolve_credential_bundle(resolved_kb_dir) + if not files: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="No files uploaded.", + ) + saved_uploads = await _save_add_uploads(resolved_kb_dir, files) + if _parse_stream_form(stream): + return StreamingResponse( + _stream_add_uploads(kb, resolved_kb_dir, saved_uploads, bundle=bundle), + media_type="text/event-stream", + ) + return await _run_add_uploads(kb, resolved_kb_dir, saved_uploads, bundle=bundle) + + @app.post("/api/v1/query", response_model=QueryResponse) + async def query_endpoint( + request: QueryRequest, + _: None = Depends(require_bearer_token), + ) -> Any: + kb_dir = _resolve_kb(request.kb) + _setup_llm_key(kb_dir) + bundle = resolve_credential_bundle(kb_dir) + config = load_config(kb_dir / ".openkb" / "config.yaml") + model = config.get("model", DEFAULT_CONFIG["model"]) + run_config = build_run_config_from_bundle(model, bundle) + + if request.stream: + return StreamingResponse( + _stream_query(request, kb_dir, model, bundle=bundle), + media_type="text/event-stream", + ) + + try: + answer = await run_query(request.question, kb_dir, model, stream=False, run_config=run_config, bundle=bundle) + append_log(kb_dir / "wiki", "query", request.question) + saved_path = ( + _save_query_answer(kb_dir, request.question, answer) + if request.save + else None + ) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Query failed: {exc}", + ) from exc + return QueryResponse( + answer=answer, + saved_path=str(saved_path) if saved_path else None, + ) + + @app.post("/api/v1/chat", response_model=ChatResponse) + async def chat_endpoint( + request: ChatRequest, + _: None = Depends(require_bearer_token), + ) -> Any: + kb_dir = _resolve_kb(request.kb) + _setup_llm_key(kb_dir) + bundle = resolve_credential_bundle(kb_dir) + session = _load_or_create_session(kb_dir, request.session_id) + run_config = build_run_config_from_bundle(session.model, bundle) + + if request.stream: + return StreamingResponse( + _stream_chat(request, kb_dir, session, bundle=bundle), + media_type="text/event-stream", + ) + + try: + answer = "" + append_log(kb_dir / "wiki", "query", request.message) + agent = build_chat_session_agent(kb_dir, session, bundle=bundle) + async for event in iter_chat_turn_events(agent, session, request.message, run_config=run_config): + if event["event"] == "final": + answer = event["data"]["answer"] + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Chat failed: {exc}", + ) from exc + return ChatResponse( + session_id=session.id, + answer=answer, + turn_count=session.turn_count, + ) + + @app.post("/api/v1/chat/sessions", response_model=ChatSessionListResponse) + async def chat_sessions_endpoint( + request: KbRequest, + _: None = Depends(require_bearer_token), + ) -> ChatSessionListResponse: + kb_dir = _resolve_kb(request.kb) + try: + sessions = list_sessions(kb_dir) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"List sessions failed: {exc}", + ) from exc + return ChatSessionListResponse(kb=request.kb, sessions=sessions) + + @app.post("/api/v1/chat/sessions/load", response_model=ChatSessionLoadResponse) + async def chat_session_load_endpoint( + request: ChatSessionLoadRequest, + _: None = Depends(require_bearer_token), + ) -> ChatSessionLoadResponse: + kb_dir = _resolve_kb(request.kb) + try: + session = load_session(kb_dir, request.session_id) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Chat session not found: {request.session_id}", + ) from exc + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Load session failed: {exc}", + ) from exc + return ChatSessionLoadResponse( + session_id=session.id, + title=session.title, + turn_count=session.turn_count, + user_turns=session.user_turns, + assistant_texts=session.assistant_texts, + ) + + @app.post("/api/v1/chat/sessions/delete", response_model=ChatSessionDeleteResponse) + async def chat_session_delete_endpoint( + request: ChatSessionDeleteRequest, + _: None = Depends(require_bearer_token), + ) -> ChatSessionDeleteResponse: + kb_dir = _resolve_kb(request.kb) + try: + deleted = delete_session(kb_dir, request.session_id) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Delete session failed: {exc}", + ) from exc + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Chat session not found: {request.session_id}", + ) + return ChatSessionDeleteResponse(deleted=True) + + @app.post("/api/v1/list", response_model=ListResponse) + async def list_endpoint( + request: KbRequest, + _: None = Depends(require_bearer_token), + ) -> ListResponse: + kb_dir = _resolve_kb(request.kb) + try: + return ListResponse(**get_kb_list(kb_dir)) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"List failed: {exc}", + ) from exc + + @app.post("/api/v1/status", response_model=StatusResponse) + async def status_endpoint( + request: KbRequest, + _: None = Depends(require_bearer_token), + ) -> StatusResponse: + kb_dir = _resolve_kb(request.kb) + try: + return StatusResponse(**get_kb_status(kb_dir)) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Status failed: {exc}", + ) from exc + + @app.post("/api/v1/lint", response_model=LintResponse) + async def lint_endpoint( + request: LintRequest, + _: None = Depends(require_bearer_token), + ) -> LintResponse: + kb_dir = _resolve_kb(request.kb) + bundle = resolve_credential_bundle(kb_dir) + try: + # Only fix=True mutations need serialization; read-only lint + # (fix=False) is a report and may run concurrently. + if request.fix: + async with _kb_mutation_lock(request.kb): + return LintResponse(**await run_lint_report(kb_dir, fix=True, bundle=bundle)) + return LintResponse(**await run_lint_report(kb_dir, fix=False, bundle=bundle)) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Lint failed: {exc}", + ) from exc + @app.post("/api/v1/remove", response_model=RemoveResponse) + async def remove_endpoint( + request: RemoveRequest, + _: None = Depends(require_bearer_token), + ) -> Any: + kb_dir = _resolve_kb(request.kb) + if request.stream: + return StreamingResponse( + _stream_remove(request, kb_dir), + media_type="text/event-stream", + ) + result = await run_in_threadpool( + run_remove_for_api, + kb_dir, + request.identifier, + keep_raw=request.keep_raw, + keep_empty=request.keep_empty, + dry_run=request.dry_run, + ) + status_value = result.get("status") + if status_value == "not_found": + raise HTTPException(status_code=404, detail=result.get("message", "Document not found.")) + if status_value == "multiple": + raise HTTPException( + status_code=409, + detail={ + "message": "Identifier matches multiple documents.", + "candidates": result.get("candidates", []), + }, + ) + return RemoveResponse(**result) + + @app.post("/api/v1/recompile", response_model=RecompileResponse) + async def recompile_endpoint( + request: RecompileRequest, + _: None = Depends(require_bearer_token), + ) -> Any: + kb_dir = _resolve_kb(request.kb) + bundle = resolve_credential_bundle(kb_dir) + if request.stream: + return StreamingResponse( + _stream_recompile(request, kb_dir, _kb_mutation_lock(request.kb), bundle=bundle), + media_type="text/event-stream", + ) + # Aggregate the async generator into a single JSON response. Terminal + # errors map to HTTP codes; the final event carries the aggregate. + targets: list[dict] | None = None + candidates: list[dict[str, str]] | None = None + error_code: int | None = None + error_message: str | None = None + result: dict = {} + async with _kb_mutation_lock(request.kb): + async for event in iter_recompile( + kb_dir, + request.doc_name, + all_docs=request.all_docs, + dry_run=request.dry_run, + refresh_schema=request.refresh_schema, + bundle=bundle, + ): + name = event.get("event") + if name == "plan": + targets = event.get("targets", []) + elif name == "error": + error_code = event.get("code", 500) + error_message = event.get("message", "Recompile failed.") + candidates = event.get("candidates") + elif name == "final": + result = event + if error_code is not None: + if error_code == 409 and candidates is not None: + raise HTTPException( + status_code=409, + detail={"message": error_message, "candidates": candidates}, + ) + raise HTTPException(status_code=error_code, detail=error_message) + return RecompileResponse( + status=result.get("status", "done"), + total=result.get("total", 0), + recompiled=result.get("recompiled", 0), + skipped=result.get("skipped", 0), + docs=result.get("docs", []), + targets=targets, + candidates=candidates, + ) + @app.post("/api/v1/watch/start", response_model=WatchStatusResponse) + async def watch_start_endpoint( + request: WatchStartRequest, + _: None = Depends(require_bearer_token), + ) -> WatchStatusResponse: + kb_dir = _resolve_kb(request.kb) + try: + registry.start(request.kb, kb_dir, debounce=request.debounce) + except Exception as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Watch start failed: {exc}", + ) from exc + return WatchStatusResponse(**registry.status(request.kb)) + + @app.post("/api/v1/watch/stop", response_model=WatchStatusResponse) + async def watch_stop_endpoint( + request: KbRequest, + _: None = Depends(require_bearer_token), + ) -> WatchStatusResponse: + _resolve_kb(request.kb) + if not registry.stop(request.kb): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"No active watcher for KB: {request.kb}", + ) + return WatchStatusResponse(kb=request.kb, active=False) + + @app.post("/api/v1/watch/status", response_model=WatchStatusResponse) + async def watch_status_endpoint( + request: KbRequest, + _: None = Depends(require_bearer_token), + ) -> WatchStatusResponse: + _resolve_kb(request.kb) + return WatchStatusResponse(**registry.status(request.kb)) + + @app.get("/api/v1/watch/events") + async def watch_events_endpoint( + request: Request, + kb: str = Query(..., min_length=1), + max_events: int | None = Query(default=None, ge=1), + timeout_seconds: float | None = Query(default=None, ge=0), + _: None = Depends(require_bearer_token), + ) -> Any: + _resolve_kb(kb) + return StreamingResponse( + _stream_watch_events(registry, kb, max_events, timeout_seconds, request), + media_type="text/event-stream", + ) + + _mount_web_ui(app) + + return app + + +def _configure_cors(app: FastAPI) -> None: + """Allow browser frontends to call the API (configurable via env).""" + raw = os.environ.get("OPENKB_CORS_ORIGINS", "") + wildcard = raw.strip() == "*" + if wildcard: + origins = ["*"] + else: + origins = [o.strip() for o in raw.split(",") if o.strip()] or [ + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:8000", + "http://127.0.0.1:8000", + ] + # A wildcard origin with credentials is insecure: any site can issue + # credentialed cross-origin requests. Reject this combination by forcing + # credentials off for wildcards, which still allows unauthenticated + # cross-origin GETs but blocks cookie/token-bearing requests. + allow_credentials = not wildcard + app.add_middleware( + CORSMiddleware, + allow_origins=origins, + allow_credentials=allow_credentials, + allow_methods=["*"], + allow_headers=["*"], + ) + + +def _mount_web_ui(app: FastAPI) -> None: + """Serve the bundled web UI at ``/`` when the ``web/`` directory exists. + + Mounting under the API origin avoids cross-origin fetch from ``file://`` + so the browser SPA can call the REST endpoints directly. + """ + web_dir = Path(__file__).resolve().parent.parent / "web" + if web_dir.is_dir(): + app.mount("/", StaticFiles(directory=str(web_dir), html=True), name="web-ui") + + +def _list_knowledge_bases() -> dict[str, Any]: + """List knowledge bases under the server's ``OPENKB_KB_ROOT``. + + Used by the web UI's KB switcher. There is no persisted KB registry, so + discovery is directory-based: a child of ``OPENKB_KB_ROOT`` counts as a KB + when it has both ``.openkb`` and ``wiki`` subdirectories. + """ + root = kb_root_dir() + items: list[dict[str, Any]] = [] + if root.is_dir(): + for child in sorted(root.iterdir()): + if not child.is_dir(): + continue + if not _is_kb_dir(child): + continue + hashes_file = child / ".openkb" / "hashes.json" + doc_count = 0 + if hashes_file.exists(): + try: + doc_count = len(json.loads(hashes_file.read_text(encoding="utf-8"))) + except (ValueError, OSError): + doc_count = 0 + last_compile = None + summaries_dir = child / "wiki" / "summaries" + if summaries_dir.is_dir(): + mtimes = [p.stat().st_mtime for p in summaries_dir.glob("*.md")] + if mtimes: + last_compile = time.strftime( + "%Y-%m-%dT%H:%M:%S", time.localtime(max(mtimes)) + ) + items.append( + { + "name": child.name, + "document_count": doc_count, + "last_compile": last_compile, + "has_raw": (child / "raw").is_dir(), + } + ) + return {"root": str(root), "knowledge_bases": items} + +def require_bearer_token( + credentials: HTTPAuthorizationCredentials | None = Depends(security), +) -> None: + expected = os.environ.get("OPENKB_API_TOKEN") + if not expected: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="OPENKB_API_TOKEN is not configured.", + ) + if credentials is None or credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Bearer token required.", + ) + if not hmac.compare_digest(credentials.credentials, expected): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid bearer token.", + ) + + +def _resolve_kb(value: str) -> Path: + try: + kb_dir = resolve_kb_alias(value) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + if not _is_kb_dir(kb_dir): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Not a knowledge base: {value}", + ) + return kb_dir + + +def _is_kb_dir(kb_dir: Path) -> bool: + """A directory counts as a KB when it has both ``.openkb`` and ``wiki``.""" + return (kb_dir / ".openkb").is_dir() and (kb_dir / "wiki").is_dir() + + +def _init_kb_for_api( + kb_dir: Path, + kb_name: str, + *, + model: str | None, + api_key: str | None, + openai_api_base: str | None, +) -> dict: + """Run ``initialize_kb`` + ``register_kb_alias`` off the event loop. + + Both touch file locks (``register_kb_alias`` holds the global-config + lock); running them in a threadpool gives each request its own + ``threading.local`` so ``kb_ingest_lock``'s reentrancy bookkeeping is + correct, and avoids blocking the event loop. + """ + result = initialize_kb( + kb_dir, model=model, api_key=api_key, openai_api_base=openai_api_base, + ) + register_kb_alias(kb_name, kb_dir) + return result + + +def _save_query_answer(kb_dir: Path, question: str, answer: str) -> Path | None: + from openkb.cli import save_exploration + + return save_exploration(kb_dir, question, answer) + + +def _parse_stream_form(value: str | bool | None) -> bool: + if isinstance(value, bool): + return value + if value is None: + return True + return value.strip().lower() not in {"false", "0", "no", "off"} + + +def _safe_upload_name(filename: str | None) -> str: + name = Path(filename or "").name + if not name: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Uploaded file is missing a filename.", + ) + return name + + +def _unique_raw_path(raw_dir: Path, filename: str) -> Path: + candidate = raw_dir / filename + if not candidate.exists(): + return candidate + + stem = candidate.stem + suffix = candidate.suffix + counter = 1 + while True: + candidate = raw_dir / f"{stem}-{counter}{suffix}" + if not candidate.exists(): + return candidate + counter += 1 + + +async def _save_upload( + kb_dir: Path, + upload: UploadFile, + request_bytes_so_far: int, +) -> tuple[Path, int]: + filename = _safe_upload_name(upload.filename) + suffix = Path(filename).suffix.lower() + if suffix not in SUPPORTED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=( + f"Unsupported file type: {suffix}. " + f"Supported: {', '.join(sorted(SUPPORTED_EXTENSIONS))}" + ), + ) + + raw_dir = kb_dir / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + saved_path = _unique_raw_path(raw_dir, filename) + try: + file_bytes = 0 + with saved_path.open("wb") as handle: + while chunk := await upload.read(UPLOAD_CHUNK_BYTES): + file_bytes += len(chunk) + request_bytes = request_bytes_so_far + file_bytes + if file_bytes > MAX_UPLOAD_FILE_BYTES: + raise HTTPException( + status_code=413, + detail=( + f"Uploaded file exceeds limit of " + f"{MAX_UPLOAD_FILE_BYTES} bytes." + ), + ) + if request_bytes > MAX_UPLOAD_REQUEST_BYTES: + raise HTTPException( + status_code=413, + detail=( + f"Upload request exceeds limit of " + f"{MAX_UPLOAD_REQUEST_BYTES} bytes." + ), + ) + handle.write(chunk) + except Exception as exc: + saved_path.unlink(missing_ok=True) + if isinstance(exc, HTTPException): + raise + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Upload save failed: {exc}", + ) from exc + finally: + await upload.close() + return saved_path, file_bytes + + +def _summarize_add_results(kb: str, results: list[AddFileItem]) -> AddResponse: + return AddResponse( + kb=kb, + files=results, + added_count=sum(1 for item in results if item.status == "added"), + skipped_count=sum(1 for item in results if item.status == "skipped"), + failed_count=sum(1 for item in results if item.status == "failed"), + ) + + +def _model_payload(model: BaseModel) -> dict[str, Any]: + return model.model_dump() + + +async def _save_add_uploads( + kb_dir: Path, + files: list[UploadFile], +) -> list[tuple[Path, str]]: + saved_uploads: list[tuple[Path, str]] = [] + request_bytes = 0 + try: + for upload in files: + original_name = _safe_upload_name(upload.filename) + saved_path, file_bytes = await _save_upload(kb_dir, upload, request_bytes) + request_bytes += file_bytes + saved_uploads.append((saved_path, original_name)) + except Exception: + for saved_path, _ in saved_uploads: + saved_path.unlink(missing_ok=True) + raise + return saved_uploads + + +async def _run_add_uploads( + kb: str, + kb_dir: Path, + saved_uploads: list[tuple[Path, str]], + *, + bundle=None, +) -> AddResponse: + results = [] + for saved_path, original_name in saved_uploads: + results.append(await _add_saved_file(kb_dir, saved_path, original_name, bundle=bundle)) + return _summarize_add_results(kb, results) + + +async def _stream_add_uploads( + kb: str, + kb_dir: Path, + saved_uploads: list[tuple[Path, str]], + *, + bundle=None, +) -> AsyncIterator[str]: + yield _sse( + "start", + {"endpoint": "add", "kb": kb, "file_count": len(saved_uploads)}, + ) + results: list[AddFileItem] = [] + try: + for saved_path, original_name in saved_uploads: + yield _sse( + "uploaded", + {"original_name": original_name, "saved_path": str(saved_path)}, + ) + yield _sse( + "file_start", + {"original_name": original_name, "saved_path": str(saved_path)}, + ) + item = await _add_saved_file(kb_dir, saved_path, original_name, bundle=bundle) + results.append(item) + yield _sse("file_done", _model_payload(item)) + final = _summarize_add_results(kb, results) + yield _sse("final", _model_payload(final)) + except HTTPException as exc: + yield _sse("error", {"message": exc.detail}) + except Exception as exc: + yield _sse("error", {"message": f"Add failed: {exc}"}) + yield _sse("done", {}) + + +async def _add_saved_file(kb_dir: Path, saved_path: Path, original_name: str, *, bundle=None) -> AddFileItem: + result = await run_in_threadpool(_add_for_api, saved_path, kb_dir, bundle=bundle) + item = AddFileItem(**result.__dict__) + item.original_name = original_name + if item.status == "skipped": + saved_path.unlink(missing_ok=True) + item.saved_path = None + return item + + +def _load_or_create_session(kb_dir: Path, session_id: str | None) -> ChatSession: + if session_id: + try: + return load_session(kb_dir, session_id) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Chat session not found: {session_id}", + ) from exc + + config = load_config(kb_dir / ".openkb" / "config.yaml") + model = config.get("model", DEFAULT_CONFIG["model"]) + language = config.get("language", "en") + return ChatSession.new(kb_dir, model, language) + + +def _sse(event: str, data: dict[str, Any]) -> str: + payload = json.dumps(data, ensure_ascii=False) + return f"event: {event}\ndata: {payload}\n\n" + + +async def _stream_query( + request: QueryRequest, + kb_dir: Path, + model: str, + *, + bundle=None, +) -> AsyncIterator[str]: + yield _sse("start", {"endpoint": "query"}) + run_config = build_run_config_from_bundle(model, bundle) + try: + config = load_config(kb_dir / ".openkb" / "config.yaml") + language = config.get("language", "en") + agent = build_query_agent(str(kb_dir / "wiki"), model, language=language, bundle=bundle) + final_answer = "" + async for event in iter_agent_response_events(agent, request.question, run_config=run_config): + data = event["data"] + if event["event"] == "final": + final_answer = data["answer"] + saved_path = ( + _save_query_answer(kb_dir, request.question, final_answer) + if request.save + else None + ) + append_log(kb_dir / "wiki", "query", request.question) + yield _sse( + "final", + { + "answer": final_answer, + "saved_path": str(saved_path) if saved_path else None, + }, + ) + else: + yield _sse(event["event"], data) + except Exception as exc: + yield _sse("error", {"message": f"Query failed: {exc}"}) + yield _sse("done", {}) + + +async def _stream_chat( + request: ChatRequest, + kb_dir: Path, + session: ChatSession, + *, + bundle=None, +) -> AsyncIterator[str]: + yield _sse("start", {"endpoint": "chat", "session_id": session.id}) + run_config = build_run_config_from_bundle(session.model, bundle) + try: + append_log(kb_dir / "wiki", "query", request.message) + agent = build_chat_session_agent(kb_dir, session, bundle=bundle) + async for event in iter_chat_turn_events(agent, session, request.message, run_config=run_config): + yield _sse(event["event"], event["data"]) + except Exception as exc: + yield _sse("error", {"message": f"Chat failed: {exc}"}) + yield _sse("done", {}) + + +async def _stream_remove( + request: RemoveRequest, + kb_dir: Path, +) -> AsyncIterator[str]: + """SSE view of remove: start, plan, per-stage progress, final, done. + + Maps ``run_remove_for_api``'s status codes to events so a streaming + client can react to ``not_found`` / ``multiple`` / ``partial`` without + waiting on an HTTP error. + """ + yield _sse("start", {"endpoint": "remove", "identifier": request.identifier}) + try: + result = await run_in_threadpool( + run_remove_for_api, + kb_dir, + request.identifier, + keep_raw=request.keep_raw, + keep_empty=request.keep_empty, + dry_run=request.dry_run, + ) + status_value = result.get("status") + if status_value == "not_found": + yield _sse("error", {"code": 404, "message": "Document not found."}) + elif status_value == "multiple": + yield _sse( + "error", + {"code": 409, "message": "Identifier matches multiple documents.", + "candidates": result.get("candidates", [])}, + ) + else: + yield _sse("plan", { + "name": result.get("name"), + "doc_name": result.get("doc_name"), + "actions": result.get("actions", []), + }) + if status_value == "dry_run": + yield _sse("final", {"status": "dry_run", **result}) + else: + yield _sse("progress", {"stage": "wiki_cleanup"}) + yield _sse("final", {"status": status_value, **result}) + except Exception as exc: + yield _sse("error", {"message": f"Remove failed: {exc}"}) + yield _sse("done", {}) + + +async def _stream_recompile( + request: RecompileRequest, + kb_dir: Path, + mutation_lock: asyncio.Lock, + *, + bundle=None, +) -> AsyncIterator[str]: + """SSE view of recompile: start, per-doc progress, final, done. + + Maps ``iter_recompile``'s events to SSE so a streaming client can react + to ``doc`` (ok/skipped/error) and terminal ``error`` (404/409/etc.) as + they happen, without waiting on an HTTP error. + """ + yield _sse("start", {"endpoint": "recompile"}) + # Hold the per-KB asyncio.Lock for the entire stream so concurrent + # same-KB recompiles are serialized before kb_ingest_lock (which uses + # threading.local and miscounts reentrancy on the event-loop thread). + async with mutation_lock: + try: + async for event in iter_recompile( + kb_dir, + request.doc_name, + all_docs=request.all_docs, + dry_run=request.dry_run, + refresh_schema=request.refresh_schema, + bundle=bundle, + ): + name = event.get("event") + if name == "error": + yield _sse("error", { + "code": event.get("code", 500), + "message": event.get("message", "Recompile failed."), + **({"candidates": event["candidates"]} if "candidates" in event else {}), + }) + elif name == "plan": + yield _sse("plan", {"targets": event.get("targets", [])}) + elif name == "doc": + yield _sse("doc", {k: v for k, v in event.items() if k != "event"}) + elif name == "final": + yield _sse("final", {k: v for k, v in event.items() if k != "event"}) + except Exception as exc: + yield _sse("error", {"message": f"Recompile failed: {exc}"}) + yield _sse("done", {}) + + +# Default cap for /watch/events SSE so abandoned clients do not poll forever. +_WATCH_SSE_TIMEOUT = float(os.environ.get("OPENKB_WATCH_SSE_TIMEOUT", "300")) + +async def _stream_watch_events( + registry: WatchRegistry, + kb: str, + max_events: int | None, + timeout_seconds: float | None, + request: Request, +) -> AsyncIterator[str]: + """Tail a KB's watch event ring buffer as an SSE stream. + + Replays existing events then polls for new ones. Terminates when the + watcher stops, or when ``max_events``/``timeout_seconds`` is reached (so + bounded clients and tests can drain without hanging). With both unset the + stream is capped by a default timeout when none is given. + """ + state = registry.get(kb) + yield _sse("start", {"endpoint": "watch", "kb": kb, "active": state is not None}) + if state is None: + yield _sse("error", {"message": f"No active watcher for KB: {kb}"}) + yield _sse("done", {}) + return + if timeout_seconds is None: + timeout_seconds = _WATCH_SSE_TIMEOUT + next_seq = 0 + emitted = 0 + started = time.monotonic() + try: + while True: + if await request.is_disconnected(): + return + for ev in list(state.events): + if ev["seq"] < next_seq: + continue + next_seq = ev["seq"] + 1 + yield _sse(ev["event"], ev["data"]) + emitted += 1 + if ev["event"] == "watcher_stopped": + yield _sse("done", {}) + return + if max_events is not None and emitted >= max_events: + yield _sse("done", {}) + return + if timeout_seconds is not None and (time.monotonic() - started) >= timeout_seconds: + yield _sse("done", {}) + return + await asyncio.sleep(0.5) + except Exception as exc: + yield _sse("error", {"message": f"Watch events stream failed: {exc}"}) + yield _sse("done", {}) + + +app = create_app() + + +def main() -> None: + parser = argparse.ArgumentParser(description="Run the OpenKB REST API.") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8000) + parser.add_argument("--reload", action="store_true") + args = parser.parse_args() + + import uvicorn + + uvicorn.run("openkb.api:app", host=args.host, port=args.port, reload=args.reload) + + +if __name__ == "__main__": + main() diff --git a/openkb/cli.py b/openkb/cli.py index 28694987..0efe5a28 100644 --- a/openkb/cli.py +++ b/openkb/cli.py @@ -10,6 +10,7 @@ import asyncio import json import logging +from dataclasses import dataclass import shutil import sys import time @@ -45,7 +46,7 @@ def filter(self, record: logging.LogRecord) -> bool: from openkb.config import ( DEFAULT_CONFIG, load_config, save_config, load_global_config, register_kb, resolve_extra_headers, set_extra_headers, resolve_timeout, set_timeout, - resolve_litellm_settings, + resolve_litellm_settings, resolve_per_request_overrides, ) from openkb.converter import _registry_path, convert_document from openkb.indexer import import_cloud_document @@ -150,19 +151,9 @@ def _setup_llm_key(kb_dir: Path | None = None) -> None: config = load_config(config_path) model = config.get("model", "") provider = _extract_provider(str(model)) - extra_headers = resolve_extra_headers(config) - timeout = resolve_timeout(config) - litellm_settings = resolve_litellm_settings(config) - # `timeout` / `extra_headers` in the block route to the per-call - # stashes (replacing the legacy top-level keys); the rest are globals. - if "extra_headers" in litellm_settings: - extra_headers = resolve_extra_headers( - {"extra_headers": litellm_settings.pop("extra_headers")} - ) - if "timeout" in litellm_settings: - timeout = resolve_timeout( - {"timeout": litellm_settings.pop("timeout")} - ) + # Shared resolver so CLI and REST paths apply identical litellm.* + # override semantics (litellm.extra_headers/timeout override top-level). + extra_headers, timeout, litellm_settings = resolve_per_request_overrides(config) set_extra_headers(extra_headers) set_timeout(timeout) _apply_litellm_settings(litellm_settings) @@ -332,13 +323,13 @@ def _clear_existing_skill_dir(kb_dir: Path, name: str) -> None: shutil.rmtree(target) -def add_single_file(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]: +def add_single_file(file_path: Path, kb_dir: Path, *, bundle=None) -> Literal["added", "skipped", "failed"]: """Convert, index, and compile a single document under the KB mutation lock.""" with kb_ingest_lock(kb_dir / ".openkb"): - return _add_single_file_locked(file_path, kb_dir) + return _add_single_file_locked(file_path, kb_dir, bundle=bundle) -def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", "skipped", "failed"]: +def _add_single_file_locked(file_path: Path, kb_dir: Path, *, bundle=None) -> Literal["added", "skipped", "failed"]: """Convert, index, and compile a single document into the knowledge base. Steps: @@ -396,7 +387,7 @@ def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", " try: asyncio.run( compile_long_doc(doc_name, summary_path, index_result.doc_id, kb_dir, model, - doc_description=index_result.description) + doc_description=index_result.description, bundle=bundle) ) break except Exception as exc: @@ -411,7 +402,7 @@ def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", " click.echo(f" Compiling short doc...") for attempt in range(2): try: - asyncio.run(compile_short_doc(doc_name, result.source_path, kb_dir, model)) + asyncio.run(compile_short_doc(doc_name, result.source_path, kb_dir, model, bundle=bundle)) break except Exception as exc: if attempt == 0: @@ -456,6 +447,44 @@ def _add_single_file_locked(file_path: Path, kb_dir: Path) -> Literal["added", " return "added" +@dataclass +class AddFileResult: + """Structured add outcome for the REST API. + + Wraps the plain ``Literal`` returned by the locked ``add_single_file`` with + the original filename and a human-readable message, which the API's + ``/add`` endpoint surfaces per file in its JSON/SSE response. + """ + original_name: str + saved_path: str | None + status: str + message: str + + +def _add_for_api(file_path: Path, kb_dir: Path, *, bundle=None) -> AddFileResult: + """Run the locked add pipeline and return a structured result for the API. + + Reuses the upstream ``add_single_file`` (which already holds the ingest + lock and handles cloud import / registry dedup) so the API and CLI share a + single ingest code path. Maps the ``Literal`` status to a message-bearing + ``AddFileResult``; on ``skipped`` the caller (api._add_saved_file) deletes + the freshly uploaded raw copy to avoid orphaning it. + """ + status_str = add_single_file(file_path, kb_dir, bundle=bundle) + if status_str == "skipped": + message = f"Already in knowledge base: {file_path.name}" + elif status_str == "failed": + message = f"Failed to add: {file_path.name} (see server logs)" + else: + message = f"Added: {file_path.name}" + return AddFileResult( + original_name=file_path.name, + saved_path=str(file_path) if status_str == "added" else None, + status=status_str, + message=message, + ) + + def _cleanup_failed_cloud_import(kb_dir: Path, doc_name: str) -> None: """Best-effort wiki cleanup after a cloud import whose compilation failed. @@ -895,6 +924,53 @@ def _stream_to_tty() -> bool: return sys.stdout.isatty() +def save_exploration(kb_dir: Path, question: str, answer: str) -> Path | None: + """Save a query answer to ``wiki/explorations/`` as a markdown page. + + Shared by the CLI ``query --save`` path and the REST ``/query?save`` path + so both behave identically. Strips ghost wikilinks, generates a unique + slug (with a CJK-safe fallback), and escapes the question for YAML + frontmatter. + """ + import re + import hashlib + from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks + + if not answer: + return None + explore_dir = kb_dir / "wiki" / "explorations" + explore_dir.mkdir(parents=True, exist_ok=True) + + # Strip ghost wikilinks the agent may have emitted to non-existent + # concept/summary pages -- the schema_md in the agent's instructions + # encourages [[wikilinks]] but the agent's view of "which pages + # exist" can drift from disk reality. + known = list_existing_wiki_targets(kb_dir / "wiki") + cleaned_answer, _ = strip_ghost_wikilinks(answer, known) + + slug = re.sub(r"[^a-z0-9]+", "-", question.lower()).strip("-")[:60] + if not slug: + # CJK / punctuation-only questions collapse to an empty slug. + # Fall back to a short hash so each question gets its own file. + slug = hashlib.sha256(question.encode("utf-8")).hexdigest()[:12] + explore_path = explore_dir / f"{slug}.md" + # Uniquify to avoid clobbering an existing exploration with a colliding slug. + counter = 1 + while explore_path.exists(): + explore_path = explore_dir / f"{slug}-{counter}.md" + counter += 1 + + # Escape the question for YAML frontmatter: wrap in double quotes and + # escape backslashes and double quotes so questions containing `"` don't + # produce invalid YAML. + escaped = question.replace("\\", "\\\\").replace('"', '\\"') + explore_path.write_text( + f'---\nquery: "{escaped}"\n---\n\n{cleaned_answer}\n', + encoding="utf-8", + ) + return explore_path + + @cli.command() @click.argument("question") @click.option("--save", is_flag=True, default=False, help="Save the answer to wiki/explorations/.") @@ -930,22 +1006,7 @@ def query(ctx, question, save, raw): append_log(kb_dir / "wiki", "query", question) if save and answer: - import re - from openkb.lint import list_existing_wiki_targets, strip_ghost_wikilinks - slug = re.sub(r"[^a-z0-9]+", "-", question.lower()).strip("-")[:60] - explore_dir = kb_dir / "wiki" / "explorations" - explore_dir.mkdir(parents=True, exist_ok=True) - explore_path = explore_dir / f"{slug}.md" - # Strip ghost wikilinks the agent may have emitted to non-existent - # concept/summary pages — the schema_md in the agent's instructions - # encourages [[wikilinks]] but the agent's view of "which pages - # exist" can drift from disk reality. - known = list_existing_wiki_targets(kb_dir / "wiki") - cleaned_answer, _ = strip_ghost_wikilinks(answer, known) - explore_path.write_text( - f"---\nquery: \"{question}\"\n---\n\n{cleaned_answer}\n", - encoding="utf-8", - ) + explore_path = save_exploration(kb_dir, question, answer) click.echo(f"\nSaved to {explore_path}") @@ -1018,176 +1079,396 @@ def _resolve_doc_identifier(registry, identifier: str) -> list[tuple[str, dict]] return fuzzy -@cli.command() -@click.argument("identifier") -@click.option("--keep-raw", is_flag=True, default=False, - help="Don't delete the original file from raw/.") -@click.option("--keep-empty", "--keep-empty-concepts", "keep_empty", - is_flag=True, default=False, - help="Keep concept AND entity pages whose only source was the " - "removed doc (leaving an empty sources: [] list). Useful " - "when replacing the doc with a newer version. " - "(--keep-empty-concepts is a backward-compatible alias.)") -@click.option("--dry-run", is_flag=True, default=False, - help="Print what would be done without modifying anything.") -@click.option("--yes", "-y", is_flag=True, default=False, - help="Skip the confirmation prompt.") -@click.pass_context -@_with_kb_lock(exclusive=True) -def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes): - """Remove a document from the knowledge base. +@dataclass +class RemoveAction: + """One planned step surfaced in the remove preview/summary.""" + tag: str + target: str - IDENTIFIER may be the original filename ("paper.pdf"), the doc_name - slug ("paper-a1b2c3d4e5f6"), or a substring that uniquely matches one. - Deletes the doc's summary and source files, prunes the doc from - concept- and entity-page frontmatter and Related Documents sections, - drops the Documents entry from index.md, removes the hash entry, and - finally runs `lint --fix` to clean any dangling wikilinks. +@dataclass +class RemovePlan: + """Structured preview of what ``remove`` will do (no side effects yet). - Concept and entity pages whose only source was this doc are deleted by - default; use --keep-empty to retain them. + Built by ``_build_remove_plan``; consumed by the CLI (for printing the + preview) and by ``_execute_remove_plan`` (for actually doing it). """ - from openkb.agent.compiler import ( - remove_doc_from_concept_pages, - remove_doc_from_entity_pages, - remove_doc_from_index, - scan_affected_pages, - ) - from openkb.lint import fix_broken_links - from openkb.state import HashRegistry - - kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) - if kb_dir is None: - click.echo("No knowledge base found. Run `openkb init` first.") - return - - openkb_dir = kb_dir / ".openkb" - registry = HashRegistry(openkb_dir / "hashes.json") - - matches = _resolve_doc_identifier(registry, identifier) - if not matches: - click.echo(f"No document matching '{identifier}' found in the KB.") - click.echo("Try `openkb list` to see indexed documents.") - return - if len(matches) > 1: - click.echo(f"'{identifier}' matches multiple documents:") - for _, m in matches: - click.echo(f" - {m.get('name', '?')} (doc_name: {m.get('doc_name', '?')})") - click.echo("Use a more specific name or the exact doc_name slug.") - return + name: str + doc_name: str + doc_type: str + file_hash: str + actions: list[RemoveAction] + concept_deletes: list[str] + entity_deletes: list[str] + raw_path: Path | None + cleanup_pageindex: bool + pageindex_doc_id: str | None + summary_path: Path + source_md: Path + source_json: Path + images_dir: Path + + +@dataclass +class RemoveResult: + """Outcome of executing a :class:`RemovePlan`. + + ``status="partial"`` means PageIndex cleanup raised and the registry + entry was deliberately kept so the user can retry — mirroring the CLI. + """ + status: Literal["removed", "partial"] + name: str + doc_name: str + actions: list[RemoveAction] + concepts_deleted: list[str] + entities_deleted: list[str] + lint_files_changed: int + lint_ghosts_removed: int + pageindex_message: str | None + pageindex_error: str | None + message: str + + +def _build_remove_plan( + kb_dir: Path, + file_hash: str, + meta: dict, + *, + keep_raw: bool, + keep_empty: bool, +) -> RemovePlan: + """Scan the KB and predict every file remove will touch (no writes). + + Only frontmatter ``sources:`` membership drives the delete/edit + classification so the plan reflects what the executor will actually do. + """ + from openkb.agent.compiler import scan_affected_pages - file_hash, meta = matches[0] name = meta.get("name", "?") doc_name = meta.get("doc_name") or Path(name).stem doc_type = meta.get("type", "") wiki_dir = kb_dir / "wiki" + openkb_dir = kb_dir / ".openkb" - # ----- Build the plan (no side effects) ----- - actions: list[tuple[str, str]] = [] + actions: list[RemoveAction] = [] summary_path = wiki_dir / "summaries" / f"{doc_name}.md" if summary_path.exists(): - actions.append(("DELETE", str(summary_path.relative_to(kb_dir)))) + actions.append(RemoveAction("DELETE", str(summary_path.relative_to(kb_dir)))) source_md = wiki_dir / "sources" / f"{doc_name}.md" source_json = wiki_dir / "sources" / f"{doc_name}.json" if source_md.exists(): - actions.append(("DELETE", str(source_md.relative_to(kb_dir)))) + actions.append(RemoveAction("DELETE", str(source_md.relative_to(kb_dir)))) if source_json.exists(): - actions.append(("DELETE", str(source_json.relative_to(kb_dir)))) + actions.append(RemoveAction("DELETE", str(source_json.relative_to(kb_dir)))) # Per-doc extracted-images directory (PDF page images + base64 images # from docx/pptx + copied relative refs from .md inputs). Created by # openkb.images during ingest, keyed by doc_name. images_dir = wiki_dir / "sources" / "images" / doc_name if images_dir.is_dir(): - actions.append(( - "DELETE", - f"{images_dir.relative_to(kb_dir)}/ (images directory)", - )) + actions.append( + RemoveAction("DELETE", f"{images_dir.relative_to(kb_dir)}/ (images directory)") + ) - # Scan concept pages to predict which will be edited vs. deleted. - # Only frontmatter ``sources:`` membership drives the plan — body-only - # references (e.g. a stray ``See also:`` line a user added by hand - # without updating sources) are cleaned by the executor but don't - # affect the delete/edit classification, so the plan reflects what - # the executor will actually do. source_file_marker = f"summaries/{doc_name}.md" affected_concepts = scan_affected_pages(wiki_dir / "concepts", source_file_marker) - concept_deletes = [s for s, r in affected_concepts if r == 0 and not keep_empty] concept_edits = [s for s, r in affected_concepts if r > 0 or keep_empty] for slug in concept_deletes: - actions.append(("DELETE", f"wiki/concepts/{slug}.md (only source: this doc)")) + actions.append(RemoveAction("DELETE", f"wiki/concepts/{slug}.md (only source: this doc)")) for slug in concept_edits: - actions.append(("MODIFY", f"wiki/concepts/{slug}.md (drop this doc from sources)")) + actions.append(RemoveAction("MODIFY", f"wiki/concepts/{slug}.md (drop this doc from sources)")) - # Scan entity pages with the same frontmatter logic as concepts. The - # executor calls ``remove_doc_from_entity_pages``; this only makes the - # preview/summary truthful about what it will delete vs. edit. affected_entities = scan_affected_pages(wiki_dir / "entities", source_file_marker) - entity_deletes = [s for s, r in affected_entities if r == 0 and not keep_empty] entity_edits = [s for s, r in affected_entities if r > 0 or keep_empty] for slug in entity_deletes: - actions.append(("DELETE", f"wiki/entities/{slug}.md (only source: this doc)")) + actions.append(RemoveAction("DELETE", f"wiki/entities/{slug}.md (only source: this doc)")) for slug in entity_edits: - actions.append(("MODIFY", f"wiki/entities/{slug}.md (drop this doc from sources)")) + actions.append(RemoveAction("MODIFY", f"wiki/entities/{slug}.md (drop this doc from sources)")) if (wiki_dir / "index.md").exists(): - actions.append(("MODIFY", "wiki/index.md (remove Documents entry)")) + actions.append(RemoveAction("MODIFY", "wiki/index.md (remove Documents entry)")) - actions.append(("REGISTRY", f"remove hash entry ({file_hash[:12]}…)")) + actions.append(RemoveAction("REGISTRY", f"remove hash entry ({file_hash[:12]}…)")) # Long PDFs leave state in PageIndex's local store (`.openkb/pageindex.db` # row + `.openkb/files/<collection>/<doc_id>.pdf` + extracted images). # Only flag this when both the registry says long_pdf and PageIndex # state exists on disk — short-doc-only KBs never created any. pageindex_doc_id = meta.get("doc_id") - pageindex_state_exists = (openkb_dir / "pageindex.db").exists() - cleanup_pageindex = doc_type == "long_pdf" and pageindex_state_exists + cleanup_pageindex = doc_type == "long_pdf" and (openkb_dir / "pageindex.db").exists() if cleanup_pageindex: if pageindex_doc_id: - actions.append(( - "PAGEINDEX", f"delete document ({pageindex_doc_id[:12]}…)", - )) + actions.append(RemoveAction("PAGEINDEX", f"delete document ({pageindex_doc_id[:12]}…)")) else: - actions.append(( - "PAGEINDEX", f"delete document (lookup by doc_name; legacy entry)", - )) + actions.append(RemoveAction("PAGEINDEX", "delete document (lookup by doc_name; legacy entry)")) + # Raw copies are named by doc_name since the collision fix: use the + # recorded raw_path when present. Only pre-upgrade entries (no + # raw_path field) fall back to the original filename — a recorded + # path that no longer exists must NOT fall through, or it could + # delete a same-named raw file belonging to another document. raw_path = None if not keep_raw: raw_dir = kb_dir / "raw" - # Raw copies are named by doc_name since the collision fix: use the - # recorded raw_path when present. Only pre-upgrade entries (no - # raw_path field) fall back to the original filename — a recorded - # path that no longer exists must NOT fall through, or it could - # delete a same-named raw file belonging to another document. if meta.get("raw_path"): candidate = kb_dir / meta["raw_path"] else: candidate = raw_dir / name if candidate.exists(): raw_path = candidate - actions.append(("DELETE", str(candidate.relative_to(kb_dir)))) + actions.append(RemoveAction("DELETE", str(candidate.relative_to(kb_dir)))) + + return RemovePlan( + name=name, + doc_name=doc_name, + doc_type=doc_type, + file_hash=file_hash, + actions=actions, + concept_deletes=concept_deletes, + entity_deletes=entity_deletes, + raw_path=raw_path, + cleanup_pageindex=cleanup_pageindex, + pageindex_doc_id=pageindex_doc_id, + summary_path=summary_path, + source_md=source_md, + source_json=source_json, + images_dir=images_dir, + ) + + +def _execute_remove_plan( + kb_dir: Path, + plan: RemovePlan, + registry, + *, + keep_empty: bool, +) -> RemoveResult: + """Carry out a remove plan. Registry write is the commit point. + + Every step before ``registry.remove_by_hash`` is idempotent, so a + PageIndex failure leaves the entry (with its ``doc_id``) intact for a + retry. The ``lint --fix`` scope is limited to the pages this remove + actually touched (modified concept + entity pages ∪ index.md) so the + sweep doesn't strip pre-existing dangling links in unrelated pages + (issue #58). + """ + from openkb.agent.compiler import ( + remove_doc_from_concept_pages, + remove_doc_from_entity_pages, + remove_doc_from_index, + ) + from openkb.lint import fix_broken_links + + wiki_dir = kb_dir / "wiki" + openkb_dir = kb_dir / ".openkb" + doc_name = plan.doc_name + name = plan.name + + plan.summary_path.unlink(missing_ok=True) + plan.source_md.unlink(missing_ok=True) + plan.source_json.unlink(missing_ok=True) + if plan.images_dir.is_dir(): + shutil.rmtree(plan.images_dir, ignore_errors=True) + + concept_result = remove_doc_from_concept_pages(wiki_dir, doc_name, keep_empty=keep_empty) + entity_result = remove_doc_from_entity_pages(wiki_dir, doc_name, keep_empty=keep_empty) + remove_doc_from_index( + wiki_dir, doc_name, concept_result["deleted"], + entity_slugs_deleted=entity_result["deleted"], + ) + + lint_scope: list[Path] = [wiki_dir / "concepts" / f"{s}.md" for s in concept_result["modified"]] + lint_scope += [wiki_dir / "entities" / f"{s}.md" for s in entity_result["modified"]] + index_md = wiki_dir / "index.md" + if index_md.exists(): + lint_scope.append(index_md) + files_changed, ghosts = fix_broken_links(wiki_dir, restrict_to=lint_scope) + + pageindex_message: str | None = None + if plan.cleanup_pageindex: + try: + _, pageindex_message = _cleanup_pageindex( + openkb_dir, kb_dir, doc_name, plan.pageindex_doc_id, + ) + except Exception as exc: + logging.getLogger(__name__).debug("PageIndex cleanup traceback:", exc_info=True) + return RemoveResult( + status="partial", + name=name, + doc_name=doc_name, + actions=plan.actions, + concepts_deleted=concept_result["deleted"], + entities_deleted=entity_result["deleted"], + lint_files_changed=files_changed, + lint_ghosts_removed=ghosts, + pageindex_message=None, + pageindex_error=str(exc), + message=( + f"PageIndex cleanup failed: {exc}; registry entry kept, " + f"re-run `openkb remove {name}` to retry" + ), + ) + + registry.remove_by_hash(plan.file_hash) + if plan.raw_path is not None: + plan.raw_path.unlink(missing_ok=True) + append_log(wiki_dir, "remove", name) + return RemoveResult( + status="removed", + name=name, + doc_name=doc_name, + actions=plan.actions, + concepts_deleted=concept_result["deleted"], + entities_deleted=entity_result["deleted"], + lint_files_changed=files_changed, + lint_ghosts_removed=ghosts, + pageindex_message=pageindex_message, + pageindex_error=None, + message=f"{name} removed from knowledge base.", + ) + + +def run_remove_for_api( + kb_dir: Path, + identifier: str, + *, + keep_raw: bool = False, + keep_empty: bool = False, + dry_run: bool = False, +) -> dict: + """Resolve ``identifier`` and run remove under the KB ingest lock. + + Shared entry point for the REST ``/api/v1/remove`` endpoint. Resolve + + plan + execute all run inside ``kb_ingest_lock`` so concurrent + add/remove can't interleave (matching the CLI's ``@_with_kb_lock``). + + Returns a dict whose ``status`` is one of ``not_found``, ``multiple``, + ``dry_run``, ``removed``, ``partial``. + """ + from openkb.state import HashRegistry + + openkb_dir = kb_dir / ".openkb" + registry = HashRegistry(openkb_dir / "hashes.json") + with kb_ingest_lock(openkb_dir): + matches = _resolve_doc_identifier(registry, identifier) + if not matches: + return {"status": "not_found", "identifier": identifier} + if len(matches) > 1: + return { + "status": "multiple", + "identifier": identifier, + "candidates": [ + {"name": m.get("name", "?"), "doc_name": m.get("doc_name", "?")} + for _, m in matches + ], + } + + file_hash, meta = matches[0] + plan = _build_remove_plan( + kb_dir, file_hash, meta, keep_raw=keep_raw, keep_empty=keep_empty, + ) + if dry_run: + return { + "status": "dry_run", + "name": plan.name, + "doc_name": plan.doc_name, + "actions": [a.__dict__ for a in plan.actions], + "concepts_deleted": plan.concept_deletes, + "entities_deleted": plan.entity_deletes, + } + + result = _execute_remove_plan(kb_dir, plan, registry, keep_empty=keep_empty) + return { + "status": result.status, + "name": result.name, + "doc_name": result.doc_name, + "actions": [a.__dict__ for a in result.actions], + "concepts_deleted": result.concepts_deleted, + "entities_deleted": result.entities_deleted, + "lint_files_changed": result.lint_files_changed, + "lint_ghosts_removed": result.lint_ghosts_removed, + "pageindex_message": result.pageindex_message, + "pageindex_error": result.pageindex_error, + "message": result.message, + } + + +@cli.command() +@click.argument("identifier") +@click.option("--keep-raw", is_flag=True, default=False, + help="Don't delete the original file from raw/.") +@click.option("--keep-empty", "--keep-empty-concepts", "keep_empty", + is_flag=True, default=False, + help="Keep concept AND entity pages whose only source was the " + "removed doc (leaving an empty sources: [] list). Useful " + "when replacing the doc with a newer version. " + "(--keep-empty-concepts is a backward-compatible alias.)") +@click.option("--dry-run", is_flag=True, default=False, + help="Print what would be done without modifying anything.") +@click.option("--yes", "-y", is_flag=True, default=False, + help="Skip the confirmation prompt.") +@click.pass_context +@_with_kb_lock(exclusive=True) +def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes): + """Remove a document from the knowledge base. + + IDENTIFIER may be the original filename ("paper.pdf"), the doc_name + slug ("paper-a1b2c3d4e5f6"), or a substring that uniquely matches one. + + Deletes the doc's summary and source files, prunes the doc from + concept- and entity-page frontmatter and Related Documents sections, + drops the Documents entry from index.md, removes the hash entry, and + finally runs `lint --fix` to clean any dangling wikilinks. + + Concept and entity pages whose only source was this doc are deleted by + default; use --keep-empty to retain them. + """ + from openkb.state import HashRegistry + + kb_dir = _find_kb_dir(ctx.obj.get("kb_dir_override")) + if kb_dir is None: + click.echo("No knowledge base found. Run `openkb init` first.") + return + + openkb_dir = kb_dir / ".openkb" + registry = HashRegistry(openkb_dir / "hashes.json") + + matches = _resolve_doc_identifier(registry, identifier) + if not matches: + click.echo(f"No document matching '{identifier}' found in the KB.") + click.echo("Try `openkb list` to see indexed documents.") + return + if len(matches) > 1: + click.echo(f"'{identifier}' matches multiple documents:") + for _, m in matches: + click.echo(f" - {m.get('name', '?')} (doc_name: {m.get('doc_name', '?')})") + click.echo("Use a more specific name or the exact doc_name slug.") + return + + file_hash, meta = matches[0] + plan = _build_remove_plan( + kb_dir, file_hash, meta, keep_raw=keep_raw, keep_empty=keep_empty, + ) # ----- Print the plan ----- - click.echo(f"Removing '{name}' (doc_name: {doc_name}, type: {doc_type or '?'}).") + click.echo(f"Removing '{plan.name}' (doc_name: {plan.doc_name}, type: {plan.doc_type or '?'}).") click.echo("") - for tag, target in actions: - click.echo(f" {tag:<8} {target}") - if concept_deletes: + for action in plan.actions: + click.echo(f" {action.tag:<8} {action.target}") + if plan.concept_deletes: click.echo("") click.echo( - f" {len(concept_deletes)} concept(s) will be DELETED because this is their only source." + f" {len(plan.concept_deletes)} concept(s) will be DELETED because this is their only source." ) click.echo(" Pass --keep-empty to retain them instead.") - if entity_deletes: + if plan.entity_deletes: click.echo("") click.echo( - f" {len(entity_deletes)} entity(s) will be DELETED because this is their only source." + f" {len(plan.entity_deletes)} entity(s) will be DELETED because this is their only source." ) click.echo(" Pass --keep-empty to retain them instead.") click.echo("") @@ -1201,91 +1482,20 @@ def remove(ctx, identifier, keep_raw, keep_empty, dry_run, yes): click.echo("Aborted.") return - # ----- Execute ----- - # Ordering rationale: every step before the registry write is - # idempotent (``unlink(missing_ok=True)``, ``shutil.rmtree( - # ignore_errors=True)``, concept/index helpers that no-op on - # already-clean state, and PageIndex's own delete-by-doc_id which - # uses ``missing_ok`` + ``if dir.exists()`` internally). The - # registry write is therefore the *commit point*: if anything - # before it raises (including PageIndex), the entry plus its - # ``doc_id`` survive and the user can simply re-run ``openkb - # remove`` to retry from a clean slate. - summary_path.unlink(missing_ok=True) - source_md.unlink(missing_ok=True) - source_json.unlink(missing_ok=True) - if images_dir.is_dir(): - shutil.rmtree(images_dir, ignore_errors=True) - - concept_result = remove_doc_from_concept_pages( - wiki_dir, doc_name, keep_empty=keep_empty, - ) - - entity_result = remove_doc_from_entity_pages( - wiki_dir, doc_name, keep_empty=keep_empty, - ) - - remove_doc_from_index(wiki_dir, doc_name, concept_result["deleted"], - entity_slugs_deleted=entity_result["deleted"]) - - # Strip dangling wikilinks now so a retry (after a PageIndex - # failure below) finds a clean wiki — no point in re-running this - # on every attempt. - # - # Scope: only the pages this remove actually touched (modified - # concept + entity pages ∪ index.md). Previously this swept the whole - # wiki via ``fix_broken_links(wiki_dir)``, which silently stripped - # pre-existing dangling links in unrelated pages — see issue #58 - # (Bug 2). Users who want a wiki-wide sweep can still run - # ``openkb lint --fix`` explicitly. - lint_scope: list[Path] = [ - wiki_dir / "concepts" / f"{slug}.md" - for slug in concept_result["modified"] - ] - lint_scope += [ - wiki_dir / "entities" / f"{slug}.md" - for slug in entity_result["modified"] - ] - index_md = wiki_dir / "index.md" - if index_md.exists(): - lint_scope.append(index_md) - files_changed, ghosts = fix_broken_links(wiki_dir, restrict_to=lint_scope) - if files_changed: - click.echo(f" lint --fix cleaned {ghosts} dangling wikilink(s) in {files_changed} file(s)") - - # Free PageIndex's local managed state for long PDFs *before* the - # registry write so the user can retry on failure — leaving the - # entry intact preserves the ``doc_id`` we need for the second - # attempt. PageIndex's local dedup is SHA-256 based, so a stale row - # left behind here would silently re-bind on the next ``openkb - # add`` and the user would get the old parse back without warning. - if cleanup_pageindex: - try: - cleaned, msg = _cleanup_pageindex( - openkb_dir, kb_dir, doc_name, pageindex_doc_id, - ) - click.echo(f" PageIndex: {msg}") - except Exception as exc: - click.echo( - f" [WARN] PageIndex cleanup failed: {exc} " - f"— registry entry kept; re-run `openkb remove {name}` to retry" - ) - logging.getLogger(__name__).debug( - "PageIndex cleanup traceback:", exc_info=True, - ) - return - - # ----- Commit point ----- - # Prune by hash, not by ``doc_name``: legacy registry entries - # (ingested before commit c504e26) carry only ``{name, type}`` and - # would silently no-op under ``remove_by_doc_name``. See issue #58. - registry.remove_by_hash(file_hash) + result = _execute_remove_plan(kb_dir, plan, registry, keep_empty=keep_empty) - if raw_path is not None: - raw_path.unlink(missing_ok=True) + if result.lint_files_changed: + click.echo(f" lint --fix cleaned {result.lint_ghosts_removed} dangling wikilink(s) in {result.lint_files_changed} file(s)") + if result.pageindex_message is not None: + click.echo(f" PageIndex: {result.pageindex_message}") + if result.pageindex_error is not None: + click.echo( + f" [WARN] PageIndex cleanup failed: {result.pageindex_error} " + f"— registry entry kept; re-run `openkb remove {result.name}` to retry" + ) + return - append_log(wiki_dir, "remove", name) - click.echo(f" [OK] {name} removed from knowledge base.") + click.echo(f" [OK] {result.name} removed from knowledge base.") def _refresh_schema(wiki_dir: Path) -> bool: @@ -1480,6 +1690,173 @@ def _classify(meta: dict) -> str: append_log(wiki_dir, "recompile", f"recompiled {recompiled}, skipped {skipped}") +async def iter_recompile( + kb_dir: Path, + doc_name: str | None = None, + *, + all_docs: bool = False, + dry_run: bool = False, + refresh_schema: bool = False, + bundle=None, +): + """Async generator view of ``recompile`` for the REST ``/api/v1/recompile``. + + Shared entry point for both the non-streaming JSON path and the SSE + streaming path. Yields ``{"event": ..., ...}`` dicts (see the endpoint): + ``start`` -> optional ``plan`` -> one ``doc`` per document -> ``final``. + Terminal errors yield an ``error`` event carrying an HTTP-mapped ``code``: + 400 (bad args), 404 (not found / empty registry), 409 (multiple). + + The whole resolve + compile span runs under ``kb_ingest_lock`` (matching + the CLI's ``@_with_kb_lock(exclusive=True)``). Compile calls are awaited on + the caller's event loop instead of ``asyncio.run``, so this is safe inside + an async FastAPI endpoint. Reference ``compiler`` via the module so tests + can patch ``openkb.agent.compiler.compile_*`` and see the call. + """ + from openkb.state import HashRegistry + + openkb_dir = kb_dir / ".openkb" + wiki_dir = kb_dir / "wiki" + registry = HashRegistry(openkb_dir / "hashes.json") + + def _classify(meta: dict) -> str: + return "long" if _is_long_doc(meta) else "short" + + with kb_ingest_lock(openkb_dir): + # --- validate args --- + if all_docs and doc_name: + yield {"event": "error", "code": 400, + "message": "Specify either a doc_name or all_docs, not both."} + return + if not all_docs and not doc_name: + yield {"event": "error", "code": 400, + "message": "Specify a document name or set all_docs to recompile every doc."} + return + + # --- resolve targets --- + if all_docs: + entries = list(registry.all_entries().values()) + if not entries: + yield {"event": "error", "code": 404, + "message": "No documents indexed yet."} + return + targets = entries + else: + matches = _resolve_doc_identifier(registry, doc_name) + if not matches: + yield {"event": "error", "code": 404, + "message": f"No document matching '{doc_name}' found in the KB."} + return + if len(matches) > 1: + yield { + "event": "error", "code": 409, + "message": "doc_name matches multiple documents.", + "candidates": [ + {"name": m.get("name", "?"), "doc_name": m.get("doc_name", "?")} + for _, m in matches + ], + } + return + targets = [matches[0][1]] + + total = len(targets) + yield {"event": "start", "total": total, "all_docs": all_docs} + + # --- dry-run: enumerate only, no LLM calls, no writes --- + if dry_run: + yield { + "event": "plan", + "targets": [ + { + "name": meta.get("doc_name") or meta.get("name", "?"), + "doc_name": meta.get("doc_name") or meta.get("name", "?"), + "type": _classify(meta), + } + for meta in targets + ], + "total": total, + } + yield {"event": "final", "status": "dry_run", "total": total, + "recompiled": 0, "skipped": 0, "docs": []} + return + + if refresh_schema: + _refresh_schema(wiki_dir) + + _setup_llm_key(kb_dir) + config = load_config(openkb_dir / "config.yaml") + model: str = config.get("model", DEFAULT_CONFIG["model"]) + + from openkb.agent import compiler + + recompiled = 0 + skipped = 0 + docs: list[dict] = [] + for meta in targets: + name = meta.get("doc_name") or Path(meta.get("name", "")).stem + doc_type = _classify(meta) + ok = False + + if not name: + doc = {"name": None, "doc_name": None, "type": doc_type, + "status": "skipped", "elapsed": None, + "message": "registry entry has no doc_name."} + elif _is_long_doc(meta): + summary_path = wiki_dir / "summaries" / f"{name}.md" + doc_id = meta.get("doc_id") + if not doc_id: + doc = {"name": name, "doc_name": name, "type": "long", + "status": "skipped", "elapsed": None, + "message": "legacy long-doc entry without a doc_id; re-add to refresh."} + elif not summary_path.exists(): + doc = {"name": name, "doc_name": name, "type": "long", + "status": "skipped", "elapsed": None, + "message": f"missing summary at wiki/summaries/{name}.md."} + else: + start = time.time() + try: + await compiler.compile_long_doc(name, summary_path, doc_id, kb_dir, model, bundle=bundle) + except Exception as exc: + doc = {"name": name, "doc_name": name, "type": "long", + "status": "error", "elapsed": round(time.time() - start, 1), + "message": f"Compilation failed: {exc}"} + else: + doc = {"name": name, "doc_name": name, "type": "long", + "status": "ok", "elapsed": round(time.time() - start, 1), + "message": None} + ok = True + else: + source_path = wiki_dir / "sources" / f"{name}.md" + if not source_path.exists(): + doc = {"name": name, "doc_name": name, "type": "short", + "status": "skipped", "elapsed": None, + "message": f"missing source at wiki/sources/{name}.md."} + else: + start = time.time() + try: + await compiler.compile_short_doc(name, source_path, kb_dir, model, bundle=bundle) + except Exception as exc: + doc = {"name": name, "doc_name": name, "type": "short", + "status": "error", "elapsed": round(time.time() - start, 1), + "message": f"Compilation failed: {exc}"} + else: + doc = {"name": name, "doc_name": name, "type": "short", + "status": "ok", "elapsed": round(time.time() - start, 1), + "message": None} + ok = True + + docs.append(doc) + yield {"event": "doc", **doc} + if ok: + recompiled += 1 + else: + skipped += 1 + + append_log(wiki_dir, "recompile", f"recompiled {recompiled}, skipped {skipped}") + yield {"event": "final", "status": "done", "total": total, + "recompiled": recompiled, "skipped": skipped, "docs": docs} + + @cli.command() @click.option( "--resume", "-r", "resume", @@ -2635,3 +3012,310 @@ def _save_deck_iteration(kb_dir: Path, deck_name: str) -> Path | None: dest = ws / f"iteration-{next_n}" shutil.copytree(src, dest) return dest + + +# --------------------------------------------------------------------------- +# REST API helpers (structured init/list/status/lint + add wrapper) +# +# These return plain dicts (or AddFileResult) so openkb.api can serialize them +# directly as JSON. They deliberately reuse the locked CLI code paths so the +# API and CLI never diverge in behavior. +# --------------------------------------------------------------------------- + + +def _newest_mtime_iso(paths: list[Path]) -> str | None: + """Newest mtime among paths as an ISO-8601 string, or None when empty.""" + if not paths: + return None + import datetime + newest = max(paths, key=lambda p: p.stat().st_mtime) + local_tz = datetime.datetime.now().astimezone().tzinfo + return datetime.datetime.fromtimestamp( + newest.stat().st_mtime, + tz=local_tz, + ).isoformat() + + +def initialize_kb( + kb_dir: Path, + *, + model: str | None = None, + api_key: str | None = None, + openai_api_base: str | None = None, +) -> dict[str, Any]: + """Initialize a knowledge base at an explicit directory (REST ``/init``). + + Non-interactive counterpart to the ``init`` Click command: creates the + raw/wiki/.openkb layout, seed files, config, and empty hash registry, then + optionally writes LLM credentials to a KB-local ``.env``. Raises + ``FileExistsError`` if the KB is already initialized. + """ + from openkb.config import kb_root_dir + + kb_dir = kb_dir.expanduser().resolve() + openkb_dir = kb_dir / ".openkb" + if openkb_dir.exists(): + raise FileExistsError(f"Knowledge base already initialized: {kb_dir}") + + kb_dir.mkdir(parents=True, exist_ok=True) + (kb_dir / "raw").mkdir(exist_ok=True) + (kb_dir / "wiki" / "sources" / "images").mkdir(parents=True, exist_ok=True) + (kb_dir / "wiki" / "summaries").mkdir(parents=True, exist_ok=True) + (kb_dir / "wiki" / "concepts").mkdir(parents=True, exist_ok=True) + (kb_dir / "wiki" / "entities").mkdir(parents=True, exist_ok=True) + + atomic_write_text(kb_dir / "wiki" / "AGENTS.md", AGENTS_MD) + atomic_write_text(kb_dir / "wiki" / "index.md", INDEX_SEED) + atomic_write_text(kb_dir / "wiki" / "log.md", "# Operations Log\n\n") + + openkb_dir.mkdir() + # Seed config.yaml: an explicit model wins; otherwise inherit the + # operator's project-root config.yaml (model/language/optional blocks) + # so a KB created via the REST UI matches the deployed setup instead of + # the hardcoded DEFAULT_CONFIG (gpt-5.4 / en). Defaults are the last resort. + template_config = Path.cwd() / "config.yaml" + if model is not None: + config = { + "model": model, + "language": DEFAULT_CONFIG["language"], + "pageindex_threshold": DEFAULT_CONFIG["pageindex_threshold"], + } + save_config(openkb_dir / "config.yaml", config) + elif template_config.exists(): + shutil.copy2(template_config, openkb_dir / "config.yaml") + else: + config = { + "model": DEFAULT_CONFIG["model"], + "language": DEFAULT_CONFIG["language"], + "pageindex_threshold": DEFAULT_CONFIG["pageindex_threshold"], + } + save_config(openkb_dir / "config.yaml", config) + atomic_write_json(openkb_dir / "hashes.json", {}) + + # Seed KB-local .env: inherit LLM credentials from the project-root .env so + # a new KB can run queries/compiles out of the box. REST-server variables + # (OPENKB_API_TOKEN, OPENKB_KB_ROOT, ...) are filtered out — they scope to + # the server, not a single KB. Explicit api_key/openai_api_base params + # override anything inherited. Precedence: default -> template -> explicit. + env_path = kb_dir / ".env" + can_write_env = not env_path.exists() + env_pairs: dict[str, str] = {} + if can_write_env: + env_pairs["LITELLM_LOCAL_MODEL_COST_MAP"] = "true" + template_env = Path.cwd() / ".env" + if template_env.exists(): + for raw in template_env.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, val = line.partition("=") + key = key.strip() + # Skip REST-server variables; keep LLM/provider config. + if key.startswith("OPENKB_"): + continue + env_pairs[key] = val.strip() + if api_key: + env_pairs["LLM_API_KEY"] = api_key + if openai_api_base: + env_pairs["OPENAI_API_BASE"] = openai_api_base + if env_pairs: + env_path.write_text( + "".join(f"{k}={v}\n" for k, v in env_pairs.items()), + encoding="utf-8", + ) + os.chmod(env_path, 0o600) + + register_kb(kb_dir) + return { + "kb_dir": str(kb_dir), + "created": True, + "env_written": { + "api_key": "LLM_API_KEY" in env_pairs, + "openai_api_base": "OPENAI_API_BASE" in env_pairs, + }, + "message": "Knowledge base initialized.", + } + + +def get_kb_list(kb_dir: Path) -> dict[str, Any]: + """Return a structured inventory of the knowledge base (REST ``/list``).""" + openkb_dir = kb_dir / ".openkb" + hashes_file = openkb_dir / "hashes.json" + hashes = ( + json.loads(hashes_file.read_text(encoding="utf-8")) + if hashes_file.exists() else {} + ) + + documents = [] + for file_hash, meta in hashes.items(): + raw_type = meta.get("type", "unknown") + pages = meta.get("pages") + documents.append({ + "hash": file_hash, + "name": meta.get("name", "unknown"), + "type": raw_type, + "display_type": _display_type(raw_type), + "pages": pages if pages not in ("", 0) else None, + }) + + summaries_dir = kb_dir / "wiki" / "summaries" + concepts_dir = kb_dir / "wiki" / "concepts" + reports_dir = kb_dir / "wiki" / "reports" + return { + "documents": documents, + "document_count": len(documents), + "summaries": sorted(p.stem for p in summaries_dir.glob("*.md")) + if summaries_dir.exists() else [], + "concepts": sorted(p.stem for p in concepts_dir.glob("*.md")) + if concepts_dir.exists() else [], + "reports": sorted(p.name for p in reports_dir.glob("*.md")) + if reports_dir.exists() else [], + } + + +def get_kb_status(kb_dir: Path) -> dict[str, Any]: + """Return structured status for the knowledge base (REST ``/status``).""" + wiki_dir = kb_dir / "wiki" + subdirs = ["sources", "summaries", "concepts", "reports"] + directories = {} + for subdir in subdirs: + path = wiki_dir / subdir + directories[subdir] = len(list(path.glob("*.md"))) if path.exists() else 0 + + raw_dir = kb_dir / "raw" + raw_count = ( + len([f for f in raw_dir.iterdir() if f.is_file()]) if raw_dir.exists() else 0 + ) + + hashes_file = kb_dir / ".openkb" / "hashes.json" + hashes = ( + json.loads(hashes_file.read_text(encoding="utf-8")) + if hashes_file.exists() else {} + ) + + summaries = ( + list((wiki_dir / "summaries").glob("*.md")) + if (wiki_dir / "summaries").exists() else [] + ) + reports = ( + list((wiki_dir / "reports").glob("*.md")) + if (wiki_dir / "reports").exists() else [] + ) + return { + "directories": directories, + "raw_count": raw_count, + "total_indexed": len(hashes), + "last_compile": _newest_mtime_iso(summaries), + "last_lint": _newest_mtime_iso(reports), + } + + +def _fix_summary(files_changed: int | None, ghosts: int | None) -> str: + """One-line summary of a ``lint --fix`` pass (mirrors the CLI wording).""" + if files_changed: + return f"Fixed {ghosts} wikilink(s) across {files_changed} file(s)." + return "Nothing to fix — all wikilinks resolve." + + +async def run_lint_report(kb_dir: Path, *, fix: bool = False, echo: bool = False, bundle=None) -> dict[str, Any]: + """Run lint and return structured report metadata (REST ``/lint``). + + Mirrors ``run_lint`` (structural + knowledge lint, writes a combined + report) but returns a JSON-serializable dict instead of printing and + returning only a path. Skips with a structured payload when there is + nothing to lint. + + When ``fix`` is True, ``fix_broken_links`` runs first under the KB ingest + lock (mirroring ``openkb lint --fix``), so the report reflects the + post-fix state and the fix counts are returned as + ``lint_files_changed`` / ``lint_ghosts_removed``. + """ + from openkb.lint import fix_broken_links, run_structural_lint + from openkb.agent.linter import run_knowledge_lint + from openkb.agent.query import build_run_config_from_bundle + + # Optional fix pass runs first (matching `openkb lint --fix`), before any + # skip/report logic, so the report and message reflect the post-fix state. + lint_files_changed: int | None = None + lint_ghosts_removed: int | None = None + if fix: + with kb_ingest_lock(kb_dir / ".openkb"): + files_changed, ghosts = fix_broken_links(kb_dir / "wiki") + lint_files_changed = files_changed + lint_ghosts_removed = ghosts + + openkb_dir = kb_dir / ".openkb" + hashes_file = openkb_dir / "hashes.json" + hashes = ( + json.loads(hashes_file.read_text(encoding="utf-8")) + if hashes_file.exists() else {} + ) + + if not hashes: + message = "Nothing to lint - no documents indexed yet. Run `openkb add` first." + if fix: + message = f"{_fix_summary(lint_files_changed, lint_ghosts_removed)} {message}" + if echo: + click.echo(message) + return { + "skipped": True, + "reason": "no_documents_indexed", + "message": message, + "structural_report": None, + "knowledge_report": None, + "report_path": None, + "lint_files_changed": lint_files_changed, + "lint_ghosts_removed": lint_ghosts_removed, + } + + config = load_config(openkb_dir / "config.yaml") + _setup_llm_key(kb_dir) + model: str = config.get("model", DEFAULT_CONFIG["model"]) + run_config = build_run_config_from_bundle(model, bundle) + + if echo: + click.echo("Running structural lint...") + structural_report = run_structural_lint(kb_dir) + if echo: + click.echo(structural_report) + click.echo("Running knowledge lint...") + + try: + knowledge_report = await run_knowledge_lint(kb_dir, model, bundle=bundle, run_config=run_config) + except Exception as exc: + knowledge_report = f"Knowledge lint failed: {exc}" + if echo: + click.echo(knowledge_report) + + reports_dir = kb_dir / "wiki" / "reports" + reports_dir.mkdir(parents=True, exist_ok=True) + import datetime + timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S") + report_path = reports_dir / f"lint_{timestamp}.md" + counter = 1 + while report_path.exists(): + report_path = reports_dir / f"lint_{timestamp}_{counter}.md" + counter += 1 + report_content = ( + f"# Lint Report — {timestamp}\n\n" + f"## Structural\n\n{structural_report}\n\n" + f"## Semantic\n\n{knowledge_report}\n" + ) + report_path.write_text(report_content, encoding="utf-8") + append_log(kb_dir / "wiki", "lint", f"report → {report_path.name}") + if echo: + click.echo(f"\nReport written to {report_path}") + + message = "Lint report written." + if fix: + message = f"{_fix_summary(lint_files_changed, lint_ghosts_removed)} {message}" + return { + "skipped": False, + "reason": None, + "message": message, + "structural_report": structural_report, + "knowledge_report": knowledge_report, + "report_path": str(report_path), + "lint_files_changed": lint_files_changed, + "lint_ghosts_removed": lint_ghosts_removed, + } diff --git a/openkb/config.py b/openkb/config.py index 52082c62..1baffb36 100644 --- a/openkb/config.py +++ b/openkb/config.py @@ -4,6 +4,8 @@ import logging import math import re +import os +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterator @@ -29,6 +31,11 @@ GLOBAL_CONFIG_PATH = GLOBAL_CONFIG_DIR / "global.yaml" GLOBAL_CONFIG_LOCK_PATH = GLOBAL_CONFIG_DIR / "global.lock" +# Portable KB names for the REST API: letters, numbers, underscores, hyphens. +# Lets clients address a knowledge base by a filesystem-safe short name +# (``kb``) instead of an absolute path, resolved via kb_aliases/OPENKB_KB_ROOT. +KB_NAME_RE = re.compile(r"[A-Za-z0-9_-]+") + @contextlib.contextmanager def _with_global_config_lock() -> Iterator[None]: @@ -205,6 +212,31 @@ def resolve_litellm_settings(config: dict) -> dict[str, Any]: # via get_extra_headers() so the value doesn't have to be threaded through # every compile/agent call chain — mirroring how the API key is applied # globally via litellm.api_key / provider env vars. + +# Shared by cli._setup_llm_key and resolve_credential_bundle so the CLI and +# REST paths apply identical litellm.* override semantics. +def resolve_per_request_overrides( + config: dict[str, Any], +) -> tuple[dict[str, str], float | None, dict[str, Any]]: + """Resolve extra_headers / timeout / litellm_settings with litellm.* overrides. + + ``litellm.extra_headers`` / ``litellm.timeout`` override the top-level + ``extra_headers`` / ``timeout`` keys (matching legacy precedence), and are + popped from the returned ``litellm_settings`` so they are not also applied + as process-wide litellm module settings. + """ + extra_headers = resolve_extra_headers(config) + timeout = resolve_timeout(config) + litellm_settings = resolve_litellm_settings(config) + if "extra_headers" in litellm_settings: + extra_headers = resolve_extra_headers( + {"extra_headers": litellm_settings.pop("extra_headers")} + ) + if "timeout" in litellm_settings: + timeout = resolve_timeout({"timeout": litellm_settings.pop("timeout")}) + return extra_headers, timeout, litellm_settings + + _runtime_extra_headers: dict[str, str] = {} @@ -242,6 +274,61 @@ def get_timeout_extra_args() -> dict[str, float] | None: return {"timeout": _runtime_timeout} if _runtime_timeout is not None else None +@dataclass(frozen=True) +class LlmCredentialBundle: + """Immutable per-request LLM credential + config bundle. + + Resolved once from a KB's ``.env`` and ``config.yaml`` via + :func:`resolve_credential_bundle`, then threaded through to LLM call sites + so concurrent requests on a shared event-loop thread never see each other's + key/headers/timeout (unlike the process-wide globals in + :func:`set_extra_headers` / :func:`set_timeout`). + """ + + api_key: str | None = None + base_url: str | None = None + extra_headers: dict[str, str] = field(default_factory=dict) + timeout: float | None = None + + +def resolve_credential_bundle(kb_dir: Path) -> LlmCredentialBundle: + """Build an :class:`LlmCredentialBundle` from a KB's config, purely. + + Reads the KB's ``.env`` (without polluting ``os.environ``) for + ``LLM_API_KEY`` / ``OPENAI_API_BASE``, and the KB's ``config.yaml`` for + ``extra_headers`` / ``timeout`` / ``litellm``. This is a side-effect-free + counterpart to ``cli._setup_llm_key``: it touches no global state, so it is + safe to call concurrently for different KBs. + """ + api_key: str | None = None + base_url: str | None = None + kb_env = kb_dir / ".env" + if kb_env.exists(): + from dotenv import dotenv_values + + values = dotenv_values(str(kb_env)) + api_key = values.get("LLM_API_KEY") or None + base_url = values.get("OPENAI_API_BASE") or None + + extra_headers: dict[str, str] = {} + timeout: float | None = None + config_path = kb_dir / ".openkb" / "config.yaml" + if config_path.exists(): + config = load_config(config_path) + # Shared resolver so CLI and REST apply identical litellm.* override + # semantics. litellm module-level settings (drop_params etc.) are + # process globals and intentionally not carried per-request: the REST + # server runs multiple KBs in one process, so they cannot be isolated. + extra_headers, timeout, _ = resolve_per_request_overrides(config) + + return LlmCredentialBundle( + api_key=api_key, + base_url=base_url, + extra_headers=extra_headers, + timeout=timeout, + ) + + def load_config(config_path: Path) -> dict[str, Any]: """Load YAML config from config_path, merged with DEFAULT_CONFIG. @@ -282,3 +369,62 @@ def register_kb(kb_path: Path) -> None: gc["known_kbs"] = known gc["default_kb"] = resolved _atomic_yaml_dump(GLOBAL_CONFIG_PATH, gc) + + +def kb_root_dir() -> Path: + """Return the root directory for API-addressable KB names. + + Controlled by ``OPENKB_KB_ROOT``; defaults to ``<config>/kbs`` so REST + ``/init`` creates knowledge bases under a predictable location. + """ + configured_root = os.environ.get("OPENKB_KB_ROOT") + if configured_root: + return Path(configured_root).expanduser().resolve() + return (GLOBAL_CONFIG_DIR / "kbs").resolve() + + +def validate_kb_name(name: str) -> str: + """Validate and return a portable KB name.""" + normalized = name.strip() + if not normalized or not KB_NAME_RE.fullmatch(normalized): + raise ValueError( + "KB name must contain only letters, numbers, underscores, and hyphens" + ) + return normalized + + +def register_kb_alias(name: str, kb_path: Path) -> None: + """Register a portable KB name alias for a KB path. + + Held under the global-config lock so concurrent API writes (uvicorn + threadpool) can't lose an alias to a read-modify-write race. + """ + alias = validate_kb_name(name) + resolved = str(kb_path.resolve()) + with _with_global_config_lock(): + gc = _load_global_config_unlocked() + known = gc.get("known_kbs", []) + if resolved not in known: + known.append(resolved) + gc["known_kbs"] = known + aliases = gc.get("kb_aliases", {}) + aliases[alias] = resolved + gc["kb_aliases"] = aliases + gc["default_kb"] = resolved + _atomic_yaml_dump(GLOBAL_CONFIG_PATH, gc) + + +def resolve_kb_alias(name: str) -> Path: + """Resolve a portable KB name to its registered path or root fallback. + + If the name is a registered alias, return its path; otherwise fall back to + ``<kb_root_dir>/<name>`` so a freshly created KB is addressable without an + explicit alias round-trip. + """ + alias = validate_kb_name(name) + with _with_global_config_lock(): + gc = _load_global_config_unlocked() + aliases = gc.get("kb_aliases", {}) + if alias in aliases: + return Path(aliases[alias]).expanduser().resolve() + return (kb_root_dir() / alias).resolve() diff --git a/openkb/watch_service.py b/openkb/watch_service.py new file mode 100644 index 00000000..3ef75e1b --- /dev/null +++ b/openkb/watch_service.py @@ -0,0 +1,261 @@ +"""Server-managed file watchers for the OpenKB REST API. + +Each knowledge base may have at most one active watcher that observes its +``raw/`` directory and automatically ingests new/modified files through the +same locked pipeline used by the CLI ``add`` command and the REST ``/add`` +endpoint (``openkb.cli._add_for_api``). The watcher runs entirely in +background threads; the FastAPI layer only starts/stops/reads state. + +Design notes: +* One worker thread per KB consumes debounced file batches sequentially, so + ingest is serialized per-KB (the global ingest lock would serialize it + anyway) while different KBs proceed in parallel. +* Per-KB mutable state is held in :class:`WatcherState`; the registry guards + only the KB->state map. Events live in a bounded ring buffer so a long-lived + watcher never accumulates unbounded memory. +* ``_add_for_api`` and ``SUPPORTED_EXTENSIONS`` are imported into this module's + namespace so tests can monkeypatch ``openkb.watch_service._add_for_api``. +""" +from __future__ import annotations + +import os +import queue +import threading +import time +from collections import deque +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any + +from watchdog.observers import Observer + +from openkb.cli import SUPPORTED_EXTENSIONS, _add_for_api +from openkb.watcher import start_watch + +# How many recent events to retain per KB for status() and SSE replay. +_MAX_EVENTS = int(os.environ.get("OPENKB_WATCH_MAX_EVENTS", "200")) + + +@dataclass +class WatcherState: + """Runtime state for a single KB's watcher.""" + + kb: str + kb_dir: Path + raw_dir: Path + debounce: float + started_at: float + observer: Observer | None = None + worker_thread: threading.Thread | None = None + queue: queue.Queue = field(default_factory=queue.Queue) + running: threading.Event = field(default_factory=threading.Event) + events: deque = field(default_factory=lambda: deque(maxlen=_MAX_EVENTS)) + counters: dict[str, int] = field(default_factory=lambda: {"added": 0, "skipped": 0, "failed": 0}) + _seq: int = 0 + _lock: threading.Lock = field(default_factory=threading.Lock, repr=False) + + def __post_init__(self) -> None: + self.running.set() + + +def _record_event(state: WatcherState, event: str, data: dict[str, Any]) -> None: + """Append a timestamped, sequenced event to the per-KB ring buffer.""" + with state._lock: + state._seq += 1 + seq = state._seq + state.events.append({"seq": seq, "ts": time.time(), "event": event, "data": data}) + + +def _inc(state: WatcherState, key: str) -> None: + with state._lock: + state.counters[key] = state.counters.get(key, 0) + 1 + + +def _public_event(ev: dict[str, Any]) -> dict[str, Any]: + """Strip the internal ``seq`` before exposing an event over the API.""" + return {"ts": ev["ts"], "event": ev["event"], "data": ev.get("data", {})} + + +def _run_worker(state: WatcherState) -> None: + """Consume debounced file batches and ingest each file. + + Exits when ``stop`` puts the ``None`` sentinel. Any per-file exception is + recorded as an ``error`` event; the worker never propagates so one bad + file can't kill the watcher. + """ + try: + while True: + try: + paths = state.queue.get() + except Exception: + return + if paths is None: + return + for raw_path in paths: + _process_file(state, raw_path) + finally: + # Ensure `running` reflects reality even on unexpected exit, so + # status() reports inactive and start() can spawn a fresh worker. + state.running.clear() + + +def _process_file(state: WatcherState, raw_path: str) -> None: + path = Path(raw_path) + suffix = path.suffix.lower() + if not suffix or suffix not in SUPPORTED_EXTENSIONS: + supported = ", ".join(sorted(SUPPORTED_EXTENSIONS)) + _record_event(state, "file_done", { + "path": str(path), + "original_name": path.name, + "status": "skipped", + "message": f"Skipping unsupported file type: {suffix}. Supported: {supported}", + }) + _inc(state, "skipped") + return + _record_event(state, "file_start", { + "path": str(path), + "original_name": path.name, + }) + try: + result = _add_for_api(path, state.kb_dir) + except Exception as exc: # worker must never die + _record_event(state, "error", { + "path": str(path), + "original_name": path.name, + "message": f"Failed to add: {path.name}: {exc}", + }) + _inc(state, "failed") + return + status = result.status if result.status in ("added", "skipped", "failed") else "failed" + _record_event(state, "file_done", { + "path": str(path), + "original_name": path.name, + "status": status, + "message": result.message, + }) + _inc(state, status) + + +class WatchRegistry: + """Thread-safe registry of per-KB watchers (one app instance owns one).""" + + def __init__(self, max_events: int = _MAX_EVENTS) -> None: + self._watchers: dict[str, WatcherState] = {} + self._lock = threading.Lock() + self._max_events = max_events + + def start(self, kb: str, kb_dir: Path, debounce: float = 2.0) -> WatcherState: + """Start a watcher for *kb*, or return the existing one (idempotent). + + If a prior watcher state exists but its worker thread is no longer + alive (the daemon died), it is purged and a fresh watcher starts. + A still-running worker for *kb* is returned as-is. + """ + with self._lock: + existing = self._watchers.get(kb) + if existing is not None: + worker_alive = ( + existing.worker_thread is not None + and existing.worker_thread.is_alive() + ) + if existing.running.is_set() and worker_alive: + return existing + # Stale state from a dead worker: purge before starting fresh. + self._watchers.pop(kb, None) + raw_dir = kb_dir / "raw" + raw_dir.mkdir(parents=True, exist_ok=True) + state = WatcherState( + kb=kb, + kb_dir=kb_dir, + raw_dir=raw_dir, + debounce=debounce, + started_at=time.time(), + events=deque(maxlen=self._max_events), + ) + + def on_new_files(paths: list[str]) -> None: + state.queue.put(paths) + + state.observer = start_watch(raw_dir, on_new_files, debounce=debounce) + state.worker_thread = threading.Thread( + target=_run_worker, args=(state,), daemon=True, + name=f"openkb-watch-{kb}", + ) + self._watchers[kb] = state + state.worker_thread.start() + return state + def get(self, kb: str) -> WatcherState | None: + with self._lock: + return self._watchers.get(kb) + + def recent_events(self, kb: str) -> list[dict[str, Any]]: + """Return public (seq-stripped) recent events for *kb* (empty if none).""" + with self._lock: + state = self._watchers.get(kb) + if state is None: + return [] + return [_public_event(e) for e in state.events] + + def status(self, kb: str) -> dict[str, Any]: + with self._lock: + state = self._watchers.get(kb) + if state is None: + return {"kb": kb, "active": False} + with state._lock: + counters = dict(state.counters) + return { + "kb": state.kb, + "active": state.running.is_set(), + "started_at": state.started_at, + "raw_dir": str(state.raw_dir), + "debounce": state.debounce, + "counters": counters, + "recent_events": [_public_event(e) for e in state.events], + } + + def list_active(self) -> list[str]: + with self._lock: + return list(self._watchers.keys()) + + def stop(self, kb: str) -> bool: + """Stop and remove *kb*'s watcher. Return False if none was active. + + Joins the worker *before* clearing ``running`` and popping the state, + so ``status()`` reports ``active: True`` (draining) while the old + worker finishes. If the worker does not exit within the join timeout, + the state is left in place with a ``watcher_draining`` event so + ``start()`` knows not to spawn a second worker. + """ + with self._lock: + state = self._watchers.get(kb) + if state is None: + return False + # Signal the worker and observer to stop, then wait for them. + try: + if state.observer is not None: + state.observer.stop() + state.observer.join(timeout=5.0) + except Exception: + pass + state.queue.put(None) + if state.worker_thread is not None: + state.worker_thread.join(timeout=5.0) + + # If the worker is still alive after the join timeout, it is likely + # mid-compile. Do not pop the state or clear running — leave a + # draining marker so start() refuses to spawn a duplicate. + if state.worker_thread is not None and state.worker_thread.is_alive(): + _record_event(state, "watcher_draining", {"kb": kb}) + return True + + # Worker has exited: safe to finalize. + state.running.clear() + _record_event(state, "watcher_stopped", {"kb": kb}) + with self._lock: + # Only pop if it is still the same state (not replaced by start()). + if self._watchers.get(kb) is state: + self._watchers.pop(kb, None) + return True + def stop_all(self) -> None: + for kb in self.list_active(): + self.stop(kb) diff --git a/openkb/watcher.py b/openkb/watcher.py index 2a0fae91..94e96b2f 100644 --- a/openkb/watcher.py +++ b/openkb/watcher.py @@ -81,18 +81,45 @@ def watch_directory( ) -> None: """Start watching *raw_dir* and block until Ctrl+C. + Thin blocking wrapper around :func:`start_watch`; kept so the CLI + ``openkb watch`` command is unchanged. The REST layer uses + :func:`start_watch` directly so it can own the Observer's lifecycle. + Args: raw_dir: Directory to watch for file changes. callback: Called with sorted list of new/modified file paths. debounce: Debounce delay in seconds. Defaults to 2.0. """ - handler = DebouncedHandler(callback, debounce_seconds=debounce) - observer = Observer() - observer.schedule(handler, str(raw_dir), recursive=True) - observer.start() + observer = start_watch(raw_dir, callback, debounce) try: while observer.is_alive(): observer.join(timeout=1.0) except KeyboardInterrupt: observer.stop() observer.join() + + +def start_watch( + raw_dir: Path, + callback: Callable[[list[str]], None], + debounce: float = 2.0, +) -> Observer: + """Start a non-blocking watcher on *raw_dir* and return its Observer. + + The caller owns the Observer's lifecycle: call ``observer.stop()`` then + ``observer.join()`` to tear it down. Debounce/filter behavior is identical + to :func:`watch_directory`. + + Args: + raw_dir: Directory to watch for file changes. + callback: Called with sorted list of new/modified file paths. + debounce: Debounce delay in seconds. Defaults to 2.0. + + Returns: + The started watchdog Observer. + """ + handler = DebouncedHandler(callback, debounce_seconds=debounce) + observer = Observer() + observer.schedule(handler, str(raw_dir), recursive=True) + observer.start() + return observer diff --git a/pyproject.toml b/pyproject.toml index 694ab5e0..cfca17f7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -54,12 +54,14 @@ Issues = "https://github.com/VectifyAI/OpenKB/issues" [project.scripts] openkb = "openkb.cli:cli" +openkb-api = "openkb.api:main" [tool.pytest.ini_options] testpaths = ["tests"] [project.optional-dependencies] -dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0"] +dev = ["pytest==9.0.3", "pytest-asyncio==1.3.0", "httpx"] +api = ["fastapi", "uvicorn", "python-multipart"] [tool.hatch.version] source = "vcs" diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 00000000..1cd8dae7 --- /dev/null +++ b/tests/test_api.py @@ -0,0 +1,1684 @@ +from __future__ import annotations + +import json +from typing import Any + +import yaml +from fastapi.testclient import TestClient + +from openkb.api import create_app + + +def _client(monkeypatch, token: str | None = "secret") -> TestClient: + if token is None: + monkeypatch.delenv("OPENKB_API_TOKEN", raising=False) + else: + monkeypatch.setenv("OPENKB_API_TOKEN", token) + return TestClient(create_app()) + + +def _auth(token: str = "secret") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _use_named_kb(monkeypatch, kb_dir, name: str = "test-kb") -> str: + def resolve(kb): + assert kb == name + return kb_dir + + monkeypatch.setattr("openkb.api.resolve_kb_alias", resolve) + return name + + +def _events_from_sse(text: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for block in text.strip().split("\n\n"): + lines = block.splitlines() + event = lines[0].removeprefix("event: ") + data = json.loads(lines[1].removeprefix("data: ")) + events.append({"event": event, "data": data}) + return events + + +def test_api_requires_configured_token(monkeypatch, kb_dir): + client = _client(monkeypatch, token=None) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/query", + json={"kb": kb, "question": "What?"}, + headers=_auth(), + ) + + assert response.status_code == 500 + assert "OPENKB_API_TOKEN" in response.json()["detail"] + + +def test_api_rejects_missing_or_invalid_token(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + missing = client.post( + "/api/v1/query", + json={"kb": kb, "question": "What?"}, + ) + invalid = client.post( + "/api/v1/query", + json={"kb": kb, "question": "What?"}, + headers=_auth("wrong"), + ) + + assert missing.status_code == 401 + assert invalid.status_code == 401 + + +def test_query_non_stream_returns_json(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + async def fake_run_query(question, kb, model, stream=False, **kwargs): + assert question == "What is OpenKB?" + assert kb == kb_dir + assert stream is False + return "A knowledge base." + + monkeypatch.setattr("openkb.api._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.api.run_query", fake_run_query) + + response = client.post( + "/api/v1/query", + json={"kb": kb, "question": "What is OpenKB?", "stream": False}, + headers=_auth(), + ) + + assert response.status_code == 200 + assert response.json() == {"answer": "A knowledge base.", "saved_path": None} + + +def test_query_stream_returns_sse_events(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + async def fake_events(agent, question, **kwargs): + assert question == "What is OpenKB?" + yield {"event": "delta", "data": {"text": "A knowledge"}} + yield {"event": "delta", "data": {"text": " base."}} + yield {"event": "final", "data": {"answer": "A knowledge base.", "history": []}} + + monkeypatch.setattr("openkb.api._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.api.build_query_agent", lambda *args, **kwargs: object()) + monkeypatch.setattr("openkb.api.iter_agent_response_events", fake_events) + + response = client.post( + "/api/v1/query", + json={"kb": kb, "question": "What is OpenKB?"}, + headers=_auth(), + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + events = _events_from_sse(response.text) + assert [event["event"] for event in events] == ["start", "delta", "delta", "final", "done"] + assert events[-2]["data"]["answer"] == "A knowledge base." + + +def test_chat_non_stream_creates_and_persists_session(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + async def fake_agent_events(agent, input_data, *, max_turns, **kwargs): + yield {"event": "delta", "data": {"text": "Hello"}} + yield { + "event": "final", + "data": { + "answer": "Hello", + "history": [{"role": "assistant", "content": "Hello"}], + }, + } + + monkeypatch.setattr("openkb.api._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.api.build_chat_session_agent", lambda *args, **kwargs: object()) + monkeypatch.setattr("openkb.agent.chat.iter_agent_response_events", fake_agent_events) + + response = client.post( + "/api/v1/chat", + json={"kb": kb, "message": "Hi", "stream": False}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["answer"] == "Hello" + assert payload["turn_count"] == 1 + session_path = kb_dir / ".openkb" / "chats" / f"{payload['session_id']}.json" + assert session_path.exists() + + +def test_chat_stream_resumes_session(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.agent.chat_session import ChatSession + + session = ChatSession.new(kb_dir, "gpt-4o-mini", "en") + session.record_turn("Hi", "Hello", [{"role": "assistant", "content": "Hello"}]) + + async def fake_chat_events(agent, loaded_session, message, **kwargs): + assert loaded_session.id == session.id + assert message == "Again" + yield {"event": "delta", "data": {"text": "Again"}} + yield { + "event": "final", + "data": { + "answer": "Again", + "session_id": loaded_session.id, + "turn_count": 2, + }, + } + + monkeypatch.setattr("openkb.api._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.api.build_chat_session_agent", lambda *args, **kwargs: object()) + monkeypatch.setattr("openkb.api.iter_chat_turn_events", fake_chat_events) + + response = client.post( + "/api/v1/chat", + json={"kb": kb, "message": "Again", "session_id": session.id}, + headers=_auth(), + ) + + assert response.status_code == 200 + events = _events_from_sse(response.text) + assert events[0]["data"]["session_id"] == session.id + assert events[-2]["data"]["turn_count"] == 2 + + +def test_init_endpoint_creates_named_kb_under_env_root(monkeypatch, tmp_path): + client = _client(monkeypatch) + root = tmp_path / "api-kbs" + kb_dir = root / "postman-kb" + # Run from a clean CWD so the KB does not inherit the real project-root + # config.yaml/.env (initialize_kb seeds from Path.cwd()). + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENKB_KB_ROOT", str(root)) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + + response = client.post( + "/api/v1/init", + json={"kb": "postman-kb", "model": "gpt-5.4-mini"}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["kb"] == "postman-kb" + assert payload["created"] is True + assert payload["env_written"] == {"api_key": False, "openai_api_base": False} + assert (kb_dir / ".openkb" / "config.yaml").is_file() + assert (kb_dir / "wiki" / "AGENTS.md").is_file() + global_config = yaml.safe_load((tmp_path / "global.yaml").read_text(encoding="utf-8")) + assert global_config["kb_aliases"] == {"postman-kb": str(kb_dir.resolve())} + + +def test_init_endpoint_ignores_stale_alias_for_creation(monkeypatch, tmp_path): + client = _client(monkeypatch) + root = tmp_path / "api-kbs" + kb_dir = root / "postman-kb" + stale_path = tmp_path / "stale-kb" + stale_path.joinpath(".openkb").mkdir(parents=True) + stale_path.joinpath("wiki").mkdir() + stale_path.joinpath("wiki", "AGENTS.md").write_text("# stale\n", encoding="utf-8") + global_dir = tmp_path / "global-config" + global_path = global_dir / "global.yaml" + global_dir.mkdir() + global_path.write_text( + yaml.safe_dump( + { + "default_kb": str(stale_path), + "known_kbs": [str(stale_path)], + "kb_aliases": {"postman-kb": str(stale_path)}, + }, + sort_keys=True, + ), + encoding="utf-8", + ) + monkeypatch.setenv("OPENKB_KB_ROOT", str(root)) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", global_path) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", global_dir) + + response = client.post( + "/api/v1/init", + json={"kb": "postman-kb"}, + headers=_auth(), + ) + + assert response.status_code == 200 + assert (kb_dir / ".openkb" / "config.yaml").is_file() + assert (kb_dir / "wiki" / "AGENTS.md").is_file() + global_config = yaml.safe_load(global_path.read_text(encoding="utf-8")) + assert global_config["kb_aliases"]["postman-kb"] == str(kb_dir.resolve()) + + +def test_init_endpoint_rejects_invalid_kb_name(monkeypatch): + client = _client(monkeypatch) + + response = client.post( + "/api/v1/init", + json={"kb": "../bad"}, + headers=_auth(), + ) + + assert response.status_code == 400 + assert "KB name" in response.json()["detail"] + + +def test_init_endpoint_writes_env_without_leaking_values(monkeypatch, tmp_path): + client = _client(monkeypatch) + root = tmp_path / "api-kbs" + kb_dir = root / "new-kb" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("OPENKB_KB_ROOT", str(root)) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + + response = client.post( + "/api/v1/init", + json={ + "kb": "new-kb", + "api_key": "sk-secret", + "openai_api_base": "https://gateway.example/v1", + }, + headers=_auth(), + ) + + assert response.status_code == 200 + payload_text = response.text + assert "sk-secret" not in payload_text + assert "https://gateway.example/v1" not in payload_text + assert response.json()["env_written"] == {"api_key": True, "openai_api_base": True} + assert (kb_dir / ".env").read_text(encoding="utf-8") == ( + "LITELLM_LOCAL_MODEL_COST_MAP=true\n" + "LLM_API_KEY=sk-secret\n" + "OPENAI_API_BASE=https://gateway.example/v1\n" + ) + + +def test_init_endpoint_inherits_project_root_config(monkeypatch, tmp_path): + # A KB created from the REST UI (no explicit params) should inherit the + # operator's project-root config.yaml and LLM credentials from .env, so it + # can run queries/compiles out of the box. Server-level OPENKB_* vars are + # filtered out of the inherited .env. + client = _client(monkeypatch) + root = tmp_path / "api-kbs" + kb_dir = root / "templated-kb" + # Simulate the project root: deploy a config.yaml + .env at CWD. + monkeypatch.chdir(tmp_path) + (tmp_path / "config.yaml").write_text( + "model: openai/deepseek-v4-flash\n" + "language: zh\n" + "pageindex_threshold: 20\n", + encoding="utf-8", + ) + (tmp_path / ".env").write_text( + "LLM_API_KEY=sk-inherited\n" + "OPENAI_API_BASE=https://gateway.example/v1\n" + "OPENKB_API_TOKEN=secret\n" + "OPENKB_KB_ROOT=" + str(root) + "\n", + encoding="utf-8", + ) + monkeypatch.setenv("OPENKB_KB_ROOT", str(root)) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + + response = client.post("/api/v1/init", json={"kb": "templated-kb"}, headers=_auth()) + + assert response.status_code == 200 + payload = response.json() + assert payload["env_written"] == {"api_key": True, "openai_api_base": True} + # config.yaml inherited verbatim (model/language preserved, not gpt-5.4/en). + config = yaml.safe_load((kb_dir / ".openkb" / "config.yaml").read_text("utf-8")) + assert config["model"] == "openai/deepseek-v4-flash" + assert config["language"] == "zh" + # .env inherited LLM creds but dropped server-level OPENKB_* vars. + env_text = (kb_dir / ".env").read_text(encoding="utf-8") + assert "LLM_API_KEY=sk-inherited" in env_text + assert "OPENAI_API_BASE=https://gateway.example/v1" in env_text + assert "OPENKB_API_TOKEN" not in env_text + assert "OPENKB_KB_ROOT" not in env_text + + +def test_init_endpoint_rejects_existing_kb(monkeypatch, tmp_path): + client = _client(monkeypatch) + root = tmp_path / "api-kbs" + kb_dir = root / "existing-kb" + (kb_dir / ".openkb").mkdir(parents=True) + monkeypatch.setenv("OPENKB_KB_ROOT", str(root)) + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_PATH", tmp_path / "global.yaml") + monkeypatch.setattr("openkb.config.GLOBAL_CONFIG_DIR", tmp_path) + + response = client.post( + "/api/v1/init", + json={"kb": "existing-kb"}, + headers=_auth(), + ) + + assert response.status_code == 400 + assert "already initialized" in response.json()["detail"] + + +def test_add_endpoint_rejects_missing_files(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + headers=_auth(), + ) + + assert response.status_code == 400 + assert "No files uploaded" in response.json()["detail"] + + +def test_add_endpoint_rejects_unsupported_extension(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[("files", ("bad.xyz", b"bad", "application/octet-stream"))], + headers=_auth(), + ) + + assert response.status_code == 400 + assert "Unsupported file type" in response.json()["detail"] + + +def test_add_endpoint_rejects_oversized_file(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + monkeypatch.setattr("openkb.api.MAX_UPLOAD_FILE_BYTES", 4) + monkeypatch.setattr("openkb.api.MAX_UPLOAD_REQUEST_BYTES", 100) + + response = client.post( + "/api/v1/add", + data={"kb": kb}, + files=[("files", ("paper.md", b"12345", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 413 + assert "Uploaded file exceeds limit" in response.json()["detail"] + assert not (kb_dir / "raw" / "paper.md").exists() + + +def test_add_endpoint_rejects_oversized_aggregate_request(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + monkeypatch.setattr("openkb.api.MAX_UPLOAD_FILE_BYTES", 10) + monkeypatch.setattr("openkb.api.MAX_UPLOAD_REQUEST_BYTES", 8) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[ + ("files", ("one.md", b"12345", "text/markdown")), + ("files", ("two.md", b"67890", "text/markdown")), + ], + headers=_auth(), + ) + + assert response.status_code == 413 + assert "Upload request exceeds limit" in response.json()["detail"] + assert not (kb_dir / "raw" / "one.md").exists() + assert not (kb_dir / "raw" / "two.md").exists() + + +def test_add_endpoint_uploads_and_adds_single_file(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.cli import AddFileResult + + calls = [] + + def fake_add(path, target_kb, **kwargs): + calls.append((path, target_kb)) + return AddFileResult(path.name, str(path), "added", f"{path.name} added to knowledge base.") + + monkeypatch.setattr("openkb.api._add_for_api", fake_add) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[("files", ("paper.md", b"# Paper", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 200 + saved_path = kb_dir / "raw" / "paper.md" + assert saved_path.read_bytes() == b"# Paper" + assert calls == [(saved_path, kb_dir)] + assert response.json()["files"][0] == { + "original_name": "paper.md", + "saved_path": str(saved_path), + "status": "added", + "message": "paper.md added to knowledge base.", + } + assert response.json()["added_count"] == 1 + + +def test_add_endpoint_runs_real_add_helper_outside_event_loop(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.converter import ConvertResult + + def fake_convert(path, target_kb): + assert target_kb == kb_dir + return ConvertResult( + raw_path=path, + source_path=target_kb / "wiki" / "sources" / path.name, + is_long_doc=False, + file_hash="abc123", + ) + + async def fake_compile_short_doc(doc_name, source_path, target_kb, model, **kwargs): + assert doc_name == "paper" + assert target_kb == kb_dir + + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.cli.convert_document", fake_convert) + monkeypatch.setattr("openkb.agent.compiler.compile_short_doc", fake_compile_short_doc) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[("files", ("paper.md", b"# Paper", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["added_count"] == 1 + assert payload["files"][0]["status"] == "added" + assert payload["files"][0]["saved_path"] == str(kb_dir / "raw" / "paper.md") + + +def test_add_endpoint_uploads_and_adds_multiple_files(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.cli import AddFileResult + + calls = [] + + def fake_add(path, target_kb, **kwargs): + calls.append((path, target_kb)) + if path.name == "notes.txt": + return AddFileResult( + path.name, + None, + "skipped", + "Already in knowledge base: notes.txt", + ) + return AddFileResult( + path.name, + str(path), + "added", + f"{path.name} added to knowledge base.", + ) + + monkeypatch.setattr("openkb.api._add_for_api", fake_add) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[ + ("files", ("paper.md", b"# Paper", "text/markdown")), + ("files", ("notes.txt", b"Notes", "text/plain")), + ], + headers=_auth(), + ) + + paper_path = kb_dir / "raw" / "paper.md" + notes_path = kb_dir / "raw" / "notes.txt" + assert response.status_code == 200 + assert paper_path.read_bytes() == b"# Paper" + assert not notes_path.exists() + assert calls == [(paper_path, kb_dir), (notes_path, kb_dir)] + assert response.json() == { + "kb": kb, + "files": [ + { + "original_name": "paper.md", + "saved_path": str(paper_path), + "status": "added", + "message": "paper.md added to knowledge base.", + }, + { + "original_name": "notes.txt", + "saved_path": None, + "status": "skipped", + "message": "Already in knowledge base: notes.txt", + }, + ], + "added_count": 1, + "skipped_count": 1, + "failed_count": 0, + } + + +def test_add_endpoint_uses_unique_raw_filename(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + (kb_dir / "raw" / "paper.md").write_text("existing", encoding="utf-8") + + from openkb.cli import AddFileResult + + def fake_add(path, target_kb, **kwargs): + return AddFileResult(path.name, str(path), "added", f"{path.name} added to knowledge base.") + + monkeypatch.setattr("openkb.api._add_for_api", fake_add) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[("files", ("paper.md", b"# New", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 200 + assert (kb_dir / "raw" / "paper.md").read_text(encoding="utf-8") == "existing" + assert (kb_dir / "raw" / "paper-1.md").read_bytes() == b"# New" + assert response.json()["files"][0]["original_name"] == "paper.md" + assert response.json()["files"][0]["saved_path"] == str(kb_dir / "raw" / "paper-1.md") + + +def test_add_endpoint_removes_skipped_upload(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.cli import AddFileResult + + skipped_path = None + + def fake_add(path, target_kb, **kwargs): + nonlocal skipped_path + skipped_path = path + return AddFileResult(path.name, None, "skipped", "Already in knowledge base: paper.md") + + monkeypatch.setattr("openkb.api._add_for_api", fake_add) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "false"}, + files=[("files", ("paper.md", b"# Paper", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 200 + assert skipped_path == kb_dir / "raw" / "paper.md" + assert not skipped_path.exists() + assert response.json()["files"][0] == { + "original_name": "paper.md", + "saved_path": None, + "status": "skipped", + "message": "Already in knowledge base: paper.md", + } + assert response.json()["skipped_count"] == 1 + + +def test_add_endpoint_streams_events(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + from openkb.cli import AddFileResult + + def fake_add(path, target_kb, **kwargs): + return AddFileResult(path.name, str(path), "added", f"{path.name} added to knowledge base.") + + monkeypatch.setattr("openkb.api._add_for_api", fake_add) + + response = client.post( + "/api/v1/add", + data={"kb": kb, "stream": "true"}, + files=[("files", ("paper.md", b"# Paper", "text/markdown"))], + headers=_auth(), + ) + + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + events = _events_from_sse(response.text) + assert [event["event"] for event in events] == [ + "start", + "uploaded", + "file_start", + "file_done", + "final", + "done", + ] + assert events[0]["data"]["kb"] == kb + assert events[-2]["data"]["added_count"] == 1 + + +def test_unknown_kb_returns_400(monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("openkb.api.resolve_kb_alias", lambda kb: tmp_path) + + response = client.post( + "/api/v1/query", + json={"kb": "missing-kb", "question": "What?"}, + headers=_auth(), + ) + + assert response.status_code == 400 + assert "Not a knowledge base" in response.json()["detail"] + + +def test_kb_endpoints_reject_unknown_kb(monkeypatch, tmp_path): + client = _client(monkeypatch) + monkeypatch.setattr("openkb.api.resolve_kb_alias", lambda kb: tmp_path) + + for path in ("/api/v1/list", "/api/v1/status", "/api/v1/lint"): + response = client.post( + path, + json={"kb": "missing-kb"}, + headers=_auth(), + ) + assert response.status_code == 400 + + +def test_kb_endpoints_reject_invalid_kb_name(monkeypatch): + client = _client(monkeypatch) + + response = client.post( + "/api/v1/query", + json={"kb": "../bad", "question": "What?"}, + headers=_auth(), + ) + + assert response.status_code == 400 + assert "KB name" in response.json()["detail"] + + +def test_unknown_chat_session_returns_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/chat", + json={ + "kb": kb, + "message": "Hi", + "session_id": "missing-session", + "stream": False, + }, + headers=_auth(), + ) + + assert response.status_code == 404 + + +def test_list_endpoint_returns_empty_inventory(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/list", + json={"kb": kb}, + headers=_auth(), + ) + + assert response.status_code == 200 + assert response.json() == { + "documents": [], + "document_count": 0, + "summaries": [], + "concepts": [], + "reports": [], + } + + +def test_list_endpoint_returns_structured_inventory(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + hashes = { + "abc123": {"name": "paper.pdf", "type": "pdf", "pages": 12}, + "def456": {"name": "notes.md", "type": "md"}, + } + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps(hashes), encoding="utf-8") + (kb_dir / "wiki" / "summaries" / "paper.md").write_text("# Paper", encoding="utf-8") + (kb_dir / "wiki" / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8") + (kb_dir / "wiki" / "reports" / "lint_20260101_000000.md").write_text("# Report", encoding="utf-8") + + response = client.post( + "/api/v1/list", + json={"kb": kb}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["document_count"] == 2 + assert payload["documents"] == [ + { + "hash": "abc123", + "name": "paper.pdf", + "type": "pdf", + "display_type": "short", + "pages": 12, + }, + { + "hash": "def456", + "name": "notes.md", + "type": "md", + "display_type": "short", + "pages": None, + }, + ] + assert payload["summaries"] == ["paper"] + assert payload["concepts"] == ["attention"] + assert payload["reports"] == ["lint_20260101_000000.md"] + + +def test_status_endpoint_returns_structured_counts(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + (kb_dir / "raw" / "paper.pdf").write_bytes(b"pdf") + (kb_dir / "wiki" / "sources" / "paper.md").write_text("# Source", encoding="utf-8") + (kb_dir / "wiki" / "summaries" / "paper.md").write_text("# Summary", encoding="utf-8") + (kb_dir / "wiki" / "concepts" / "attention.md").write_text("# Attention", encoding="utf-8") + (kb_dir / "wiki" / "reports" / "lint_20260101_000000.md").write_text("# Report", encoding="utf-8") + (kb_dir / ".openkb" / "hashes.json").write_text( + json.dumps({"abc123": {"name": "paper.pdf", "type": "pdf"}}), + encoding="utf-8", + ) + + response = client.post( + "/api/v1/status", + json={"kb": kb}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["directories"] == { + "sources": 1, + "summaries": 1, + "concepts": 1, + "reports": 1, + } + assert payload["raw_count"] == 1 + assert payload["total_indexed"] == 1 + assert payload["last_compile"] is not None + assert payload["last_lint"] is not None + + +def test_lint_endpoint_skips_empty_kb(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + response = client.post( + "/api/v1/lint", + json={"kb": kb}, + headers=_auth(), + ) + + assert response.status_code == 200 + assert response.json() == { + "skipped": True, + "reason": "no_documents_indexed", + "message": "Nothing to lint - no documents indexed yet. Run `openkb add` first.", + "structural_report": None, + "knowledge_report": None, + "report_path": None, + "lint_files_changed": None, + "lint_ghosts_removed": None, + } + assert list((kb_dir / "wiki" / "reports").glob("*.md")) == [] + + +def test_lint_endpoint_runs_and_writes_report(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + (kb_dir / ".openkb" / "hashes.json").write_text( + json.dumps({"abc123": {"name": "paper.pdf", "type": "pdf"}}), + encoding="utf-8", + ) + + async def fake_knowledge_lint(kb, model, **kwargs): + assert kb == kb_dir + return "No semantic issues." + + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.agent.linter.run_knowledge_lint", fake_knowledge_lint) + + response = client.post( + "/api/v1/lint", + json={"kb": kb}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["skipped"] is False + assert payload["reason"] is None + assert payload["message"] == "Lint report written." + assert "Structural Lint Report" in payload["structural_report"] + assert payload["knowledge_report"] == "No semantic issues." + # Without `fix`, the new count fields are absent (None). + assert payload["lint_files_changed"] is None + assert payload["lint_ghosts_removed"] is None + reports = list((kb_dir / "wiki" / "reports").glob("lint_*.md")) + assert len(reports) == 1 + assert payload["report_path"] == str(reports[0]) + assert "No semantic issues." in reports[0].read_text(encoding="utf-8") + +# --------------------------------------------------------------------------- +# POST /api/v1/remove +# --------------------------------------------------------------------------- + + +def test_lint_endpoint_fix_rewrites_broken_wikilinks(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + # Non-empty hashes so the report path (not the skip path) runs. + (kb_dir / ".openkb" / "hashes.json").write_text( + json.dumps({"abc123": {"name": "paper.pdf", "type": "pdf"}}), + encoding="utf-8", + ) + # Valid target + a page with a broken link that has no match. + (kb_dir / "wiki" / "concepts" / "a.md").write_text("A valid page.", encoding="utf-8") + page = kb_dir / "wiki" / "summaries" / "s.md" + page.write_text("See [[concepts/a]] and [[Ghost Link]].", encoding="utf-8") + + async def fake_knowledge_lint(kb, model, **kwargs): + return "No semantic issues." + + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.agent.linter.run_knowledge_lint", fake_knowledge_lint) + + response = client.post( + "/api/v1/lint", + json={"kb": kb, "fix": True}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + # The broken link was stripped from its file. + assert payload["lint_files_changed"] >= 1 + assert payload["lint_ghosts_removed"] >= 1 + assert "[[Ghost Link]]" not in page.read_text(encoding="utf-8") + # The valid link is left intact. + assert "[[concepts/a]]" in page.read_text(encoding="utf-8") + # A lint report was still written, reflecting the post-fix state. + assert payload["skipped"] is False + assert "Fixed" in payload["message"] + reports = list((kb_dir / "wiki" / "reports").glob("lint_*.md")) + assert len(reports) == 1 + + +def test_lint_endpoint_fix_noop_when_clean(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + (kb_dir / ".openkb" / "hashes.json").write_text( + json.dumps({"abc123": {"name": "paper.pdf", "type": "pdf"}}), + encoding="utf-8", + ) + # Clean wiki: only a valid, resolving link. + (kb_dir / "wiki" / "concepts" / "a.md").write_text("A valid page.", encoding="utf-8") + (kb_dir / "wiki" / "summaries" / "s.md").write_text( + "See [[concepts/a]].", encoding="utf-8" + ) + + async def fake_knowledge_lint(kb, model, **kwargs): + return "No semantic issues." + + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.agent.linter.run_knowledge_lint", fake_knowledge_lint) + + response = client.post( + "/api/v1/lint", + json={"kb": kb, "fix": True}, + headers=_auth(), + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["lint_files_changed"] == 0 + assert payload["lint_ghosts_removed"] == 0 + assert "Nothing to fix" in payload["message"] + assert payload["skipped"] is False + + +def test_remove_non_stream_success(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + payload = { + "status": "removed", "name": "paper.pdf", "doc_name": "paper", + "actions": [{"tag": "DELETE", "target": "wiki/summaries/paper.md"}], + "concepts_deleted": ["transformer"], "entities_deleted": [], + "lint_files_changed": 1, "lint_ghosts_removed": 2, + "pageindex_message": None, "pageindex_error": None, + "message": "paper.pdf removed from knowledge base.", + } + monkeypatch.setattr( + "openkb.api.run_remove_for_api", + lambda kb_dir, identifier, **kw: payload, + ) + + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "paper.pdf"}, + headers=_auth(), + ) + assert response.status_code == 200 + body = response.json() + assert body["status"] == "removed" + assert body["doc_name"] == "paper" + assert body["actions"][0]["tag"] == "DELETE" + assert body["lint_files_changed"] == 1 + + +def test_remove_not_found_returns_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + monkeypatch.setattr( + "openkb.api.run_remove_for_api", + lambda kb_dir, identifier, **kw: {"status": "not_found", "message": "Document not found."}, + ) + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "nope"}, + headers=_auth(), + ) + assert response.status_code == 404 + + +def test_remove_multiple_matches_returns_409(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + monkeypatch.setattr( + "openkb.api.run_remove_for_api", + lambda kb_dir, identifier, **kw: { + "status": "multiple", + "candidates": [ + {"name": "a.pdf", "doc_name": "a-h1"}, + {"name": "b.pdf", "doc_name": "b-h2"}, + ], + }, + ) + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "h"}, + headers=_auth(), + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["candidates"][0]["doc_name"] == "a-h1" + + +def test_remove_dry_run_returns_plan(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + captured = {} + + def fake(kb_dir, identifier, *, keep_raw, keep_empty, dry_run): + captured["dry_run"] = dry_run + captured["keep_raw"] = keep_raw + return { + "status": "dry_run", "name": "paper.pdf", "doc_name": "paper", + "actions": [{"tag": "DELETE", "target": "wiki/summaries/paper.md"}], + "concepts_deleted": [], "entities_deleted": [], + } + + monkeypatch.setattr("openkb.api.run_remove_for_api", fake) + + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "paper.pdf", "dry_run": True, "keep_raw": True}, + headers=_auth(), + ) + assert response.status_code == 200 + assert response.json()["status"] == "dry_run" + assert captured["dry_run"] is True + assert captured["keep_raw"] is True + + +def test_remove_passes_keep_empty(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + captured = {} + + def fake(kb_dir, identifier, *, keep_raw, keep_empty, dry_run): + captured["keep_empty"] = keep_empty + return {"status": "removed", "name": "p", "doc_name": "p", "actions": []} + + monkeypatch.setattr("openkb.api.run_remove_for_api", fake) + client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "p", "keep_empty": True}, + headers=_auth(), + ) + assert captured["keep_empty"] is True + + +def test_remove_stream_returns_sse(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + monkeypatch.setattr( + "openkb.api.run_remove_for_api", + lambda kb_dir, identifier, **kw: { + "status": "removed", "name": "paper.pdf", "doc_name": "paper", + "actions": [{"tag": "DELETE", "target": "wiki/summaries/paper.md"}], + "concepts_deleted": [], "entities_deleted": [], + "message": "done", + }, + ) + + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "paper.pdf", "stream": True}, + headers=_auth(), + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + events = _events_from_sse(response.text) + names = [e["event"] for e in events] + assert names[0] == "start" + assert names[-1] == "done" + assert "plan" in names and "final" in names + + +def test_remove_stream_not_found(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + monkeypatch.setattr( + "openkb.api.run_remove_for_api", + lambda kb_dir, identifier, **kw: {"status": "not_found"}, + ) + response = client.post( + "/api/v1/remove", + json={"kb": kb, "identifier": "x", "stream": True}, + headers=_auth(), + ) + events = _events_from_sse(response.text) + assert any(e["event"] == "error" and e["data"].get("code") == 404 for e in events) + + + +# --------------------------------------------------------------------------- +# recompile endpoint +# --------------------------------------------------------------------------- +# +# Seed helpers mirror tests/test_recompile.py (_seed_short/_seed_long) but are +# kept local to avoid coupling the two test modules. + + +from pathlib import Path +from unittest.mock import AsyncMock, patch + + +def _seed_short(kb_dir: Path, *, slug: str = "notes", name: str = "notes.md") -> None: + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps({ + "h_s": {"name": name, "doc_name": slug, "type": "md"}, + })) + (kb_dir / "wiki" / "sources" / f"{slug}.md").write_text("# Notes\n\nbody\n", encoding="utf-8") + (kb_dir / "wiki" / "log.md").write_text("# Log\n\n", encoding="utf-8") + + +def _seed_long(kb_dir: Path, *, slug: str = "paper", name: str = "paper.pdf", + doc_id: str = "doc-abc123") -> None: + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps({ + "h_l": {"name": name, "doc_name": slug, "type": "long_pdf", "doc_id": doc_id}, + })) + (kb_dir / "wiki" / "summaries" / f"{slug}.md").write_text( + "---\nsources: [raw/paper.pdf]\nbrief: P\n---\n# Paper\n", encoding="utf-8", + ) + (kb_dir / "wiki" / "log.md").write_text("# Log\n\n", encoding="utf-8") + + +def _patch_recompile(monkeypatch): + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + short = AsyncMock() + long_ = AsyncMock() + monkeypatch.setattr("openkb.agent.compiler.compile_short_doc", short) + monkeypatch.setattr("openkb.agent.compiler.compile_long_doc", long_) + return short, long_ + + +def test_recompile_non_stream_short_doc(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + short, long_ = _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md"}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["status"] == "done" + assert body["recompiled"] == 1 + assert body["skipped"] == 0 + assert body["docs"][0]["status"] == "ok" + short.assert_called_once() + assert short.call_args.args[0] == "notes" # doc_name + assert short.call_args.args[2] == kb_dir # kb_dir + long_.assert_not_called() + + +def test_recompile_non_stream_long_doc(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_long(kb_dir) + short, long_ = _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "paper.pdf"}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + long_.assert_called_once() + args = long_.call_args.args + assert args[0] == "paper" # doc_name + assert args[2] == "doc-abc123" # doc_id + assert args[3] == kb_dir # kb_dir + short.assert_not_called() + + +def test_recompile_not_found_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "ghost"}, + headers=_auth(), + ) + assert response.status_code == 404 + + +def test_recompile_multiple_409(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + # Two entries whose slug share a substring. + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps({ + "h1": {"name": "a.pdf", "doc_name": "rep-x", "type": "md"}, + "h2": {"name": "b.pdf", "doc_name": "rep-y", "type": "md"}, + })) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "rep"}, + headers=_auth(), + ) + assert response.status_code == 409 + detail = response.json()["detail"] + assert detail["candidates"][0]["doc_name"] == "rep-x" + + +def test_recompile_requires_doc_or_all_400(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb}, + headers=_auth(), + ) + assert response.status_code == 400 + + +def test_recompile_both_doc_and_all_400(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md", "all_docs": True}, + headers=_auth(), + ) + assert response.status_code == 400 + + +def test_recompile_dry_run_returns_plan(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + short, _ = _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md", "dry_run": True}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["status"] == "dry_run" + assert body["targets"][0]["doc_name"] == "notes" + short.assert_not_called() + + +def test_recompile_all_recompiles_every_doc(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir, slug="a", name="a.md") + (kb_dir / "wiki" / "sources" / "a.md").write_text("# A\n", encoding="utf-8") + # add a second short doc to the registry + data = json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) + data["h2"] = {"name": "b.md", "doc_name": "b", "type": "md"} + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps(data)) + (kb_dir / "wiki" / "sources" / "b.md").write_text("# B\n", encoding="utf-8") + short, _ = _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "all_docs": True}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["total"] == 2 + assert body["recompiled"] == 2 + assert short.call_count == 2 + + +def test_recompile_refresh_schema_invoked(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + _patch_recompile(monkeypatch) + called = {"n": 0} + + def fake_refresh(wiki_dir): + called["n"] += 1 + return False + + monkeypatch.setattr("openkb.cli._refresh_schema", fake_refresh) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md", "refresh_schema": True}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + assert called["n"] == 1 + + +def test_recompile_skip_missing_source(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir, slug="good", name="good.md") + (kb_dir / "wiki" / "sources" / "good.md").write_text("# Good\n", encoding="utf-8") + # second doc whose source file is absent -> should be skipped + data = json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) + data["h2"] = {"name": "bad.md", "doc_name": "bad", "type": "md"} + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps(data)) + short, _ = _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "all_docs": True}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["recompiled"] == 1 + assert body["skipped"] == 1 + assert short.call_count == 1 + + +def test_recompile_compile_error_counts_as_skipped(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + short = AsyncMock(side_effect=RuntimeError("boom")) + long_ = AsyncMock() + monkeypatch.setattr("openkb.cli._setup_llm_key", lambda kb: None) + monkeypatch.setattr("openkb.agent.compiler.compile_short_doc", short) + monkeypatch.setattr("openkb.agent.compiler.compile_long_doc", long_) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md"}, + headers=_auth(), + ) + assert response.status_code == 200, response.text + body = response.json() + assert body["recompiled"] == 0 + assert body["skipped"] == 1 + assert body["docs"][0]["status"] == "error" + + +def test_recompile_stream_per_doc_events(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + _patch_recompile(monkeypatch) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "notes.md", "stream": True}, + headers=_auth(), + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/event-stream") + events = _events_from_sse(response.text) + names = [e["event"] for e in events] + assert names[0] == "start" + assert names[-1] == "done" + assert "doc" in names and "final" in names + + +def test_recompile_stream_not_found(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _seed_short(kb_dir) + + response = client.post( + "/api/v1/recompile", + json={"kb": kb, "doc_name": "ghost", "stream": True}, + headers=_auth(), + ) + events = _events_from_sse(response.text) + assert any(e["event"] == "error" and e["data"].get("code") == 404 for e in events) + + + +def test_cors_wildcard_disables_credentials(monkeypatch): + """allow_origins=[*] must force allow_credentials=False (CORS spec). + A wildcard with credentials lets any site send credentialed requests.""" + monkeypatch.setenv('OPENKB_CORS_ORIGINS', '*') + from openkb.api import _configure_cors + from fastapi import FastAPI + app = FastAPI() + _configure_cors(app) + # Find the CORS middleware in the stack + cors_mw = None + for mw in app.user_middleware: + if 'CORSMiddleware' in str(mw.cls): + cors_mw = mw + break + assert cors_mw is not None + assert cors_mw.kwargs.get('allow_credentials') is False + assert cors_mw.kwargs.get('allow_origins') == ['*'] + + +def test_cors_explicit_origins_keep_credentials(monkeypatch): + """Explicit origins allow credentials (normal case).""" + monkeypatch.setenv('OPENKB_CORS_ORIGINS', 'http://localhost:3000') + from openkb.api import _configure_cors + from fastapi import FastAPI + app = FastAPI() + _configure_cors(app) + cors_mw = None + for mw in app.user_middleware: + if 'CORSMiddleware' in str(mw.cls): + cors_mw = mw + break + assert cors_mw.kwargs.get('allow_credentials') is True + + +def test_token_compare_digest_accepts_correct(monkeypatch, kb_dir): + """compare_digest should accept the correct token and reject wrong ones.""" + monkeypatch.setenv('OPENKB_API_TOKEN', 'secret') + client = TestClient(create_app()) + # Wrong token -> 401 + r = client.get('/api/v1/kbs', headers={'Authorization': 'Bearer wrong'}) + assert r.status_code == 401 + # Correct token -> 200 + r = client.get('/api/v1/kbs', headers={'Authorization': 'Bearer secret'}) + assert r.status_code == 200 + + +def test_concurrent_same_kb_lint_serialized(monkeypatch, kb_dir): + """Two concurrent lint requests to the same KB must not overlap. + + Drives the real _kb_mutation_lock closure inside create_app() via two + concurrent ASGI requests. Without per-KB serialization, both would run + simultaneously and max_seen would be 2. + """ + import asyncio + import httpx + + monkeypatch.setenv("OPENKB_API_TOKEN", "secret") + _use_named_kb(monkeypatch, kb_dir) + + active = 0 + max_seen = 0 + + async def slow_lint(kb_dir_arg, *, fix, **kwargs): + nonlocal active, max_seen + active += 1 + max_seen = max(max_seen, active) + await asyncio.sleep(0.1) + active -= 1 + return {"skipped": False, "message": "ok"} + + monkeypatch.setattr("openkb.api.run_lint_report", slow_lint) + + app = create_app() + + async def main(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + r1, r2 = await asyncio.gather( + client.post("/api/v1/lint", json={"kb": "test-kb", "fix": True}, headers=_auth()), + client.post("/api/v1/lint", json={"kb": "test-kb", "fix": True}, headers=_auth()), + ) + return r1, r2 + + r1, r2 = asyncio.run(main()) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert max_seen == 1, f"expected max 1 concurrent lint, got {max_seen} (lock not serializing)" + + + +def test_concurrent_readonly_lint_not_serialized(monkeypatch, kb_dir): + """Two concurrent read-only lint (fix=False) requests to the same KB may overlap. + + Only fix=True takes the per-KB mutation lock; read-only lint is a report + and must not be serialized, otherwise read-only lint is over-constrained. + """ + import asyncio + import httpx + + monkeypatch.setenv("OPENKB_API_TOKEN", "secret") + _use_named_kb(monkeypatch, kb_dir) + + active = 0 + max_seen = 0 + + async def slow_lint(kb_dir_arg, *, fix, **kwargs): + nonlocal active, max_seen + active += 1 + max_seen = max(max_seen, active) + await asyncio.sleep(0.1) + active -= 1 + return {"skipped": False, "message": "ok"} + + monkeypatch.setattr("openkb.api.run_lint_report", slow_lint) + + app = create_app() + + async def main(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + r1, r2 = await asyncio.gather( + client.post("/api/v1/lint", json={"kb": "test-kb", "fix": False}, headers=_auth()), + client.post("/api/v1/lint", json={"kb": "test-kb", "fix": False}, headers=_auth()), + ) + return r1, r2 + + r1, r2 = asyncio.run(main()) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert max_seen == 2, f"expected max 2 concurrent read-only lint, got {max_seen} (over-serialized)" + + +def test_resolve_credential_bundle_reads_kb_key(monkeypatch, kb_dir): + """resolve_credential_bundle reads the KB's LLM_API_KEY without polluting os.environ.""" + import os + from openkb.config import resolve_credential_bundle + + monkeypatch.setenv("LLM_API_KEY", "server-key") + (kb_dir / ".env").write_text("LLM_API_KEY=kb-specific-key\n", encoding="utf-8") + + bundle = resolve_credential_bundle(kb_dir) + assert bundle.api_key == "kb-specific-key" + # os.environ must NOT be polluted (unlike the old _scoped_llm_key). + assert os.environ.get("LLM_API_KEY") == "server-key" + + +def test_query_endpoint_passes_kb_key_in_bundle(monkeypatch, kb_dir): + """The query endpoint must pass the KB's key in the bundle, not via os.environ.""" + import os + + monkeypatch.setenv("OPENKB_API_TOKEN", "secret") + monkeypatch.setenv("LLM_API_KEY", "server-key") + kb = _use_named_kb(monkeypatch, kb_dir) + (kb_dir / ".env").write_text("LLM_API_KEY=kb-specific-key\n", encoding="utf-8") + + captured = {} + + async def fake_run_query(question, kbd, model, stream=False, **kwargs): + bundle = kwargs.get("bundle") + captured["bundle_key"] = bundle.api_key if bundle else None + captured["env_key"] = os.environ.get("LLM_API_KEY") + return "answer" + + monkeypatch.setattr("openkb.api.run_query", fake_run_query) + + client = TestClient(create_app()) + r = client.post( + "/api/v1/query", + json={"kb": kb, "question": "q", "stream": False}, + headers=_auth(), + ) + assert r.status_code == 200 + assert captured.get("bundle_key") == "kb-specific-key" + # os.environ must remain the server key (no env mutation). + assert captured.get("env_key") == "server-key" + + +def test_resolve_credential_bundle_no_env_returns_none(monkeypatch, kb_dir): + """If the KB has no .env, the bundle's api_key is None.""" + from openkb.config import resolve_credential_bundle + + bundle = resolve_credential_bundle(kb_dir) + assert bundle.api_key is None + + + +def test_concurrent_same_kb_recompile_serialized(monkeypatch, kb_dir): + """Two concurrent non-streaming recompile requests to the same KB must not overlap. + + Mirrors test_concurrent_same_kb_lint_serialized but for the recompile path, + which also relies on the per-KB asyncio.Lock. Without serialization both + async generators would be iterated concurrently and max_seen would be 2. + """ + import asyncio + import httpx + + monkeypatch.setenv("OPENKB_API_TOKEN", "secret") + _use_named_kb(monkeypatch, kb_dir) + + active = 0 + max_seen = 0 + + async def slow_iter_recompile(kb_dir_arg, doc_name, *, all_docs, dry_run, refresh_schema, **kwargs): + nonlocal active, max_seen + active += 1 + max_seen = max(max_seen, active) + await asyncio.sleep(0.1) + active -= 1 + yield {"event": "final", "recompiled": 0, "skipped": 0} + + monkeypatch.setattr("openkb.api.iter_recompile", slow_iter_recompile) + + app = create_app() + + async def main(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + r1, r2 = await asyncio.gather( + client.post("/api/v1/recompile", json={"kb": "test-kb", "all_docs": True}, headers=_auth()), + client.post("/api/v1/recompile", json={"kb": "test-kb", "all_docs": True}, headers=_auth()), + ) + return r1, r2 + + r1, r2 = asyncio.run(main()) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert max_seen == 1, f"expected max 1 concurrent recompile, got {max_seen} (lock not serializing)" + + +def test_concurrent_different_kbs_do_not_block(monkeypatch, kb_dir, tmp_path_factory): + """Concurrent recompiles on different KBs must not serialize against each other. + + The per-KB lock is keyed by KB name, so two distinct KBs should run + concurrently (max_seen == 2). This guards against an accidental single + global lock that would serialize cross-KB traffic. + """ + import asyncio + import httpx + + monkeypatch.setenv("OPENKB_API_TOKEN", "secret") + kb_dir_a = kb_dir + kb_dir_b = tmp_path_factory.mktemp("kb-b") + for d in (kb_dir_a, kb_dir_b): + (d / ".openkb").mkdir(parents=True, exist_ok=True) + (d / "wiki").mkdir(parents=True, exist_ok=True) + + def resolve(kb): + return kb_dir_a if kb == "kb-a" else kb_dir_b + + monkeypatch.setattr("openkb.api.resolve_kb_alias", resolve) + + active = 0 + max_seen = 0 + + async def slow_iter_recompile(kb_dir_arg, doc_name, *, all_docs, dry_run, refresh_schema, **kwargs): + nonlocal active, max_seen + active += 1 + max_seen = max(max_seen, active) + await asyncio.sleep(0.1) + active -= 1 + yield {"event": "final", "recompiled": 0, "skipped": 0} + + monkeypatch.setattr("openkb.api.iter_recompile", slow_iter_recompile) + + app = create_app() + + async def main(): + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + r1, r2 = await asyncio.gather( + client.post("/api/v1/recompile", json={"kb": "kb-a", "all_docs": True}, headers=_auth()), + client.post("/api/v1/recompile", json={"kb": "kb-b", "all_docs": True}, headers=_auth()), + ) + return r1, r2 + + r1, r2 = asyncio.run(main()) + assert r1.status_code == 200 + assert r2.status_code == 200 + assert max_seen == 2, f"expected 2 concurrent cross-KB recompiles, got {max_seen} (cross-KB serialized)" + diff --git a/tests/test_api_watch.py b/tests/test_api_watch.py new file mode 100644 index 00000000..b7123795 --- /dev/null +++ b/tests/test_api_watch.py @@ -0,0 +1,235 @@ +"""Tests for the watch REST endpoints (lifecycle + SSE), isolated per app. + +Mirrors the helpers/patterns in tests/test_api.py. Ingest is mocked so no real +LLM/compilation runs. +""" +from __future__ import annotations + +import json +import time +from typing import Any + +from fastapi.testclient import TestClient + +from openkb.api import create_app +from openkb.cli import AddFileResult + + +def _client(monkeypatch, token: str | None = "secret") -> TestClient: + if token is None: + monkeypatch.delenv("OPENKB_API_TOKEN", raising=False) + else: + monkeypatch.setenv("OPENKB_API_TOKEN", token) + return TestClient(create_app()) + + +def _auth(token: str = "secret") -> dict[str, str]: + return {"Authorization": f"Bearer {token}"} + + +def _use_named_kb(monkeypatch, kb_dir, name: str = "test-kb") -> str: + def resolve(kb): + assert kb == name + return kb_dir + + monkeypatch.setattr("openkb.api.resolve_kb_alias", resolve) + return name + + +def _events_from_sse(text: str) -> list[dict[str, Any]]: + events: list[dict[str, Any]] = [] + for block in text.strip().split("\n\n"): + lines = block.splitlines() + if not lines: + continue + event = lines[0].removeprefix("event: ") + data = json.loads(lines[1].removeprefix("data: ")) + events.append({"event": event, "data": data}) + return events + + +def _mock_add(monkeypatch, status: str = "added"): + monkeypatch.setattr( + "openkb.watch_service._add_for_api", + lambda path, kb: AddFileResult(path.name, str(path), status, "msg"), + ) + + +def test_watch_endpoints_require_token(monkeypatch, kb_dir): + client = _client(monkeypatch, token=None) + kb = _use_named_kb(monkeypatch, kb_dir) + for method, path in ( + ("post", "/api/v1/watch/start"), + ("post", "/api/v1/watch/status"), + ): + resp = getattr(client, method)(path, json={"kb": kb}) + assert resp.status_code == 500 + assert "OPENKB_API_TOKEN" in resp.json()["detail"] + resp = client.get("/api/v1/watch/events", params={"kb": kb}) + assert resp.status_code == 500 + + +def test_watch_start_status_stop_lifecycle(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _mock_add(monkeypatch) + + started = client.post("/api/v1/watch/start", json={"kb": kb}, headers=_auth()) + assert started.status_code == 200, started.text + body = started.json() + assert body["active"] is True + assert body["kb"] == kb + assert body["raw_dir"].endswith("raw") + assert body["debounce"] == 2.0 + + status = client.post("/api/v1/watch/status", json={"kb": kb}, headers=_auth()) + assert status.status_code == 200 + assert status.json()["active"] is True + + stopped = client.post("/api/v1/watch/stop", json={"kb": kb}, headers=_auth()) + assert stopped.status_code == 200 + stop_body = stopped.json() + assert stop_body["kb"] == kb + assert stop_body["active"] is False + + status2 = client.post("/api/v1/watch/status", json={"kb": kb}, headers=_auth()) + assert status2.status_code == 200 + assert status2.json()["active"] is False + + +def test_watch_start_is_idempotent(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _mock_add(monkeypatch) + + first = client.post("/api/v1/watch/start", json={"kb": kb}, headers=_auth()) + second = client.post("/api/v1/watch/start", json={"kb": kb}, headers=_auth()) + assert first.status_code == 200 and second.status_code == 200 + assert first.json() == second.json() + client.post("/api/v1/watch/stop", json={"kb": kb}, headers=_auth()) + + +def test_watch_stop_unknown_returns_404(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + resp = client.post("/api/v1/watch/stop", json={"kb": kb}, headers=_auth()) + assert resp.status_code == 404 + assert "No active watcher" in resp.json()["detail"] + + +def test_watch_start_rejects_invalid_debounce(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + resp = client.post("/api/v1/watch/start", json={"kb": kb, "debounce": 0}, headers=_auth()) + assert resp.status_code == 422 + + +def _wait_for_added(client, kb: str, timeout: float = 6.0) -> bool: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + st = client.post("/api/v1/watch/status", json={"kb": kb}, headers=_auth()) + if st.json().get("counters", {}).get("added", 0) >= 1: + return True + time.sleep(0.1) + return False + + +def test_watch_events_streams_file_done_after_drop(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + _mock_add(monkeypatch) + + started = client.post("/api/v1/watch/start", json={"kb": kb, "debounce": 0.1}, headers=_auth()) + assert started.status_code == 200 + try: + (kb_dir / "raw" / "dropped.md").write_text("# hi", encoding="utf-8") + assert _wait_for_added(client, kb), "file was not processed in time" + + resp = client.get( + "/api/v1/watch/events", + params={"kb": kb, "max_events": 2, "timeout_seconds": 5}, + headers=_auth(), + ) + assert resp.status_code == 200 + assert resp.headers["content-type"].startswith("text/event-stream") + events = _events_from_sse(resp.text) + names = [e["event"] for e in events] + assert names[0] == "start" + assert names[-1] == "done" + assert "file_start" in names and "file_done" in names + finally: + client.post("/api/v1/watch/stop", json={"kb": kb}, headers=_auth()) + + +def test_watch_events_inactive_returns_error(monkeypatch, kb_dir): + client = _client(monkeypatch) + kb = _use_named_kb(monkeypatch, kb_dir) + + resp = client.get("/api/v1/watch/events", params={"kb": kb, "max_events": 1}, headers=_auth()) + events = _events_from_sse(resp.text) + assert events[0]["event"] == "start" + assert events[0]["data"]["active"] is False + assert any(e["event"] == "error" for e in events) + assert events[-1]["event"] == "done" + + +def test_watch_events_disconnect_terminates(monkeypatch, kb_dir): + """The SSE stream must terminate when the client disconnects.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + from openkb.api import _stream_watch_events + from openkb.watch_service import WatchRegistry + + reg = WatchRegistry() + reg.start("t", kb_dir, debounce=0.1) + try: + fake_request = MagicMock() + fake_request.is_disconnected = AsyncMock(return_value=True) + + async def collect(): + events = [] + async for chunk in _stream_watch_events(reg, "t", None, None, fake_request): + events.append(chunk) + return events + + result = asyncio.run(collect()) + # With is_disconnected=True, the stream yields start then immediately + # returns (done is NOT emitted because we return before the final yield, + # but the generator is exhausted so collect() finishes). + assert len(result) >= 1 # at least the start event + reg.stop("t") + finally: + reg.stop("t") + + + +def test_watch_events_default_timeout_terminates(monkeypatch, kb_dir): + """With max_events and timeout_seconds both None, the stream must use the + default _WATCH_SSE_TIMEOUT cap instead of running indefinitely.""" + import asyncio + from unittest.mock import AsyncMock, MagicMock + from openkb.api import _stream_watch_events + from openkb.watch_service import WatchRegistry + + monkeypatch.setattr("openkb.api._WATCH_SSE_TIMEOUT", 0.05) + + reg = WatchRegistry() + reg.start("t", kb_dir, debounce=0.1) + try: + fake_request = MagicMock() + fake_request.is_disconnected = AsyncMock(return_value=False) + + async def collect(): + events = [] + async for chunk in _stream_watch_events(reg, "t", None, None, fake_request): + events.append(chunk) + return events + + result = asyncio.run(collect()) + # Stream must terminate on its own (not hang) and emit done. + joined = "\n".join(result) + assert "event: done" in joined + finally: + reg.stop("t") diff --git a/tests/test_per_request_overrides.py b/tests/test_per_request_overrides.py new file mode 100644 index 00000000..ab45e4c5 --- /dev/null +++ b/tests/test_per_request_overrides.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +import yaml + +from openkb.config import resolve_per_request_overrides, resolve_credential_bundle + + +def _write_config(kb_dir: object, config: dict) -> None: + """Write a config dict to ``.openkb/config.yaml``.""" + openkb_dir = kb_dir / ".openkb" + openkb_dir.mkdir(parents=True, exist_ok=True) + (openkb_dir / "config.yaml").write_text( + yaml.safe_dump(config), encoding="utf-8" + ) + + +def test_litellm_extra_headers_overrides_top_level(): + """litellm.extra_headers must override the top-level extra_headers key.""" + config = { + "extra_headers": {"Editor-Version": "top-level"}, + "litellm": {"extra_headers": {"Editor-Version": "from-litellm"}}, + } + headers, _timeout, _litellm = resolve_per_request_overrides(config) + assert headers == {"Editor-Version": "from-litellm"} + + +def test_litellm_timeout_overrides_top_level(): + """litellm.timeout must override the top-level timeout key.""" + config = { + "timeout": 30, + "litellm": {"timeout": 120}, + } + _headers, timeout, _litellm = resolve_per_request_overrides(config) + assert timeout == 120 + + +def test_popped_keys_absent_from_litellm_settings(): + """litellm.extra_headers / litellm.timeout are popped from litellm_settings.""" + config = { + "litellm": { + "extra_headers": {"X": "1"}, + "timeout": 90, + "drop_params": True, # genuine module-level setting, must survive + }, + } + _h, _t, litellm_settings = resolve_per_request_overrides(config) + assert "extra_headers" not in litellm_settings + assert "timeout" not in litellm_settings + assert litellm_settings == {"drop_params": True} + + +def test_no_litellm_block_preserves_top_level(): + """Without a litellm: block, top-level extra_headers/timeout pass through.""" + config = {"extra_headers": {"Editor-Version": "ok"}, "timeout": 45} + headers, timeout, litellm_settings = resolve_per_request_overrides(config) + assert headers == {"Editor-Version": "ok"} + assert timeout == 45 + assert litellm_settings == {} + + +def test_bundle_reads_litellm_extra_headers(tmp_path): + """Regression: resolve_credential_bundle must honor litellm.extra_headers. + + Previously the bundle only read top-level extra_headers, so KBs that put + auth headers (e.g. Copilot Editor-Version) under litellm: got an empty + bundle on REST endpoints while the CLI worked fine. + """ + _write_config(tmp_path, { + "litellm": { + "extra_headers": {"Editor-Version": "github-copilot/1.0"}, + "timeout": 60, + }, + }) + bundle = resolve_credential_bundle(tmp_path) + assert bundle.extra_headers == {"Editor-Version": "github-copilot/1.0"} + assert bundle.timeout == 60 + + +def test_bundle_has_no_litellm_settings_field(): + """The bundle must not carry litellm_settings (process global, not isolatable).""" + import dataclasses + from openkb.config import LlmCredentialBundle + + field_names = {f.name for f in dataclasses.fields(LlmCredentialBundle)} + assert "litellm_settings" not in field_names diff --git a/tests/test_query.py b/tests/test_query.py index a4d29387..fdb246a8 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -182,3 +182,34 @@ def test_timeout_applied_from_stash(self, tmp_path): def test_no_timeout_by_default(self, tmp_path): agent = build_query_agent(str(tmp_path), "gpt-4o-mini") assert agent.model_settings.extra_args is None + + + +class TestBuildRunConfigFromBundle: + """The per-KB RunConfig must pass the model string to litellm verbatim. + + Regression: build_run_config_from_bundle prefixed the model with + litellm/ (an Agent-layer convention to select the LiteLLM backend), + producing litellm/openai/deepseek-v4-flash -- which litellm.acompletion + rejects with BadRequestError("LLM Provider NOT provided"). LitellmModel + feeds its model straight to litellm.acompletion, so it must be the + raw litellm provider/model string, with no prefix. + """ + + def test_none_bundle_returns_none(self): + """CLI path (bundle=None) returns None so the SDK uses the agent model.""" + from openkb.agent.query import build_run_config_from_bundle + + assert build_run_config_from_bundle("openai/gpt-4o", None) is None + + def test_bundle_model_has_no_litellm_prefix(self): + from openkb.agent.query import build_run_config_from_bundle + from openkb.config import LlmCredentialBundle + + bundle = LlmCredentialBundle(api_key="k", base_url=None) + run_config = build_run_config_from_bundle("openai/deepseek-v4-flash", bundle) + assert run_config is not None + # The model reaches litellm.acompletion as-is; the agent-layer prefix + # must not leak in. + assert run_config.model.model == "openai/deepseek-v4-flash" + assert not run_config.model.model.startswith("litellm/") diff --git a/tests/test_remove.py b/tests/test_remove.py index ac390732..24a982ac 100644 --- a/tests/test_remove.py +++ b/tests/test_remove.py @@ -1276,3 +1276,86 @@ def test_remove_cloud_doc_never_touches_pageindex(tmp_path): assert not (tmp_path / "wiki" / "summaries" / "cloud-doc.md").exists() assert not (tmp_path / "wiki" / "sources" / "cloud-doc.json").exists() assert HashRegistry(openkb_dir / "hashes.json").get("synthhash") is None + +# --------------------------------------------------------------------------- +# run_remove_for_api (REST entry point, shares _build/_execute with the CLI) +# --------------------------------------------------------------------------- + + +def _seed_one_doc_kb(kb_dir: Path) -> None: + (kb_dir / ".openkb" / "hashes.json").write_text(json.dumps({ + "h_a": {"name": "paper.pdf", "doc_name": "paper", "type": "short", + "path": "raw/paper.pdf"}, + })) + (kb_dir / "raw" / "paper.pdf").write_bytes(b"%PDF-paper") + (kb_dir / "wiki" / "summaries" / "paper.md").write_text( + "---\nsources: [raw/paper.pdf]\nbrief: x\n---\n# Paper\n", encoding="utf-8", + ) + (kb_dir / "wiki" / "index.md").write_text( + "# Knowledge Base Index\n\n## Documents\n" + "- [[summaries/paper]] (short) - x\n\n## Concepts\n\n## Explorations\n", + encoding="utf-8", + ) + (kb_dir / "wiki" / "log.md").write_text("# Log\n", encoding="utf-8") + + +def test_run_remove_for_api_dry_run_has_no_side_effects(kb_dir): + _seed_one_doc_kb(kb_dir) + from openkb.cli import run_remove_for_api + + result = run_remove_for_api(kb_dir, "paper.pdf", dry_run=True) + + assert result["status"] == "dry_run" + assert result["name"] == "paper.pdf" + assert any(a["tag"] == "DELETE" for a in result["actions"]) + # Nothing modified. + assert (kb_dir / "wiki" / "summaries" / "paper.md").exists() + assert "h_a" in json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) + + +def test_run_remove_for_api_removes_doc(kb_dir): + _seed_one_doc_kb(kb_dir) + from openkb.cli import run_remove_for_api + + result = run_remove_for_api(kb_dir, "paper.pdf", dry_run=False) + + assert result["status"] == "removed" + assert not (kb_dir / "wiki" / "summaries" / "paper.md").exists() + assert not (kb_dir / "raw" / "paper.pdf").exists() + assert json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) == {} + + +def test_run_remove_for_api_not_found(kb_dir): + _seed_one_doc_kb(kb_dir) + from openkb.cli import run_remove_for_api + + assert run_remove_for_api(kb_dir, "missing")["status"] == "not_found" + + +def test_run_remove_for_api_keep_raw_preserves_file(kb_dir): + _seed_one_doc_kb(kb_dir) + from openkb.cli import run_remove_for_api + + run_remove_for_api(kb_dir, "paper.pdf", keep_raw=True) + assert (kb_dir / "raw" / "paper.pdf").exists() + assert json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) == {} + + +def test_run_remove_for_api_pageindex_failure_is_partial(kb_dir): + """PageIndex cleanup raising must leave the registry entry intact for + retry (status=partial), mirroring the CLI behavior.""" + _seed_long_pdf_kb(kb_dir, doc_id="pi-doc-xyz") + + failing_client = MagicMock() + failing_client.collection.side_effect = RuntimeError("LLM key missing") + + with patch("pageindex.PageIndexClient", return_value=failing_client), \ + patch("openkb.cli._setup_llm_key"): + from openkb.cli import run_remove_for_api + result = run_remove_for_api(kb_dir, "paper.pdf", keep_raw=True) + + assert result["status"] == "partial" + # Registry entry (with doc_id) survives for retry. + hashes = json.loads((kb_dir / ".openkb" / "hashes.json").read_text()) + assert "h_paper" in hashes + assert hashes["h_paper"]["doc_id"] == "pi-doc-xyz" diff --git a/tests/test_save_exploration.py b/tests/test_save_exploration.py new file mode 100644 index 00000000..338bf1df --- /dev/null +++ b/tests/test_save_exploration.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +from openkb.cli import save_exploration + + +def test_save_exploration_cjk_question_gets_hash_slug(tmp_path): + """CJK / punctuation-only questions must not collapse to an empty slug.""" + kb_dir = tmp_path + (kb_dir / "wiki").mkdir() + p = save_exploration(kb_dir, "这是什么问题?", "answer text") + assert p is not None + assert p.name != ".md" + assert p.exists() + + +def test_save_exploration_same_question_uniquifies(tmp_path): + """Two saves of the same question must not overwrite each other.""" + kb_dir = tmp_path + (kb_dir / "wiki").mkdir() + p1 = save_exploration(kb_dir, "What is RAG?", "answer 1") + p2 = save_exploration(kb_dir, "What is RAG?", "answer 2") + assert p1 != p2 + assert p1.read_text(encoding="utf-8").endswith("answer 1\n") + assert p2.read_text(encoding="utf-8").endswith("answer 2\n") + + +def test_save_exploration_quote_in_question_safe_yaml(tmp_path): + """A question containing a double-quote must not break frontmatter.""" + kb_dir = tmp_path + (kb_dir / "wiki").mkdir() + p = save_exploration(kb_dir, 'What does "RAG" mean?', "answer") + content = p.read_text(encoding="utf-8") + import yaml as _yaml + + fm_lines = content.split("---")[1] + parsed = _yaml.safe_load(fm_lines) + assert "query" in parsed + assert "RAG" in parsed["query"] + + +def test_save_exploration_empty_answer_returns_none(tmp_path): + kb_dir = tmp_path + (kb_dir / "wiki").mkdir() + assert save_exploration(kb_dir, "any question", "") is None + + +def test_save_exploration_strips_ghost_wikilinks(tmp_path): + """Ghost wikilinks to non-existent targets should be stripped (matching CLI).""" + kb_dir = tmp_path + (kb_dir / "wiki").mkdir() + p = save_exploration(kb_dir, "What is X?", "see [[nonexistent_page]] for details") + content = p.read_text(encoding="utf-8") + assert "[[nonexistent_page]]" not in content + assert "for details" in content diff --git a/tests/test_watch_service.py b/tests/test_watch_service.py new file mode 100644 index 00000000..4a8ae05a --- /dev/null +++ b/tests/test_watch_service.py @@ -0,0 +1,294 @@ +"""Tests for openkb.watch_service (per-KB watcher registry + worker). + +All ingest is mocked so no real LLM/compilation runs, per AGENTS.md. +Worker behavior is driven by putting batches directly on the queue to avoid +OS-watcher timing flakiness; one end-to-end test exercises the real debounce +pipeline with a small debounce. +""" +from __future__ import annotations + +import threading +import time +from collections import deque +from pathlib import Path + +from openkb.cli import AddFileResult +from openkb.watch_service import ( + WatchRegistry, + WatcherState, + _public_event, + _record_event, +) + + +def _drain_worker(state: WatcherState, timeout: float = 2.0) -> None: + """Run the worker loop on already-queued batches, then stop it.""" + from openkb.watch_service import _run_worker + + state.queue.put(None) + t = threading.Thread(target=_run_worker, args=(state,)) + t.start() + t.join(timeout=timeout) + assert not t.is_alive(), "worker did not drain in time" + + +def _events_of(state: WatcherState, name: str) -> list[dict]: + return [e["data"] for e in state.events if e["event"] == name] + + +def _make_state(kb_dir: Path, kb: str = "test-kb") -> WatcherState: + return WatcherState( + kb=kb, + kb_dir=kb_dir, + raw_dir=kb_dir / "raw", + debounce=0.0, + started_at=time.time(), + ) + + +# registry lifecycle + + +def test_start_is_idempotent(kb_dir, monkeypatch): + monkeypatch.setattr("openkb.watch_service.start_watch", lambda *a, **k: object()) + reg = WatchRegistry() + s1 = reg.start("test-kb", kb_dir, debounce=0.0) + s2 = reg.start("test-kb", kb_dir, debounce=0.0) + assert s1 is s2 + assert reg.list_active() == ["test-kb"] + reg.stop("test-kb") + + +def test_stop_returns_false_when_not_active(): + reg = WatchRegistry() + assert reg.stop("nope") is False + + +def test_status_inactive_returns_active_false(): + reg = WatchRegistry() + assert reg.status("missing") == {"kb": "missing", "active": False} + + +def test_status_active_fields_and_recent_events(kb_dir, monkeypatch): + monkeypatch.setattr("openkb.watch_service.start_watch", lambda *a, **k: object()) + reg = WatchRegistry() + state = reg.start("test-kb", kb_dir, debounce=0.0) + _record_event(state, "file_done", {"original_name": "a.md", "status": "added"}) + st = reg.status("test-kb") + assert st["active"] is True + assert st["kb"] == "test-kb" + assert st["raw_dir"] == str(kb_dir / "raw") + assert st["counters"] == {"added": 0, "skipped": 0, "failed": 0} + assert len(st["recent_events"]) == 1 + assert st["recent_events"][0]["event"] == "file_done" + assert "seq" not in st["recent_events"][0] + reg.stop("test-kb") + + +def test_stop_all_clears_registry(kb_dir, monkeypatch): + monkeypatch.setattr("openkb.watch_service.start_watch", lambda *a, **k: object()) + reg = WatchRegistry() + reg.start("a", kb_dir, debounce=0.0) + reg.start("b", kb_dir, debounce=0.0) + reg.stop_all() + assert reg.list_active() == [] + + +# worker file processing + + +def test_worker_added_records_file_start_done_and_counter(kb_dir, monkeypatch): + state = _make_state(kb_dir) + state.queue.put([str(kb_dir / "raw" / "paper.md")]) + monkeypatch.setattr( + "openkb.watch_service._add_for_api", + lambda path, kb: AddFileResult(path.name, str(path), "added", "Added."), + ) + _drain_worker(state) + assert _events_of(state, "file_start")[0]["original_name"] == "paper.md" + done = _events_of(state, "file_done")[0] + assert done["status"] == "added" + assert done["message"] == "Added." + assert state.counters == {"added": 1, "skipped": 0, "failed": 0} + + +def test_worker_skipped_and_failed_branches(kb_dir, monkeypatch): + state = _make_state(kb_dir) + state.queue.put([str(kb_dir / "raw" / "dup.md"), str(kb_dir / "raw" / "boom.md")]) + + def fake_add(path, target_kb): + if path.name == "dup.md": + return AddFileResult(path.name, None, "skipped", "Already in KB.") + raise RuntimeError("explode") + + monkeypatch.setattr("openkb.watch_service._add_for_api", fake_add) + _drain_worker(state) + dones = _events_of(state, "file_done") + assert [d["status"] for d in dones] == ["skipped"] + assert dones[0]["message"] == "Already in KB." + errs = _events_of(state, "error") + assert len(errs) == 1 + assert "explode" in errs[0]["message"] + assert state.counters == {"added": 0, "skipped": 1, "failed": 1} + + +def test_worker_unsupported_suffix_is_skipped_without_ingest(kb_dir, monkeypatch): + state = _make_state(kb_dir) + state.queue.put([str(kb_dir / "raw" / "image.xyz")]) + called = [] + monkeypatch.setattr( + "openkb.watch_service._add_for_api", + lambda path, kb: called.append(path) or AddFileResult("x", None, "added", "x"), + ) + _drain_worker(state) + assert called == [] + done = _events_of(state, "file_done")[0] + assert done["status"] == "skipped" + assert "unsupported" in done["message"].lower() + assert state.counters == {"added": 0, "skipped": 1, "failed": 0} + + +def test_worker_does_not_die_on_exception(kb_dir, monkeypatch): + state = _make_state(kb_dir) + state.queue.put([str(kb_dir / "raw" / "bad.md"), str(kb_dir / "raw" / "good.md")]) + + def fake_add(path, target_kb): + if path.name == "bad.md": + raise RuntimeError("nope") + return AddFileResult(path.name, str(path), "added", "ok") + + monkeypatch.setattr("openkb.watch_service._add_for_api", fake_add) + _drain_worker(state) + assert state.counters == {"added": 1, "skipped": 0, "failed": 1} + + +def test_ring_buffer_keeps_most_recent_ordered(): + buf = deque(maxlen=3) + for i in range(5): + buf.append({"seq": i, "ts": float(i), "event": "file_done", "data": {"i": i}}) + public = [_public_event(e) for e in buf] + assert [p["data"]["i"] for p in public] == [2, 3, 4] + assert public[0]["ts"] < public[-1]["ts"] + + +def test_end_to_end_debounce_processes_real_file(kb_dir, monkeypatch): + seen = [] + monkeypatch.setattr( + "openkb.watch_service._add_for_api", + lambda path, kb: seen.append(path.name) + or AddFileResult(path.name, str(path), "added", "ok"), + ) + reg = WatchRegistry() + reg.start("test-kb", kb_dir, debounce=0.1) + try: + (kb_dir / "raw" / "dropped.md").write_text("# hi", encoding="utf-8") + for _ in range(50): + if reg.status("test-kb")["counters"]["added"] == 1: + break + time.sleep(0.1) + assert seen == ["dropped.md"] + names = [e["event"] for e in reg.status("test-kb")["recent_events"]] + assert "file_start" in names and "file_done" in names + finally: + reg.stop("test-kb") + +def test_record_event_appends_inside_lock(): + """_record_event must append inside the lock so watcher_stopped ordering is preserved.""" + from openkb.watch_service import WatcherState, _record_event + state = WatcherState(kb='t', kb_dir=None, raw_dir=None, debounce=0, started_at=0.0) + _record_event(state, "file_done", {"path": "a.md"}) + _record_event(state, "watcher_stopped", {"kb": "t"}) + events = list(state.events) + assert events[-1]["event"] == "watcher_stopped" + seqs = [e["seq"] for e in events] + assert seqs == sorted(seqs) + + +def test_stop_then_start_does_not_double_worker(kb_dir, monkeypatch): + """After stop(), start() must not spawn a second worker.""" + monkeypatch.setattr('openkb.watch_service._add_for_api', lambda path, kb_dir: AddFileResult('x', str(path), 'added', 'ok')) + reg = WatchRegistry() + reg.start("t", kb_dir, debounce=0.1) + reg.stop("t") + state2 = reg.start("t", kb_dir, debounce=0.1) + assert state2.worker_thread is not None + assert state2.worker_thread.is_alive() + assert len(reg.list_active()) == 1 + reg.stop("t") + + +def test_start_returns_existing_if_running(kb_dir, monkeypatch): + """start() is idempotent.""" + monkeypatch.setattr('openkb.watch_service._add_for_api', lambda path, kb_dir: AddFileResult('x', str(path), 'added', 'ok')) + reg = WatchRegistry() + s1 = reg.start("t", kb_dir, debounce=0.1) + s2 = reg.start("t", kb_dir, debounce=0.1) + assert s1 is s2 + reg.stop("t") + + +def test_worker_exception_clears_running(kb_dir, monkeypatch): + """When the worker exits (via stop sentinel), running must be cleared.""" + + monkeypatch.setattr( + "openkb.watch_service._add_for_api", + lambda path, kb_dir: AddFileResult("x", str(path), "added", "ok"), + ) + reg = WatchRegistry() + state = reg.start("t", kb_dir, debounce=0.1) + assert state.running.is_set() + # stop() joins the worker; after it returns, running should be cleared. + reg.stop("t") + assert not state.running.is_set() + + +def test_record_event_seq_monotonic_under_burst(): + """watcher_stopped terminator must have a higher seq than preceding events.""" + from collections import deque + from openkb.watch_service import WatcherState, _record_event + + state = WatcherState(kb="t", kb_dir=None, raw_dir=None, debounce=0, started_at=0.0, events=None) + state.events = deque(maxlen=3) + for i in range(5): + _record_event(state, "file_done", {"i": i}) + _record_event(state, "watcher_stopped", {"kb": "t"}) + events = list(state.events) + seqs = [e["seq"] for e in events] + assert seqs == sorted(seqs) + assert events[-1]["event"] == "watcher_stopped" + + +def test_stop_draining_when_worker_stuck(kb_dir, monkeypatch): + """When the worker does not exit within the join timeout, stop() must leave + a draining marker and keep the state registered so start() refuses to + spawn a duplicate worker.""" + import threading as _t + + block = _t.Event() + + def slow_process(state, raw_path): + block.wait(timeout=10) + + monkeypatch.setattr("openkb.watch_service._process_file", slow_process) + + reg = WatchRegistry() + state = reg.start("t", kb_dir, debounce=0.1) + # Put a batch so the worker enters _process_file and blocks. + state.queue.put(["fake.md"]) + time.sleep(0.3) # let the worker pick it up and block + + # Replace join with a no-op so the test does not wait 5 s. + real_join = state.worker_thread.join + state.worker_thread.join = lambda timeout=None: None + + result = reg.stop("t") + assert result is True + # State must still be registered (not popped) and running still set. + assert reg.get("t") is state + assert state.running.is_set() + events = list(state.events) + assert any(e["event"] == "watcher_draining" for e in events) + + # Cleanup: release the worker so it can exit. + block.set() + real_join(timeout=5)