Skip to content

feat: add REST API server and Knowledge Workbench web UI#150

Open
jidechao wants to merge 2 commits into
VectifyAI:mainfrom
jidechao:feat/rest-api-and-workbench
Open

feat: add REST API server and Knowledge Workbench web UI#150
jidechao wants to merge 2 commits into
VectifyAI:mainfrom
jidechao:feat/rest-api-and-workbench

Conversation

@jidechao

Copy link
Copy Markdown

Summary

Adds a production-ready REST API (FastAPI) and a bundled Knowledge Workbench web UI to OpenKB, so a knowledge base can be built, queried, and maintained entirely from the browser, Postman, or any HTTP client — no CLI required.

The two are co-served from one origin: openkb-api mounts the built SPA at / while exposing the JSON/SSE API under /api/v1.

REST API (openkb/api.py, openkb/watch_service.py)

  • GET /kbs + POST /init — list and create knowledge bases (resolvable by short name via OPENKB_KB_ROOT)
  • POST /add (multipart, SSE) — upload + compile documents with per-file progress
  • POST /query (SSE) — one-shot question, streamed answer
  • POST /chat + POST /chat/sessions{,/load,/delete} (SSE) — multi-turn chat with persisted, resumable sessions
  • POST /list, /status — inventory and index stats
  • POST /lint (optional fix), POST /remove (SSE), POST /recompile (SSE)
  • POST /watch/{start,stop,status} + GET /watch/events (SSE) — background file watcher
  • Bearer-token auth; a shared iter_agent_response_events layer means the CLI and API emit identical event streams
  • A Postman collection is included at openkb-postman.json

Knowledge Workbench (frontend/ → built web/)

A React + Vite single-page app (dark, three-pane):

  • Overview — stat cards (indexed/concepts/summaries/reports), clickable concept chips, recent docs, activity
  • Documents — drag-and-drop multi-file upload with per-file SSE progress, hash table, delete with confirmation
  • Query / Chat — streamed answers, live retrieval & reasoning inspector timeline, GFM Markdown rendering (react-markdown + remark-gfm + rehype-highlight), multi-turn sessions with history/resume/delete
  • Maintenance — lint (optional auto-fix), recompile (all/single doc), file-watcher toggle
  • Responsive: panes collapse to a single column on narrow screens

initialize_kb inherits the operator's project-root config.yaml and LLM credentials (.env, with OPENKB_* server vars filtered out), so a KB created from the UI runs out of the box.

Build / packaging

  • web/ holds the pre-built bundle and is mounted by the API; frontend/ source is included and node_modules/dist are gitignored
  • New entry point: openkb-api (also python -m openkb.api)

