Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions frontend/src/api/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ export interface GlobalConfig {
model: string
language: string
pageindex_threshold: number
/** CLEANED effective global entity-extraction vocabulary (always includes
* "other"). Mirrors KbConfig.entity_types. */
entity_types: string[]
/** Plaintext global-default LLM base URL (a config value, not a secret);
* null if unset. Mirrors KbConfig.openai_api_base. */
openai_api_base?: string | null
Expand All @@ -30,6 +33,9 @@ export interface GlobalConfigPatch {
model?: string | null
language?: string | null
pageindex_threshold?: number | null
/** A list sets the global vocabulary; `null` reverts to the built-in
* default. Cleaned server-side (lowercase, `[a-z0-9 _-]`, + "other"). */
entity_types?: string[] | null
}
api_key?: string | null
openai_api_base?: string | null
Expand Down
16 changes: 12 additions & 4 deletions frontend/src/api/kb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,17 +54,21 @@ export interface KbConfig {
model: string
language: string
pageindex_threshold: number
/** CLEANED effective entity-extraction vocabulary (always includes "other"). */
entity_types: string[]
/** Plaintext LLM base URL (a config value, not a credential); null if unset. */
openai_api_base: string | null
/** Presence flag only — the raw key value is NEVER returned by the API. */
has_api_key: boolean
/** Which layer supplied each scalar's effective value. */
sources: Record<"model" | "language" | "pageindex_threshold", ConfigSource>
/** Raw global-layer scalars (null where global.yaml is silent). */
/** Which layer supplied each field's effective value. */
sources: Record<"model" | "language" | "pageindex_threshold" | "entity_types", ConfigSource>
/** Raw global-layer values (null where global.yaml is silent). */
global_values: {
model: string | null
language: string | null
pageindex_threshold: number | null
/** RAW global list (not cleaned) — for the inherited badge. */
entity_types: string[] | null
}
}

Expand All @@ -76,7 +80,11 @@ export interface KbConfig {
* key" request, not "unchanged" or "clear".
*/
export interface KbConfigPatch {
config?: Partial<Pick<KbConfig, "model" | "language" | "pageindex_threshold">>
config?: Partial<Pick<KbConfig, "model" | "language" | "pageindex_threshold">> & {
/** A list sets/overrides for this KB; `null` reverts to inherited. Values
* are cleaned server-side (lowercase, `[a-z0-9 _-]`, dedupe, + "other"). */
entity_types?: string[] | null
}
api_key?: string | null
openai_api_base?: string | null
}
Expand Down
84 changes: 84 additions & 0 deletions frontend/src/components/EntityTypesEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { X } from 'lucide-react'

/** The backend's catch-all bucket: it is re-appended server-side on every
* write, so the editor renders it as a fixed, non-removable chip. */
const ALWAYS_INCLUDED = 'other'

/**
* Chips editor for the entity-extraction vocabulary, shared by the per-KB
* override row (KbSettingsSheet) and the global editor (Settings › general).
* Controlled: renders `value` as removable chips + an add input (Enter to add,
* commas split), and calls `onChange` with the FULL next list on each
* add/remove. Client-side it only trims/lowercases and drops empties/dupes —
* the authoritative cleaning (charset, dedupe, "other") happens server-side.
*/
export default function EntityTypesEditor({
value,
disabled,
onChange,
}: {
/** Current list (server-cleaned effective list, or local draft state). */
value: string[]
disabled?: boolean
/** Receives the complete next list after an add/remove. */
onChange: (next: string[]) => void
}) {
const { t } = useTranslation('common')
const [draft, setDraft] = useState('')

const add = () => {
const seen = new Set(value.map((v) => v.toLowerCase()))
const added: string[] = []
for (const part of draft.split(',')) {
const v = part.trim().toLowerCase()
if (v === '' || seen.has(v)) continue
seen.add(v)
added.push(v)
}
setDraft('')
if (added.length > 0) onChange([...value, ...added])
}

return (
<div className="flex flex-wrap items-center gap-1.5 rounded-md border border-input px-2.5 py-2">
{value.map((item) => (
<span
key={item}
className="inline-flex items-center gap-1 rounded-full border border-[hsl(var(--glass-border))] bg-muted px-2.5 py-1 text-[11.5px] font-mono2 text-foreground"
>
{item}
{item === ALWAYS_INCLUDED ? (
<span className="text-[10.5px] text-muted-foreground">{t('entityTypes.always')}</span>
) : (
<button
type="button"
onClick={() => onChange(value.filter((x) => x !== item))}
disabled={disabled}
aria-label={t('entityTypes.removeAria', { type: item })}
className="grid h-3.5 w-3.5 place-items-center rounded-full text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-60"
>
<X className="h-2.5 w-2.5" />
</button>
)}
</span>
))}
<input
value={draft}
disabled={disabled}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
// Enter confirms IME composition first; only a real Enter adds.
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
e.preventDefault()
add()
}
}}
placeholder={t('entityTypes.placeholder')}
aria-label={t('entityTypes.placeholder')}
className="h-6 min-w-[140px] flex-1 bg-transparent text-[12.5px] font-mono2 outline-none placeholder:text-muted-foreground/70 disabled:opacity-60"
/>
</div>
)
}
82 changes: 82 additions & 0 deletions frontend/src/components/KbSettingsSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import type { SseEvent } from '@/api/client'
import { Switch } from '@/components/ui/switch'
import { cn } from '@/lib/utils'
import { UnLanguageDatalist, UN_LANG_LIST_ID } from '@/components/UnLanguageDatalist'
import EntityTypesEditor from '@/components/EntityTypesEditor'

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

Expand Down Expand Up @@ -169,6 +170,22 @@ function KbConfigSection({ kb }: { kb: string }) {
[kb, apply, t],
)

// Persist the entity-types override (list) or revert to inherited (null).
// Always adopts the response: the row then shows the server-CLEANED list.
const setEntityTypesOverride = useCallback(
async (value: string[] | null) => {
setBusy(true)
try {
apply(await patchKbConfig(kb, { config: { entity_types: value } }))
} catch (e) {
toast.error(t('common:saveError', { error: errMsg(e) }))
} finally {
setBusy(false)
}
},
[kb, apply, t],
)

const saveCredentials = useCallback(async () => {
if (!config) return
setBusy(true)
Expand Down Expand Up @@ -258,6 +275,13 @@ function KbConfigSection({ kb }: { kb: string }) {
onSet={(v) => setOverride('pageindex_threshold', Number(v))}
onRevert={() => setOverride('pageindex_threshold', null)}
/>
<EntityTypesRow
source={config.sources.entity_types}
effective={config.entity_types}
busy={busy}
onSet={setEntityTypesOverride}
onRevert={() => setEntityTypesOverride(null)}
/>

<h3 className="pt-2 text-[12px] font-semibold text-muted-foreground tracking-wide">{t('kbSettings:credHeading')}</h3>
<div>
Expand Down Expand Up @@ -385,6 +409,64 @@ function OverrideRow({
)
}

/** OverrideRow's list-shaped sibling for the entity-extraction vocabulary:
* the same 为本库覆盖 switch + inherited badge, but the override editor is a
* chips list (EntityTypesEditor) and every add/remove persists immediately —
* the chips always show the server-cleaned effective list. */
function EntityTypesRow({
source, effective, busy, onSet, onRevert,
}: {
source: ConfigSource
/** Cleaned effective list (always includes "other") — shown as-is in the
* inherited badge, so it matches the vocabulary the compiler actually uses. */
effective: string[]
busy: boolean
onSet: (value: string[]) => void
onRevert: () => void
}) {
const { t } = useTranslation(['kbSettings', 'common'])
const overridden = source === 'kb'
const label = t('common:fields.entityTypes')

const inheritedBadge =
source === 'global'
? t('kbSettings:inheritGlobal', { value: effective.join(', ') })
: t('kbSettings:inheritDefault', { value: effective.join(', ') })

return (
<div>
<div className="flex items-center justify-between">
<label className="text-[12px] font-medium text-muted-foreground">{label}</label>
<span className="flex items-center gap-2 text-[11px] text-muted-foreground">
{t('kbSettings:override')}
<Switch
checked={overridden}
disabled={busy}
// ON seeds the override with the current effective list (the
// backend then owns it as this KB's own list); OFF reverts to
// inherited via an explicit null.
onCheckedChange={(v) => (v ? onSet(effective) : onRevert())}
aria-label={t('kbSettings:overrideAria', { label })}
/>
</span>
</div>
{overridden ? (
<div className="mt-1.5">
<EntityTypesEditor value={effective} disabled={busy} onChange={onSet} />
</div>
) : (
<div
data-field="entity_types"
className="mt-1.5 flex min-h-9 items-center rounded-md border border-dashed border-[hsl(var(--glass-border))] px-3 py-1.5 text-[12.5px] text-muted-foreground"
>
{inheritedBadge}
</div>
)}
<p className="mt-1.5 text-[11.5px] text-muted-foreground">{t('kbSettings:entityTypesNote')}</p>
</div>
)
}

/** Per-doc row accumulated from a recompile SSE stream (from the `doc` event).
* Moved verbatim from `pages/KbDetail.tsx` (formerly its 任务 tab). */
interface RecompileDoc {
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
"fields": {
"model": "Model",
"wikiLanguage": "Wiki output language",
"threshold": "PageIndex threshold (pages)"
"threshold": "PageIndex threshold (pages)",
"entityTypes": "Entity types"
},
"entityTypes": {
"placeholder": "Add a type, press Enter",
"removeAria": "Remove type {{type}}",
"always": "always included"
},
"errors": {
"requestFailed": "Request failed",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/en/kbSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"inheritDefault": "Inherited · default ({{value}})",
"override": "Override for this KB",
"overrideAria": "Override {{label}} for this KB",
"entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.",
"unnamed": "(unnamed)",
"recompileFailed": "Recompile failed",
"watchStarted": "File watching started",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/en/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
},
"general": {
"langDesc": "The output language written into the compile prompt, e.g. en / 中文 / 日本語",
"entityTypesDesc": "The vocabulary used to classify entities extracted at compile time; each KB can inherit or override it in its own settings.",
"entityTypesNote": "Changes apply to future recompiles only; existing entity pages keep their current type.",
"kbRootLabel": "Knowledge base root",
"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.",
"kbRootPlaceholder": "e.g. ~/openkb",
Expand Down
8 changes: 7 additions & 1 deletion frontend/src/locales/zh/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,13 @@
"fields": {
"model": "模型",
"wikiLanguage": "Wiki 输出语言",
"threshold": "PageIndex 阈值(页数)"
"threshold": "PageIndex 阈值(页数)",
"entityTypes": "实体类型"
},
"entityTypes": {
"placeholder": "输入类型,按 Enter 添加",
"removeAria": "移除类型 {{type}}",
"always": "始终包含"
},
"errors": {
"requestFailed": "请求失败",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/zh/kbSettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"inheritDefault": "继承 · 默认({{value}})",
"override": "为本库覆盖",
"overrideAria": "{{label}} 为本库覆盖",
"entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。",
"unnamed": "(未命名)",
"recompileFailed": "重新编译失败",
"watchStarted": "已启动文件监听",
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/locales/zh/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@
},
"general": {
"langDesc": "写入编译 prompt 的输出语言,例如 en / 中文 / 日本語",
"entityTypesDesc": "编译时为抽取出的实体分类所用的类型词表;各知识库可在其设置中继承或覆盖。",
"entityTypesNote": "改动仅影响之后的重新编译;已有实体页会保留其当前类型。",
"kbRootLabel": "知识库根目录",
"kbRootDesc": "新建知识库的默认位置(<根目录>/<名称>);留空则恢复内置默认值。单个知识库可在创建时设置自定义路径。",
"kbRootPlaceholder": "例如 ~/openkb",
Expand Down
29 changes: 28 additions & 1 deletion frontend/src/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { toast } from 'sonner'
import { getGlobalConfig, patchGlobalConfig, type GlobalConfig, type GlobalConfigPatch } from '@/api/config'
import ConnectorCards from '@/components/ConnectorCards'
import AboutTab from '@/components/AboutTab'
import EntityTypesEditor from '@/components/EntityTypesEditor'
import { cn } from '@/lib/utils'
import { UnLanguageDatalist, UN_LANG_LIST_ID } from '@/components/UnLanguageDatalist'

Expand Down Expand Up @@ -39,6 +40,10 @@ export default function Settings() {
const [model, setModel] = useState('')
const [language, setLanguage] = useState('')
const [threshold, setThreshold] = useState('')
// Entity-extraction vocabulary, edited as chips. Seeded from the CLEANED
// effective list (always includes "other"); diffed order-sensitively in
// buildPatch. Server re-cleans on save, so client edits stay lightweight.
const [entityTypes, setEntityTypes] = useState<string[]>([])
// KB root directory. Emptying a previously-set root clears it (null → default),
// mirroring the credential-base's empty→null discipline in buildPatch.
const [kbRoot, setKbRoot] = useState('')
Expand All @@ -58,6 +63,7 @@ export default function Settings() {
setModel(c.model)
setLanguage(c.language)
setThreshold(String(c.pageindex_threshold))
setEntityTypes(c.entity_types)
setKbRoot(c.kb_root ?? '')
setApiBase(c.openai_api_base ?? '')
setApiKey('')
Expand Down Expand Up @@ -103,6 +109,14 @@ export default function Settings() {
if (threshold.trim() !== '' && Number.isInteger(n) && n >= 1 && n !== config.pageindex_threshold) {
cfg.pageindex_threshold = n
}
// Order-INSENSITIVE compare: the vocabulary is a set (the compiler treats
// it as an allowlist), and removing-then-re-adding a type appends it at the
// end — so a same-set reorder must NOT dirty the form or persist a no-op.
const a = [...entityTypes].sort()
const b = [...config.entity_types].sort()
if (a.length !== b.length || a.some((v, i) => v !== b[i])) {
cfg.entity_types = entityTypes
}
if (Object.keys(cfg).length > 0) patch.config = cfg
// When kb_root is env-pinned the field is read-only and the effective value
// is the OPENKB_KB_ROOT env root, so never diff/emit it — a Save would
Expand All @@ -118,7 +132,7 @@ export default function Settings() {
if (clearKey) patch.api_key = null
else if (apiKey !== '') patch.api_key = apiKey
return { patch, dirty: Object.keys(patch).length > 0 }
}, [config, model, language, threshold, kbRoot, apiBase, apiKey, clearKey])
}, [config, model, language, threshold, entityTypes, kbRoot, apiBase, apiKey, clearKey])

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

Expand Down Expand Up @@ -313,6 +327,19 @@ export default function Settings() {
<UnLanguageDatalist />
</div>

<div>
<label className="text-[13px] font-semibold text-foreground">{t('common:fields.entityTypes')}</label>
<p className="mt-0.5 text-[12px] text-muted-foreground">{t('settings:general.entityTypesDesc')}</p>
<div className="mt-1.5 max-w-[560px]">
<EntityTypesEditor
value={entityTypes}
disabled={loading || !config}
onChange={setEntityTypes}
/>
</div>
<p className="mt-1.5 text-[11.5px] text-muted-foreground">{t('settings:general.entityTypesNote')}</p>
</div>

<div>
<label className="text-[13px] font-semibold text-foreground">{t('settings:general.kbRootLabel')}</label>
<p className="mt-0.5 text-[12px] text-muted-foreground">{t('settings:general.kbRootDesc')}</p>
Expand Down
Loading
Loading