Skip to content

Commit 64f6073

Browse files
committed
feat(web): surface KB location — create-path preview, custom path, KB-root setting
- CreateKbDialog shows a live "Will be created at <root>/<name>" preview (fetched from /api/v1/kbs root) and an optional Custom path field that overrides it. - Settings › General adds a Knowledge base root field (top-level kb_root merge-patch; empty -> null/default) with a note when it's pinned by OPENKB_KB_ROOT. - KbList cards show each KB's path (now that KBs can live outside the root). Claude-Session: https://claude.ai/code/session_01XMxbhmAkxxVV8CFWCZDBaG
1 parent f6cfcb2 commit 64f6073

9 files changed

Lines changed: 139 additions & 10 deletions

File tree

frontend/src/api/config.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ export interface GlobalConfig {
1111
openai_api_base?: string | null
1212
/** Presence flag only — the raw key value is NEVER returned by the API. */
1313
has_api_key?: boolean
14+
/** Effective KB root directory — where `<root>/<name>` KBs are created. */
15+
kb_root: string
16+
/** True when OPENKB_KB_ROOT is set in the environment, which overrides any
17+
* UI-set root; edits here won't take effect until the env var is unset. */
18+
kb_root_env_pinned?: boolean
1419
}
1520

1621
/**
@@ -28,6 +33,9 @@ export interface GlobalConfigPatch {
2833
}
2934
api_key?: string | null
3035
openai_api_base?: string | null
36+
/** Effective KB root. A value sets it; `null` (or empty) reverts to the
37+
* built-in default. Placed top-level, mirroring api_key/openai_api_base. */
38+
kb_root?: string | null
3139
}
3240

3341
export function getGlobalConfig(): Promise<GlobalConfig> {

frontend/src/api/kb.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ export interface KbSummary {
55
document_count: number
66
last_compile: string | null
77
has_raw: boolean
8+
/** Absolute directory of this KB (may sit outside the default root). */
9+
path?: string
810
}
911

1012
export interface KbListResponse {
@@ -18,12 +20,14 @@ export function listKbs(): Promise<KbListResponse> {
1820

1921
/** Body for POST /api/v1/init. Only `kb` is required; model/credentials are
2022
* omitted here and left to inherit global defaults (set later via the per-KB
21-
* gear / KbSettingsSheet). */
23+
* gear / KbSettingsSheet). When `path` is given, the KB is created at that
24+
* absolute directory instead of the default `<root>/<name>`. */
2225
export interface InitRequest {
2326
kb: string
2427
model?: string
2528
api_key?: string
2629
openai_api_base?: string
30+
path?: string
2731
}
2832

2933
export interface InitResponse {

frontend/src/components/CreateKbDialog.tsx

Lines changed: 73 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@ import { useNavigate } from 'react-router'
33
import { useTranslation } from 'react-i18next'
44
import * as Dialog from '@radix-ui/react-dialog'
55
import { AnimatePresence, motion, useReducedMotion } from 'motion/react'
6-
import { Loader2, Plus } from 'lucide-react'
7-
import { createKb } from '@/api/kb'
6+
import { ChevronDown, Loader2, Plus } from 'lucide-react'
7+
import { createKb, listKbs } from '@/api/kb'
8+
import { cn } from '@/lib/utils'
89

910
const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
1011

@@ -26,36 +27,61 @@ export default function CreateKbDialog({ children }: { children: ReactNode }) {
2627
const [name, setName] = useState('')
2728
const [error, setError] = useState<string | null>(null)
2829
const [submitting, setSubmitting] = useState(false)
30+
// Effective KB root, fetched on open, used to render the live target-path
31+
// preview (`<root>/<name>`). Null until the fetch lands (or if it fails).
32+
const [root, setRoot] = useState<string | null>(null)
33+
// Optional advanced override: an absolute directory to create the KB at
34+
// instead of `<root>/<name>`. Hidden behind the "Custom path" toggle.
35+
const [customPath, setCustomPath] = useState('')
36+
const [showAdvanced, setShowAdvanced] = useState(false)
2937

3038
// Reset transient form state on every open/close so a reopened dialog is
3139
// clean and a Radix-initiated close (Escape / scrim) can't strand an error.
40+
// On open we (re)fetch the KB root so the preview reflects the current root.
3241
const onOpenChange = useCallback((next: boolean) => {
3342
if (submitting) return
3443
setOpen(next)
3544
setName('')
3645
setError(null)
46+
setCustomPath('')
47+
setShowAdvanced(false)
48+
if (next) {
49+
setRoot(null)
50+
listKbs()
51+
.then((r) => setRoot(r.root))
52+
.catch(() => setRoot(null))
53+
}
3754
}, [submitting])
3855

3956
const trimmed = name.trim()
57+
const customTrimmed = customPath.trim()
58+
59+
// What the create will actually target: an explicit custom path wins;
60+
// otherwise `<root>/<name>` (with an ellipsis stand-in before a name is typed).
61+
const previewPath = customTrimmed || (root ? `${root}/${trimmed || '…'}` : '')
4062

4163
const submit = useCallback(async () => {
4264
if (!trimmed || submitting) return
4365
setSubmitting(true)
4466
setError(null)
4567
try {
46-
await createKb({ kb: trimmed })
68+
const path = customPath.trim()
69+
// Resolution is by name regardless of path, so navigation is unchanged.
70+
await createKb(path ? { kb: trimmed, path } : { kb: trimmed })
4771
// Broadcast so any live KB list (e.g. AppSidebar) re-fetches without a
4872
// full reload; mirrors the old app's `openkb:reload-kbs` signal.
4973
window.dispatchEvent(new CustomEvent('openkb:reload-kbs'))
5074
setSubmitting(false)
5175
setOpen(false)
5276
setName('')
77+
setCustomPath('')
78+
setShowAdvanced(false)
5379
navigate(`/kb/${encodeURIComponent(trimmed)}`)
5480
} catch (e) {
5581
setError(errMsg(e))
5682
setSubmitting(false)
5783
}
58-
}, [trimmed, submitting, navigate])
84+
}, [trimmed, customPath, submitting, navigate])
5985

6086
return (
6187
<Dialog.Root open={open} onOpenChange={onOpenChange}>
@@ -114,6 +140,49 @@ export default function CreateKbDialog({ children }: { children: ReactNode }) {
114140
className="mt-1.5 w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-accent-brand"
115141
/>
116142

143+
{/* Live target-path preview: `<root>/<name>`, or the custom
144+
path when one is set. Only shown once the root is known. */}
145+
{previewPath && (
146+
<p className="mt-1.5 text-[11.5px] text-muted-foreground font-mono2 break-all">
147+
{t('create.previewLabel', { path: previewPath })}
148+
</p>
149+
)}
150+
151+
{/* Unobtrusive advanced override: a text toggle that reveals
152+
an absolute custom-path field; empty = use default root. */}
153+
<button
154+
type="button"
155+
onClick={() => setShowAdvanced((v) => !v)}
156+
disabled={submitting}
157+
className="mt-2.5 inline-flex items-center gap-1 text-[12px] font-medium text-muted-foreground hover:text-foreground transition-colors disabled:opacity-60"
158+
>
159+
<ChevronDown
160+
className={cn('w-3.5 h-3.5 transition-transform', showAdvanced && 'rotate-180')}
161+
/>
162+
{t('create.advancedToggle')}
163+
</button>
164+
{showAdvanced && (
165+
<div className="mt-2">
166+
<label htmlFor="create-kb-path" className="text-[12px] font-medium text-muted-foreground">
167+
{t('create.customPathLabel')}
168+
</label>
169+
<input
170+
id="create-kb-path"
171+
value={customPath}
172+
disabled={submitting}
173+
onChange={(e) => {
174+
setCustomPath(e.target.value)
175+
if (error) setError(null)
176+
}}
177+
placeholder={t('create.customPathPlaceholder')}
178+
className="mt-1.5 w-full h-9 rounded-md border border-input bg-transparent px-3 text-[13px] font-mono2 outline-none focus-visible:ring-2 focus-visible:ring-ring focus:border-accent-brand"
179+
/>
180+
<p className="mt-1.5 text-[11.5px] text-muted-foreground">
181+
{t('create.customPathHint')}
182+
</p>
183+
</div>
184+
)}
185+
117186
{error && (
118187
<div className="mt-2.5 rounded-lg bg-red-50 dark:bg-red-500/10 border border-red-200/70 dark:border-red-500/25 px-3 py-2 text-[12.5px] text-red-600 dark:text-red-400">
119188
{error}

frontend/src/locales/en/kbList.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"namePlaceholder": "e.g. my-kb",
1313
"cancel": "Cancel",
1414
"submit": "Create",
15-
"submitting": "Creating…"
15+
"submitting": "Creating…",
16+
"previewLabel": "Will be created at {{path}}",
17+
"advancedToggle": "Custom path",
18+
"customPathLabel": "Custom path",
19+
"customPathPlaceholder": "/absolute/path/to/kb",
20+
"customPathHint": "Leave empty to use the default root"
1621
}
1722
}

frontend/src/locales/en/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
"undoClear": "Undo"
2020
},
2121
"general": {
22-
"langDesc": "The output language written into the compile prompt, e.g. en / 中文 / 日本語"
22+
"langDesc": "The output language written into the compile prompt, e.g. en / 中文 / 日本語",
23+
"kbRootLabel": "Knowledge base root",
24+
"kbRootDesc": "Default location for new knowledge bases (<root>/<name>); leave empty to restore the built-in default. Individual KBs can set a custom path at creation.",
25+
"kbRootPlaceholder": "e.g. ~/openkb",
26+
"kbRootEnvPinned": "Pinned by the OPENKB_KB_ROOT environment variable; edits here won't take effect until it's unset."
2327
},
2428
"conn": {
2529
"note": "Cloud data-source connectors (OAuth / S3) are in development and not yet available; for now, upload local files from the KB detail page. Want a connector? Click a card below to vote on GitHub and help us prioritize."

frontend/src/locales/zh/kbList.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@
1212
"namePlaceholder": "例如 my-kb",
1313
"cancel": "取消",
1414
"submit": "创建",
15-
"submitting": "创建中…"
15+
"submitting": "创建中…",
16+
"previewLabel": "将创建于 {{path}}",
17+
"advancedToggle": "自定义路径",
18+
"customPathLabel": "自定义路径",
19+
"customPathPlaceholder": "/绝对/路径/到/知识库",
20+
"customPathHint": "留空则使用默认根目录"
1621
}
1722
}

frontend/src/locales/zh/settings.json

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,11 @@
1919
"undoClear": "撤销"
2020
},
2121
"general": {
22-
"langDesc": "写入编译 prompt 的输出语言,例如 en / 中文 / 日本語"
22+
"langDesc": "写入编译 prompt 的输出语言,例如 en / 中文 / 日本語",
23+
"kbRootLabel": "知识库根目录",
24+
"kbRootDesc": "新建知识库的默认位置(<根目录>/<名称>);留空则恢复内置默认值。单个知识库可在创建时设置自定义路径。",
25+
"kbRootPlaceholder": "例如 ~/openkb",
26+
"kbRootEnvPinned": "已由环境变量 OPENKB_KB_ROOT 固定,此处的修改在取消该环境变量前不会生效。"
2327
},
2428
"conn": {
2529
"note": "云端数据源连接器(OAuth / S3)开发中,尚不可用;当前请在知识库详情页手动上传本地文件。想要某个连接器?点下方卡片去 GitHub 投票,帮我们排优先级。"

frontend/src/pages/KbList.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,14 @@ export default function KbList() {
5555
<span className="text-[16px] font-bold text-foreground">{kb.name}</span>
5656
</div>
5757

58+
{/* Directory in small muted mono so scattered (custom-path) KBs
59+
stay legible. `title` exposes the full path when truncated. */}
60+
{kb.path && (
61+
<div className="mt-1.5 text-[11px] text-muted-foreground font-mono2 truncate" title={kb.path}>
62+
{kb.path}
63+
</div>
64+
)}
65+
5866
<div className="mt-4">
5967
<div className="inline-flex items-center gap-3 rounded-xl bg-muted/50 border border-[hsl(var(--glass-border))] px-3.5 py-2.5">
6068
<FileText className="w-4 h-4 text-muted-foreground" />

frontend/src/pages/Settings.tsx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ export default function Settings() {
3939
const [model, setModel] = useState('')
4040
const [language, setLanguage] = useState('')
4141
const [threshold, setThreshold] = useState('')
42+
// KB root directory. Emptying a previously-set root clears it (null → default),
43+
// mirroring the credential-base's empty→null discipline in buildPatch.
44+
const [kbRoot, setKbRoot] = useState('')
4245
// Global-default credentials (written to ~/.config/openkb/.env). The API key
4346
// is write-only: its value is never fetched, so the input starts empty and a
4447
// non-empty value means "rotate". `clearKey` defers an explicit-null removal
@@ -55,6 +58,7 @@ export default function Settings() {
5558
setModel(c.model)
5659
setLanguage(c.language)
5760
setThreshold(String(c.pageindex_threshold))
61+
setKbRoot(c.kb_root ?? '')
5862
setApiBase(c.openai_api_base ?? '')
5963
setApiKey('')
6064
setClearKey(false)
@@ -97,13 +101,16 @@ export default function Settings() {
97101
cfg.pageindex_threshold = n
98102
}
99103
if (Object.keys(cfg).length > 0) patch.config = cfg
104+
const rootTrim = kbRoot.trim()
105+
const currentRoot = config.kb_root ?? ''
106+
if (rootTrim !== currentRoot) patch.kb_root = rootTrim === '' ? null : rootTrim
100107
const baseTrim = apiBase.trim()
101108
const currentBase = config.openai_api_base ?? ''
102109
if (baseTrim !== currentBase) patch.openai_api_base = baseTrim === '' ? null : baseTrim
103110
if (clearKey) patch.api_key = null
104111
else if (apiKey !== '') patch.api_key = apiKey
105112
return { patch, dirty: Object.keys(patch).length > 0 }
106-
}, [config, model, language, threshold, apiBase, apiKey, clearKey])
113+
}, [config, model, language, threshold, kbRoot, apiBase, apiKey, clearKey])
107114

108115
const dirty = useMemo(() => buildPatch().dirty, [buildPatch])
109116

@@ -279,6 +286,21 @@ export default function Settings() {
279286
/>
280287
<UnLanguageDatalist />
281288
</div>
289+
290+
<div>
291+
<label className="text-[13px] font-semibold text-foreground">{t('settings:general.kbRootLabel')}</label>
292+
<p className="mt-0.5 text-[12px] text-muted-foreground">{t('settings:general.kbRootDesc')}</p>
293+
<input
294+
value={kbRoot}
295+
disabled={loading || !config}
296+
onChange={(e) => setKbRoot(e.target.value)}
297+
placeholder={t('settings:general.kbRootPlaceholder')}
298+
className={cn(inputCls, 'max-w-[420px]')}
299+
/>
300+
{config?.kb_root_env_pinned && (
301+
<p className="mt-1.5 text-[11.5px] text-muted-foreground">{t('settings:general.kbRootEnvPinned')}</p>
302+
)}
303+
</div>
282304
</div>
283305

284306
<SaveBar dirty={dirty} saving={saving} onSave={save} disabled={loading || !config} />

0 commit comments

Comments
 (0)