Test plan

  • pytest tests/test_api.py tests/test_api_watch.py tests/test_watch_service.py tests/test_remove.py — 126 passed
  • npm run build produces web/index.html + web/assets/*
  • Manual: KB switch, upload → SSE progress → table update, streamed query with tool_call timeline, chat create/resume/delete, lint/recompile/watch

Notes

  • This is a new capability layered on top of existing wiki/agent internals; core compile/query agents are unchanged in behavior — a shared SSE event helper was factored out so CLI and REST emit the same stream.
  • Happy to split this into separate PRs (API vs. Web UI) if that's easier to review, or adjust to fit the project's conventions.

Add a production-ready REST API (FastAPI) and a bundled React single-page
app ("Knowledge Workbench") served at the same origin, so OpenKB can be
driven from the browser, Postman, or other HTTP clients without the CLI.

REST API (openkb/api.py):
- Endpoints under /api/v1: /kbs, /init, /add, /query, /chat,
  /chat/sessions{,/load,/delete}, /list, /status, /lint, /remove,
  /recompile, /watch/{start,stop,status}, /watch/events (SSE)
- Bearer-token auth, SSE streaming for query/chat/add/remove/recompile,
  multipart upload, and KB name resolution via OPENKB_KB_ROOT
- Shared SSE event layer (iter_agent_response_events) reused by query/chat
  so the CLI and API emit identical event streams
- Background file-watcher service (openkb/watch_service.py) for auto-compile

Knowledge Workbench (frontend/, built to web/):
- React + Vite dark three-pane workbench: Overview, Documents, Query,
  Chat, Maintenance, with a live retrieval/reasoning inspector timeline
- Streamed answers, multi-turn chat with persisted sessions, drag-and-drop
  upload with per-file progress, lint/recompile/watch controls
- Markdown via react-markdown + remark-gfm + rehype-highlight

Also: initialize_kb inherits project-root config.yaml and .env (filtering
OPENKB_* server vars) so a KB created from the UI runs out of the box;
POSTMAN collection; README docs.
@KylinMountain

Copy link
Copy Markdown
Collaborator

@jidechao
Thanks, this is a big and useful addition! A few things before I review in depth:

  1. Could you add a few screenshots / a short GIF of the Workbench? Hard to review the UI from the bundled JS alone.

  2. Some changes look unintentional — please revert:

    • config.yaml.example: defaults were flipped to model: openai/deepseek-v4-flash / language: zh (looks like a local dev value). Please restore gpt-5.4 / en.
    • openkb/cli.py: a UTF-8 BOM got added to line 1 — please strip it.
  3. Same question for the committed web/ build output — better to ship frontend/ source + a build step than to vendor the bundle.

I'll follow up with implementation feedback after.

…p BOM, unvendor web/

- config.yaml.example: restore gpt-5.4 / en (unintentional flip to local
  dev values openai/deepseek-v4-flash / zh)
- openkb/cli.py, tests/test_api.py: remove stray UTF-8 BOM
- web/: stop vendoring the built bundle; add web/ to .gitignore; README
  notes the Workbench UI requires `npm run build` (API-only otherwise)

Refs: PR VectifyAI#150
@jidechao

Copy link
Copy Markdown
Author

@jidechao Thanks, this is a big and useful addition! A few things before I review in depth:

  1. Could you add a few screenshots / a short GIF of the Workbench? Hard to review the UI from the bundled JS alone.

  2. Some changes look unintentional — please revert:

    • config.yaml.example: defaults were flipped to model: openai/deepseek-v4-flash / language: zh (looks like a local dev value). Please restore gpt-5.4 / en.
    • openkb/cli.py: a UTF-8 BOM got added to line 1 — please strip it.
  3. Same question for the committed web/ build output — better to ship frontend/ source + a build step than to vendor the bundle.

I'll follow up with implementation feedback after.

Overview Documents Query chat Maintenance

@jidechao

Copy link
Copy Markdown
Author

@KylinMountain Thanks for the review. All three actionable points are addressed in the latest push (3d3e086):

  1. Screenshots / GIF — pending. I'll capture and attach them once I rebuild and run the Workbench locally (Overview / Documents / Query·Chat / Maintenance). Will follow up in this thread.

  2. Reverted unintentional changes:

    • config.yaml.example: restored model: gpt-5.4 / language: en (the openai/deepseek-v4-flash / zh flip was a local dev value). git diff against base is now clean.
    • BOM: stripped the stray UTF-8 BOM from openkb/cli.py line 1. While auditing, found the same BOM in tests/test_api.py (same batch of accidental edits) and removed it too.
  3. Unvendored web/: deleted the committed build bundle, added web/ to .gitignore, and documented the build step in the README quick-start. frontend/ source stays tracked; npm run build regenerates web/. The API's _mount_web_ui already no-ops when web/ is absent, so openkb-api falls back to API-only with no breakage.

Verification: pytest tests/test_api.py tests/test_api_watch.py tests/test_watch_service.py tests/test_remove.py → 126 passed; npm run build regenerates web/ cleanly.

Standing by for the implementation-layer feedback.

@KylinMountain KylinMountain left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review — REST API + Knowledge Workbench

Nice, largely-additive feature — CLI behavior is unchanged and the shared SSE helper is only consumed by the new API paths. The high-severity issues cluster in three areas, flagged inline.

Must-fix (correctness / data):

  • Mutation endpoints (recompile / lint --fix / init) run sync, lock-holding work directly on the event loop; remove/add already use run_in_threadpool, these don't. Because kb_ingest_lock's reentrancy bookkeeping is threading.local, concurrent same-KB requests on the one event-loop thread bypass mutual exclusion → KB corruption (different-KB requests instead stall the loop). [api.py:321/541/581]
  • Watcher stop() can orphan an in-flight compile and a restart then double-ingests raw/. [watch_service.py:204]
  • _save_query_answer: empty/colliding exploration slug silently loses data (acute for CJK questions), drops the CLI's ghost-link stripping, and writes the question into YAML unescaped. [api.py:801]

Should-fix: leaked/never-terminating /watch/events SSE [api.py:1145]; frontend doesn't abort the stream on unmount/KB-switch [useSSEStream.js]; CORS * + credentials [api.py:699]; token sent to a user-supplied origin + non-constant-time compare [sse.js:50, api.py:772]; per-KB LLM key ignored due to override=False [api.py:321].

Verified clean (so you don't re-chase): KB short-names go through validate_kb_name (fullmatch on [A-Za-z0-9_-]+) → no path traversal; upload filenames via Path(...).name; the .env inheritance filter correctly excludes OPENKB_* server vars while keeping LLM_API_KEY; _sse always emits single-line data:; CLI query/chat streaming is unchanged by the refactor.

Minor / cleanup: duplicated KB-dir check (api.py:730 & 787 → extract _is_kb_dir); redundant _setup_llm_key wrapper; dead model.dict() Pydantic-v1 branch.

Thanks for the thorough test plan and the offer to split the PR — splitting API vs Web UI would make these land more incrementally.

Comment thread openkb/api.py
return RemoveResponse(**result)

@app.post("/api/v1/recompile", response_model=RecompileResponse)
async def recompile_endpoint(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Concurrent KB mutation can corrupt the KB — run this in a threadpool. recompile_endpoint / _stream_recompile drive iter_recompile directly on the event loop, which acquires kb_ingest_lock (openkb/locks.py). That lock tracks reentrancy in threading.local (locks.py:45,82-128), but asyncio runs multiple requests on the same event-loop thread — so a second concurrent same-KB recompile/lint/init is mis-counted as a re-entrant hold and skips locking entirely → two compiles mutate hashes.json/wiki pages at once. For different KBs, the blocking flock/compile instead stalls the whole event loop. remove_endpoint (L559) and the add path already use run_in_threadpool — do the same here. (Same root cause at lint_endpoint L541 and init_endpoint L321.)

Comment thread openkb/api.py
) -> LintResponse:
kb_dir = _resolve_kb(request.kb)
try:
return LintResponse(**await run_lint_report(kb_dir, fix=request.fix))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same event-loop hazard as the recompile comment (L581): with fix=True, run_lint_report runs fix_broken_links synchronously under kb_ingest_lock before any await, blocking the loop and sharing the threading.local reentrancy state. Wrap in run_in_threadpool like remove/add.

Comment thread openkb/api.py
return KbListResponse(**_list_knowledge_bases())

@app.post("/api/v1/init", response_model=InitResponse)
async def init_endpoint(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two issues here:

  1. initialize_kb + register_kb_alias (a global-config file lock) run on the event loop — wrap in run_in_threadpool (see L581).
  2. The per-KB LLM_API_KEY this writes is later loaded by _setup_llm_key with override=False, but openkb-api already has LLM_API_KEY in os.environ from the server's root .env — so queries against a KB created here silently use the server's key, not the KB's. Breaks multi-key / multi-tenant setups.

Comment thread openkb/watch_service.py
with self._lock:
return list(self._watchers.keys())

def stop(self, kb: str) -> bool:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

stop() can orphan an in-flight worker, and a restart then double-ingests. stop() pops the state (L207) then worker_thread.join(timeout=5.0) (L220). A compile under kb_ingest_lock can take minutes, so the join times out and stop() returns True / active:false while the worker keeps running, detached. A later start() finds no state (it was popped) and spawns a second observer+worker → two threads ingest the same raw/. Suggest: join before popping; surface a 'draining' state instead of silently timing out; and have start() refuse/await while a prior worker for the KB is still alive (it currently keys idempotency on running.is_set(), which also stays set if the observer/worker dies).

Comment thread openkb/api.py
setup(kb_dir)


def _save_query_answer(kb_dir: Path, question: str, answer: str) -> Path | None:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three problems in this save path, and it diverges from the CLI's query --save:

  1. Empty / colliding slug → silent data loss. slug = re.sub(r'[^a-z0-9]+','-', q.lower()).strip('-')[:60] collapses to '' for any CJK / punctuation-only question → writes wiki/explorations/.md, and every such query clobbers that one hidden file. Two questions sharing a 60-char prefix also overwrite each other. Add a fallback slug + uniquify (the lint-report path already uniquifies).
  2. Drops ghost-wikilink stripping the CLI applies (cli.py:971-988 runs strip_ghost_wikilinks), so API-saved explorations keep dangling [[links]].
  3. Unescaped question in YAMLquery: "{question}" produces invalid frontmatter if the question contains ".
    Recommend extracting a shared save_exploration(kb_dir, question, answer) used by both CLI and API.

Comment thread openkb/api.py
yield _sse("done", {})


async def _stream_watch_events(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaked / never-terminating SSE. _stream_watch_events never checks await request.is_disconnected(), and max_events/timeout_seconds default to None (unbounded) — a client that opens /watch/events then navigates away leaves a generator polling every ~0.5s forever, holding the WatcherState (prevents GC even after the watcher is stopped/popped). Separately, in watch_service._record_event the seq is assigned under the lock but events.append happens outside it, so watcher_stopped can be reordered / evicted from the deque(maxlen) and the stream never sees its terminator. Add a disconnect check + a default cap, and move the append inside the lock.

}, []);

const start = useCallback(async (cfg, onEvent, onAbort) => {
if (ctrlRef.current) return; // already streaming

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stream isn't aborted on unmount / KB or session switch. Views are swapped by conditional render, so leaving Chat/Query unmounts the component mid-stream, but there's no cleanup useEffect calling stop() — the fetch keeps running and setMsgs/inspector callbacks fire after unmount, writing deltas for the old KB/session (kb is captured in the start() closure). Two related issues: if (ctrlRef.current) return (L21) silently drops a second call, leaving a permanent pending bubble; and aiIdx = msgs.length captured at send time points at the wrong row after loadHistory() replaces msgs. Add an unmount + kb-change effect that calls stop(), and key the target message by id rather than index.

Comment thread openkb/api.py
]
app.add_middleware(
CORSMiddleware,
allow_origins=origins,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

allow_origins=['*'] together with allow_credentials=True. When OPENKB_CORS_ORIGINS=* (a natural 'allow my frontend' choice), any site the operator visits can issue credentialed cross-origin requests to the (often localhost-bound) server and drive /remove, /init, etc. A wildcard origin must be incompatible with credentials — reject * when credentials are enabled, or require an explicit origin allowlist.

Comment thread openkb/api.py
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Bearer token required.",
)
if credentials.credentials != expected:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use a constant-time comparison: hmac.compare_digest(credentials.credentials, expected) (or secrets.compare_digest). Plain != short-circuits and is timing-attackable once the server is bound beyond localhost. (Good that a missing OPENKB_API_TOKEN is rejected rather than treated as open.)

Comment thread frontend/src/api/sse.js
return {
...(body && !isForm ? { "Content-Type": "application/json" } : {}),
Accept: "text/event-stream",
...(token ? { Authorization: `Bearer ${token}` } : {}),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The bearer token is attached to baseUrl() + path where baseUrl() is whatever the user typed in Settings — no origin / HTTPS check — and it's stored in localStorage (XSS-readable). A user who pastes a malicious or typo'd API base sends their token to an attacker-controlled host on the first request. Suggest validating the base origin and warning on non-HTTPS cross-origin. (Same header build in client.js:39.)

@KylinMountain

Copy link
Copy Markdown
Collaborator

Also, I suggest setting the default webpage language to English, but with a language switcher available—that might work better. @jidechao

@KylinMountain

Copy link
Copy Markdown
Collaborator

Follow-up: structure & docs organization

A few organizational notes beyond the inline correctness comments — all about keeping the surface lean and single-sourced.

1. api.py re-implements logic the CLI already owns

The API redoes core operations instead of sharing one source of truth, so the two paths will drift (some already have):

  • _save_query_answer (api.py:801) diverges from query --save — it drops the ghost-wikilink stripping the CLI applies, and its slug collapses to empty for CJK / punctuation-only questions (flagged inline). Extract a shared save_exploration(kb_dir, question, answer) used by both CLI and API.
  • KB-dir check is inlined twice (api.py:730 and :787) — extract _is_kb_dir(path).
  • _setup_llm_key (api.py:795) is just a thin wrapper over the CLI's.

Net: let the API be a thin layer over the same core helpers the CLI calls, so behavior can't fork.

(watcher.py + watch_service.py are fine as-is — low-level debounced watch vs. per-KB service registry is a reasonable split.)

2. openkb-postman.json (844 lines) duplicates the OpenAPI schema

FastAPI already serves an interactive schema at /docs and /openapi.json. A hand-maintained Postman collection is a second API surface that will drift from the routes. Suggest dropping it and pointing users at /docs (the OpenAPI URL can be imported into Postman directly if someone wants a collection).

3. README is carrying a full API reference (+463 lines)

The bulk of the README addition is (a) the Workbench setup + per-feature tour and (b) a full REST endpoint reference (Auth, SSE, per-endpoint HTTP request/response). That inlines an API manual into the top-level README, which pulls against the recent direction of keeping the README to "what features exist + Quick Start + command tables" and pushing deep usage into examples/.

Suggested shape:

  • README: a short (~3–5 line) subsection each for the Web UI and the REST API — one-line positioning, one start command, and a link.
  • Move the endpoint reference + Workbench tour into examples/rest-api/README.md, matching the existing examples/<case>/ convention (examples/slides/, examples/visualize/, …). Note that docs/ is gitignored, so the published docs in this repo live under examples/ — that's the natural home for the API reference, not docs/.

Happy to help wire up examples/rest-api/ if that's useful.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants