Skip to content

Commit d7e82b8

Browse files
fix(rest-api): address all PR #150 review feedback (#150)
Must-fix (correctness/data): - Serialize same-KB mutations with an asyncio.Lock before kb_ingest_lock. The ingest lock tracks reentrancy in threading.local, but the event loop runs every request on one thread, so concurrent same-KB requests were mis-counted as re-entrant and bypassed mutual exclusion. [init/lint --fix/recompile] - Watcher stop() now joins the worker before clearing state and leaves a draining marker, so a restart can no longer orphan an in-flight compile and double-ingest raw/. [watch_service.py] - Extract a shared save_exploration() used by both CLI and REST: strips ghost wikilinks, generates a CJK-safe slug (hash fallback), uniquifies on collision, and escapes the question for YAML frontmatter. [cli.py] Should-fix: - Cap /watch/events SSE with a default timeout and an is_disconnected check. - Abort the in-flight SSE on component unmount / KB switch (AbortController). - Force allow_credentials off when CORS origins is a wildcard. - Constant-time token compare (hmac.compare_digest); the SPA warns when the configured API base is cross-origin and non-HTTPS. - Isolate per-KB LLM credentials via LlmCredentialBundle (dotenv_values, no os.environ pollution; a per-request LitellmModel instance). Regression (introduced by the per-KB bundle isolation above): - build_run_config_from_bundle passed model=f"litellm/{model}" to LitellmModel, but LitellmModel feeds model verbatim to litellm.acompletion, so the prefix produced "litellm/openai/deepseek-v4-flash" and litellm rejected it as an unknown provider. CLI was unaffected (bundle=None); all REST query/chat failed. Refactor/cleanup: - Extract _is_kb_dir; move _setup_llm_key to cli; model_dump() (pydantic v2). - Drop the hand-maintained openkb-postman.json (OpenAPI is served at /docs). - Slim README to short subsections + links; move the REST reference and Workbench tour to examples/rest-api/README.md. Frontend: - Default the UI to English with a zh/en toggle (i18n.jsx). - index.html lang/title default to English. Tests: 118 passed (query/api/api_watch/watch_service/per_request_overrides/save_exploration).
1 parent 3d3e086 commit d7e82b8

34 files changed

Lines changed: 2000 additions & 1626 deletions

README.md

Lines changed: 5 additions & 408 deletions
Large diffs are not rendered by default.

examples/rest-api/README.md

Lines changed: 419 additions & 0 deletions
Large diffs are not rendered by default.

frontend/index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<!doctype html>
2-
<html lang="zh-CN">
2+
<html lang="en">
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>OpenKB · 知识工作台</title>
6+
<title>OpenKB · Knowledge Workbench</title>
77
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css" />
88
</head>
99
<body>
1010
<div id="root"></div>
1111
<script type="module" src="/src/main.jsx"></script>
1212
</body>
13-
</html>
13+
</html>

frontend/src/App.jsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useEffect, useCallback } from "react";
22
import { Menu } from "lucide-react";
33
import { AppProvider, useApp } from "./state/AppContext.jsx";
4+
import { I18nProvider, useI18n } from "./i18n.jsx";
45
import { api, hasConnection } from "./api/client.js";
56
import Sidebar from "./components/Sidebar.jsx";
67
import Inspector from "./components/Inspector.jsx";
@@ -12,10 +13,9 @@ import Query from "./views/Query.jsx";
1213
import Chat from "./views/Chat.jsx";
1314
import Maintenance from "./views/Maintenance.jsx";
1415

15-
const TITLES = { overview: "概览", documents: "文档管理", query: "查询", chat: "对话", maintenance: "维护" };
16-
1716
function Shell() {
1817
const { kb, view, setKbs, setKb, kbs, setSidebarOpen, setSettingsOpen, toastMsg } = useApp();
18+
const { t, lang } = useI18n();
1919

2020
const loadKbs = useCallback(async () => {
2121
if (!hasConnection()) return;
@@ -43,8 +43,13 @@ function Shell() {
4343
};
4444
}, [loadKbs, toastMsg, setSettingsOpen]);
4545

46+
// Keep the browser tab title in sync with the active language.
47+
useEffect(() => {
48+
document.title = t("appTitle");
49+
}, [lang, t]);
50+
4651
function renderView() {
47-
if (!kb) return <div className="empty-state"><h3>未选择知识库</h3><p>请在左侧选择或新建一个知识库。</p></div>;
52+
if (!kb) return <div className="empty-state"><h3>{t("noKbSelected")}</h3><p>{t("selectOrCreate")}</p></div>;
4853
if (view === "overview") return <Overview kb={kb} />;
4954
if (view === "documents") return <Documents kb={kb} />;
5055
if (view === "query") return <Query kb={kb} />;
@@ -55,13 +60,13 @@ function Shell() {
5560

5661
return (
5762
<div className="app">
58-
<button className="mobile-menu-btn" onClick={() => setSidebarOpen(true)} aria-label="菜单">
63+
<button className="mobile-menu-btn" onClick={() => setSidebarOpen(true)} aria-label={t("menu")}>
5964
<Menu size={20} />
6065
</button>
6166
<Sidebar />
6267
<main className="workspace">
6368
<header className="ws-header">
64-
<h1 className="ws-title">{TITLES[view]}</h1>
69+
<h1 className="ws-title">{t(view)}</h1>
6570
</header>
6671
<section className="ws-body">{renderView()}</section>
6772
</main>
@@ -74,8 +79,10 @@ function Shell() {
7479

7580
export default function App() {
7681
return (
77-
<AppProvider>
78-
<Shell />
79-
</AppProvider>
82+
<I18nProvider>
83+
<AppProvider>
84+
<Shell />
85+
</AppProvider>
86+
</I18nProvider>
8087
);
8188
}

frontend/src/api/client.js

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,42 @@ export function baseUrl() {
2727
return getApiBase().replace(/\/$/, "");
2828
}
2929

30+
// Warn when the configured base is cross-origin and not HTTPS. The bearer
31+
// token is attached to every request, so a typo'd or malicious base would
32+
// leak the token to an attacker-controlled host. This is advisory only —
33+
// same-origin (empty base, the production mount at /) is always safe.
34+
let _baseWarned = false;
35+
export function isBaseUrlSafe() {
36+
const base = baseUrl();
37+
if (!base) return true; // same-origin, always safe
38+
try {
39+
const url = new URL(base, window.location.origin);
40+
if (url.origin === window.location.origin) return true;
41+
return url.protocol === "https:";
42+
} catch {
43+
return false;
44+
}
45+
}
46+
47+
export function checkBaseSafety() {
48+
if (_baseWarned) return;
49+
const base = baseUrl();
50+
if (!base) return; // same-origin
51+
try {
52+
const url = new URL(base, window.location.origin);
53+
if (url.origin !== window.location.origin && url.protocol !== "https:") {
54+
console.warn(
55+
`[OpenKB] API base "${base}" is cross-origin and non-HTTPS. ` +
56+
"Your API token will be sent to this host on every request. " +
57+
"Verify this URL is trusted before proceeding.",
58+
);
59+
_baseWarned = true;
60+
}
61+
} catch {
62+
// invalid URL — will fail at fetch time
63+
}
64+
}
65+
3066
// Surface 401s to the UI so the connection modal can reopen for re-entry.
3167
export function notifyUnauthorized() {
3268
window.dispatchEvent(new CustomEvent("openkb:unauthorized"));
@@ -41,6 +77,7 @@ export async function request(path, { method = "GET", body, headers } = {}) {
4177
};
4278
const init = { method, headers: finalHeaders };
4379
if (body !== undefined) init.body = body instanceof FormData ? body : JSON.stringify(body);
80+
checkBaseSafety();
4481
const res = await fetch(baseUrl() + path, init);
4582
if (!res.ok) {
4683
let msg = `${res.status} ${res.statusText}`;

frontend/src/api/sse.js

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
// SSE streaming over fetch (EventSource cannot set Authorization headers).
22
// Parses `event:` / `data:` blocks from a ReadableStream and invokes handlers.
33

4-
import { baseUrl, getToken, notifyUnauthorized } from "./client.js";
4+
import { baseUrl, getToken, notifyUnauthorized, checkBaseSafety } from "./client.js";
55

66
// Parse raw text buffer into complete SSE blocks, returning [events, remainder].
77
function parseBuffer(buf) {
@@ -54,6 +54,7 @@ function buildHeaders(body, extra = {}) {
5454

5555
// Stream a JSON-POST endpoint. Returns an AbortController-like handle via opts.signal.
5656
export async function streamSSE(path, payload, onEvent, opts = {}) {
57+
checkBaseSafety();
5758
const res = await fetch(baseUrl() + path, {
5859
method: "POST",
5960
headers: buildHeaders(payload),
@@ -78,6 +79,7 @@ await readStream(res, onEvent);
7879

7980
// Stream a multipart upload (FormData) endpoint.
8081
export async function streamUpload(path, form, onEvent, opts = {}) {
82+
checkBaseSafety();
8183
const res = await fetch(baseUrl() + path, {
8284
method: "POST",
8385
headers: buildHeaders(form),

frontend/src/components/Inspector.jsx

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
import { useEffect, useRef } from "react";
22
import { useApp } from "../state/AppContext.jsx";
3+
import { useI18n } from "../i18n.jsx";
34

45
export default function Inspector() {
56
const { inspItems, inspBusy } = useApp();
7+
const { t } = useI18n();
68
const bodyRef = useRef(null);
79

810
useEffect(() => {
@@ -12,13 +14,13 @@ export default function Inspector() {
1214
return (
1315
<aside className="inspector">
1416
<div className="insp-head">
15-
<span className="insp-title">检索与推理</span>
16-
<span className={`insp-status ${inspBusy ? "busy" : ""}`}>{inspBusy ? "推理中…" : "空闲"}</span>
17+
<span className="insp-title">{t("inspectorTitle")}</span>
18+
<span className={`insp-status ${inspBusy ? "busy" : ""}`}>{inspBusy ? t("inspBusy") : t("inspIdle")}</span>
1719
</div>
1820
<div className="insp-body" ref={bodyRef}>
1921
{inspItems.length === 0 ? (
2022
<div className="insp-empty">
21-
发起查询或对话后,<br />无向量检索与推理过程将在此实时呈现。
23+
{t("inspEmptyHint")}
2224
</div>
2325
) : (
2426
inspItems.map((it, i) => (

frontend/src/components/SettingsModal.jsx

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { useState, useEffect } from "react";
22
import { X } from "lucide-react";
33
import { useApp } from "../state/AppContext.jsx";
4+
import { useI18n } from "../i18n.jsx";
45

56
export default function SettingsModal() {
67
const { settingsOpen, setSettingsOpen, saveConnection, apiBase, token, toastMsg } = useApp();
8+
const { t } = useI18n();
79
const [base, setBase] = useState(apiBase);
810
const [tok, setTok] = useState(token);
911

@@ -18,33 +20,33 @@ export default function SettingsModal() {
1820
const cleaned = (base || "").trim().replace(/\/$/, "");
1921
saveConnection(cleaned, tok);
2022
setSettingsOpen(false);
21-
toastMsg("已保存,正在刷新…", "ok");
23+
toastMsg(t("connect"), "ok");
2224
window.dispatchEvent(new CustomEvent("openkb:reload-kbs"));
2325
}
2426

2527
return (
2628
<div className="overlay" onClick={() => setSettingsOpen(false)}>
2729
<div className="modal" onClick={(e) => e.stopPropagation()}>
2830
<div className="modal-head">
29-
<h2>连接设置</h2>
31+
<h2>{t("settings")}</h2>
3032
<button className="icon-btn" onClick={() => setSettingsOpen(false)}>
3133
<X size={18} />
3234
</button>
3335
</div>
3436
<div className="modal-body">
3537
<label className="field">
36-
<span className="field-label">API 地址</span>
37-
<input value={base} onChange={(e) => setBase(e.target.value)} placeholder="留空则同源访问(如 http://127.0.0.1:8000)" />
38+
<span className="field-label">{t("apiBase")}</span>
39+
<input value={base} onChange={(e) => setBase(e.target.value)} placeholder={t("apiBasePlaceholder")} />
3840
</label>
3941
<label className="field">
4042
<span className="field-label">Bearer Token</span>
4143
<input type="password" value={tok} onChange={(e) => setTok(e.target.value)} placeholder="OPENKB_API_TOKEN" />
4244
</label>
43-
<p className="field-hint">配置信息仅保存在本浏览器本地。</p>
45+
<p className="field-hint">{t("insecureWarn")}</p>
4446
</div>
4547
<div className="modal-foot">
46-
<button className="btn btn-ghost" onClick={() => setSettingsOpen(false)}>取消</button>
47-
<button className="btn btn-primary" onClick={handleSave}>保存</button>
48+
<button className="btn btn-ghost" onClick={() => setSettingsOpen(false)}>{t("cancel")}</button>
49+
<button className="btn btn-primary" onClick={handleSave}>{t("save")}</button>
4850
</div>
4951
</div>
5052
</div>

frontend/src/components/Sidebar.jsx

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,20 @@
11
import { useState, useRef, useEffect } from "react";
2-
import { LayoutGrid, FileText, Search, MessageSquare, Wrench, Settings, Plus, ChevronDown } from "lucide-react";
2+
import { LayoutGrid, FileText, Search, MessageSquare, Wrench, Settings, Plus, ChevronDown, Languages } from "lucide-react";
33
import { useApp } from "../state/AppContext.jsx";
4+
import { useI18n } from "../i18n.jsx";
45
import { api } from "../api/client.js";
56

67
const NAV = [
7-
{ view: "overview", label: "概览", icon: LayoutGrid },
8-
{ view: "documents", label: "文档", icon: FileText },
9-
{ view: "query", label: "查询", icon: Search },
10-
{ view: "chat", label: "对话", icon: MessageSquare },
11-
{ view: "maintenance", label: "维护", icon: Wrench },
8+
{ view: "overview", icon: LayoutGrid },
9+
{ view: "documents", icon: FileText },
10+
{ view: "query", icon: Search },
11+
{ view: "chat", icon: MessageSquare },
12+
{ view: "maintenance", icon: Wrench },
1213
];
1314

1415
export default function Sidebar() {
1516
const { kbs, kb, setKb, view, setView, setSettingsOpen, sidebarOpen, setSidebarOpen, toastMsg } = useApp();
17+
const { t, toggleLang, lang } = useI18n();
1618
const [menuOpen, setMenuOpen] = useState(false);
1719
const [creating, setCreating] = useState(false);
1820
const menuRef = useRef(null);
@@ -26,15 +28,15 @@ export default function Sidebar() {
2628
}, []);
2729

2830
async function handleCreate() {
29-
const name = window.prompt("新知识库名称(字母/数字/下划线/连字符):");
31+
const name = window.prompt(t("newKbPrompt"));
3032
if (!name) return;
3133
setCreating(true);
3234
try {
3335
await api.initKb(name.trim());
3436
setKb(name.trim());
3537
setMenuOpen(false);
3638
window.dispatchEvent(new CustomEvent("openkb:reload-kbs"));
37-
toastMsg("已创建:" + name.trim(), "ok");
39+
toastMsg(t("created") + ": " + name.trim(), "ok");
3840
} catch (e) {
3941
toastMsg(e.message, "err");
4042
} finally {
@@ -53,15 +55,15 @@ export default function Sidebar() {
5355
<div className="kb-switcher" ref={menuRef}>
5456
<button className="kb-current" type="button" onClick={() => setMenuOpen((o) => !o)}>
5557
<span className="kb-current-dot" />
56-
<span className="kb-current-name">{kb || "未选择知识库"}</span>
58+
<span className="kb-current-name">{kb || t("noKbSelected")}</span>
5759
<ChevronDown className="kb-chev" size={14} />
5860
</button>
5961
{menuOpen && (
6062
<div className="kb-menu">
6163
<div className="kb-menu-list">
6264
{kbs.length === 0 && (
6365
<div className="kb-menu-item" style={{ color: "var(--text-3)" }}>
64-
<span className="mi-name">暂无知识库</span>
66+
<span className="mi-name">{t("noKbs")}</span>
6567
</div>
6668
)}
6769
{kbs.map((k) => (
@@ -71,27 +73,30 @@ export default function Sidebar() {
7173
onClick={() => { setKb(k.name); setMenuOpen(false); }}
7274
>
7375
<span className="mi-name">{k.name}</span>
74-
<span className="mi-meta">{k.document_count} </span>
76+
<span className="mi-meta">{k.document_count} {t("docs")}</span>
7577
</button>
7678
))}
7779
</div>
7880
<button className="kb-menu-new" onClick={handleCreate} disabled={creating}>
7981
<Plus size={14} />
80-
新建知识库
82+
{t("newKb")}
8183
</button>
8284
</div>
8385
)}
8486
</div>
8587
<nav className="nav">
86-
{NAV.map(({ view: v, label, icon: Icon }) => (
88+
{NAV.map(({ view: v, icon: Icon }) => (
8789
<button key={v} className={`nav-item ${view === v ? "active" : ""}`} onClick={() => setView(v)}>
8890
<Icon size={16} />
89-
<span>{label}</span>
91+
<span>{t(v)}</span>
9092
</button>
9193
))}
9294
</nav>
9395
<div className="sidebar-foot">
94-
<button className="icon-btn" title="连接设置" onClick={() => setSettingsOpen(true)}>
96+
<button className="icon-btn" title={lang === "en" ? "中文" : "English"} onClick={toggleLang}>
97+
<Languages size={16} />
98+
</button>
99+
<button className="icon-btn" title={t("settings")} onClick={() => setSettingsOpen(true)}>
95100
<Settings size={16} />
96101
</button>
97102
</div>
Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
import { useI18n } from "../i18n.jsx";
2+
13
export default function Spinner({ size = 18 }) {
2-
return <span className="spinner" style={{ width: size, height: size }} aria-label="加载中" />;
4+
const { t } = useI18n();
5+
return <span className="spinner" style={{ width: size, height: size }} aria-label={t("loading")} />;
36
}

0 commit comments

Comments
 (0)