From ab8d5d3db62b72f4974188c0b80205a951cd603e Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 05:09:20 +0000 Subject: [PATCH 1/2] Make knowledge bases mount-first and on by default on desktop. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Opening 知识库 now lands on local and cloud mount setup instead of a buried enable toggle. Vector, image, and scanned-document OCR settings stay available as foundation tabs for later cleaning, labeling, and distillation. Co-authored-by: XYAI Labs --- dashboard/src/locales/en.json | 41 ++-- dashboard/src/locales/zh.json | 41 ++-- .../pages/KnowledgeBases/CloudMountPanel.tsx | 50 ++++- .../KnowledgeMountHome.test.tsx | 57 ++++++ .../KnowledgeBases/KnowledgeMountHome.tsx | 76 +++++++ .../KnowledgeBases/LocalMountPanel.test.tsx | 59 ++++++ .../pages/KnowledgeBases/LocalMountPanel.tsx | 69 +++++-- .../pages/KnowledgeBases/index.module.less | 72 +++++++ dashboard/src/pages/KnowledgeBases/index.tsx | 192 +++++++++++------- .../pickKnowledgeFolder.test.ts | 24 +++ .../KnowledgeBases/pickKnowledgeFolder.ts | 13 ++ src/octop/api/routers/knowledge_bases.py | 13 +- src/octop/infra/knowledge/gate.py | 24 ++- src/octop/infra/knowledge/service.py | 4 +- src/octop/infra/server.py | 14 ++ tests/unit/api/test_knowledge_bases.py | 4 +- tests/unit/knowledge/test_gate.py | 48 +++++ tests/unit/knowledge/test_params.py | 1 + tests/unit/knowledge/test_service_acl.py | 23 +++ tests/unit/knowledge/test_text_documents.py | 12 ++ 20 files changed, 706 insertions(+), 131 deletions(-) create mode 100644 dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx create mode 100644 dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx create mode 100644 dashboard/src/pages/KnowledgeBases/LocalMountPanel.test.tsx create mode 100644 dashboard/src/pages/KnowledgeBases/pickKnowledgeFolder.test.ts create mode 100644 dashboard/src/pages/KnowledgeBases/pickKnowledgeFolder.ts diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 1df40e4f..3af678f3 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -416,15 +416,32 @@ }, "knowledgeBases": { "title": "Knowledge Bases", - "subtitle": "Index documents for chat retrieval. Optionally share them with users on this instance.", + "subtitle": "Mount a local folder or cloud knowledge base first. Cleaning, labeling, and distillation come later.", "featureTitle": "Enable Knowledge Bases", "settingsTitle": "Knowledge base settings", - "settingsLead": "Instance-wide switch. When on, configure an embedding model. When off, knowledge bases are unavailable for everyone.", - "settingsOpen": "Enable knowledge bases", + "settingsFoundation": "Foundation settings", + "settingsFoundationLead": "These settings are for later cleaning, labeling, and distillation. You can mount sources first without changing them.", + "settingsTabVector": "Vector model", + "settingsTabImage": "Images", + "settingsTabOcr": "Scanned-document OCR", + "settingsLead": "Advanced switch. It is on by default on the desktop. Turning it off hides knowledge bases from colleagues.", + "settingsOpen": "Knowledge base feature", + "homeTitle": "Mount your sources first", + "homeDesc": "Pick a folder on this computer, or attach a cloud knowledge base such as ima. The original folder stays read-only. Parse results go to a new folder you choose. Colleagues can look them up in chat.", + "homeAdvancedHint": "Vector, image, and scanned-document OCR settings are for later cleaning, labeling, and distillation.", + "defaultLocalName": "Local files", + "defaultCloudName": "Cloud knowledge", + "pickFolder": "Choose folder", + "pickFolderUnavailable": "This window cannot open a folder picker. Type the path instead.", + "imageSettingsTitle": "Read text in images", + "imageSettingsDesc": "Turn this on to recognize text in pictures, so you can clean and label them later.", + "ocrFoundationHint": "Turn this on so scanned PDFs can be read as text for later distillation.", + "disabledTitle": "Knowledge bases are turned off", + "disabledDesc": "You can turn the feature back on in foundation settings. After that, mount a local folder or a cloud knowledge base to start.", "manageModels": "Manage models", "unavailableTitle": "Knowledge bases are not ready", - "unavailableDescription": "Open Settings in the top-right to enable knowledge bases and configure an embedding model.", - "unavailableDescriptionNonAdmin": "Knowledge bases are not enabled or not configured yet. Please contact an administrator.", + "unavailableDescription": "Open Foundation settings in the top-right to turn knowledge bases on.", + "unavailableDescriptionNonAdmin": "Knowledge bases are turned off. Please contact an administrator.", "create": "Create knowledge base", "edit": "Edit knowledge base", "addToChat": "Add to chat", @@ -449,12 +466,12 @@ "enableGuideStepModel": "Configure an embedding model", "enableGuideStepModelDetail": "Choose local ONNX (download the model first) or an online embedding provider, then save.", "noDescription": "No description", - "selectBase": "Select a knowledge base to view its documents", - "mountTitle": "Local directory mount", - "mountHint": "Mount a folder as read-only. FreeOS never deletes or edits files in that folder. Parse copies must go to a new folder you choose.", + "selectBase": "Select a knowledge base, or mount a source first", + "mountTitle": "Local folder mount", + "mountHint": "Attach a folder on this computer. It stays read-only. Parse or distillation results must go to a new folder you choose.", "mountSource": "Local folder path", - "mountSourceRequired": "Enter a local folder to mount", - "mountAction": "Mount read-only", + "mountSourceRequired": "Choose a local folder to mount", + "mountAction": "Mount folder", "mountSaved": "Folder mounted (read-only)", "mountFailed": "Could not mount the folder", "mountDir": "Folder", @@ -463,8 +480,8 @@ "distillDest": "Choose a new folder for parse results (not the mounted folder)", "distillDone": "Copied {{count}} files to {{path}}", "distillFailed": "Could not copy parse results", - "cloudMountTitle": "Cloud knowledge (ima and similar)", - "cloudMountHint": "Attach a cloud corpus such as ima. FreeOS does not download or parse those files locally. Distillation writes a pointer into a new folder you choose, for later agent strengthening.", + "cloudMountTitle": "Cloud knowledge mount", + "cloudMountHint": "Attach a cloud knowledge base such as ima. Files are not parsed locally. Distillation writes a pointer into a new folder you choose, for later assistant strengthening.", "cloudUrl": "Cloud knowledge URL", "cloudUrlRequired": "Enter a cloud knowledge URL", "cloudProvider": "Provider (ima…)", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 6b4a2101..6d093a9c 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -416,15 +416,32 @@ }, "knowledgeBases": { "title": "知识库", - "subtitle": "上传文档建立索引,在对话中检索引用;可按需共享给同实例用户。", + "subtitle": "先挂接本地文件夹或云知识库,再按需做清洗、标注和蒸馏。", "featureTitle": "启用知识库", "settingsTitle": "知识库设置", - "settingsLead": "实例级开关。启用后需配置向量模型;关闭后全实例不可使用知识库。", - "settingsOpen": "启用知识库", + "settingsFoundation": "基础设置", + "settingsFoundationLead": "这些是后续清洗、标注、蒸馏用的基础设置。先挂接资料即可开始,不必先改这里。", + "settingsTabVector": "向量模型", + "settingsTabImage": "图片", + "settingsTabOcr": "扫描件 OCR", + "settingsLead": "高级开关。桌面默认已打开。关掉后,同事都无法再使用知识库。", + "settingsOpen": "知识库功能", + "homeTitle": "先把资料挂进来", + "homeDesc": "选电脑上的文件夹,或挂接 ima 等云知识库。原来的文件夹只读,不会被改动;解析结果会存到你另选的新文件夹。同事可以在对话里查阅。", + "homeAdvancedHint": "向量模型、图片和扫描件 OCR 留给后续清洗、标注、蒸馏。", + "defaultLocalName": "本地资料", + "defaultCloudName": "云知识库", + "pickFolder": "选择文件夹", + "pickFolderUnavailable": "当前窗口无法弹出文件夹选择,请直接填写路径。", + "imageSettingsTitle": "识别图片里的文字", + "imageSettingsDesc": "打开后,可以把图片里的字认出来,方便以后清洗和标注。", + "ocrFoundationHint": "打开后,扫描件 PDF 也能认成文字,方便以后蒸馏。", + "disabledTitle": "知识库功能已关闭", + "disabledDesc": "可以在基础设置里重新打开。打开后,先挂接本地文件夹或云知识库即可使用。", "manageModels": "管理模型", "unavailableTitle": "知识库尚未就绪", - "unavailableDescription": "请先在右上角设置中启用知识库,并配置可用的向量模型。", - "unavailableDescriptionNonAdmin": "知识库尚未启用或未配置向量模型,请联系管理员。", + "unavailableDescription": "请先在右上角基础设置中打开知识库。", + "unavailableDescriptionNonAdmin": "知识库尚未打开,请联系管理员。", "create": "创建知识库", "edit": "编辑知识库", "addToChat": "添加到会话", @@ -449,12 +466,12 @@ "enableGuideStepModel": "配置向量模型", "enableGuideStepModelDetail": "选择本地 ONNX(需先下载模型)或在线 Embedding 提供商,保存后即可使用。", "noDescription": "暂无描述", - "selectBase": "选择一个知识库以查看文档", - "mountTitle": "挂载本地目录", - "mountHint": "以只读方式挂载本地文件夹。FreeOS 不会删除或修改该目录中的文件。解析结果必须保存到你另外指定的新文件夹。", + "selectBase": "选一个知识库查看文档,或先挂接资料", + "mountTitle": "本地文件夹挂接", + "mountHint": "把电脑上的文件夹挂进来,只读,不会改原来的文件。解析或蒸馏结果必须存到你另选的新文件夹。", "mountSource": "本地文件夹路径", - "mountSourceRequired": "请输入要挂载的本地文件夹", - "mountAction": "只读挂载", + "mountSourceRequired": "请选择要挂接的本地文件夹", + "mountAction": "挂接文件夹", "mountSaved": "已只读挂载该文件夹", "mountFailed": "挂载失败", "mountDir": "文件夹", @@ -463,8 +480,8 @@ "distillDest": "请选择保存解析结果的新文件夹(不能是已挂载的目录)", "distillDone": "已复制 {{count}} 个文件到 {{path}}", "distillFailed": "无法复制解析结果", - "cloudMountTitle": "云知识库(ima 等)", - "cloudMountHint": "挂载 ima 等云端语料。FreeOS 不会在本地解析这些文件。蒸馏时只在你指定的新文件夹写入指针,供后续强化智能体。", + "cloudMountTitle": "云知识库挂接", + "cloudMountHint": "挂接 ima 等云知识库。不会在本地解析原文。蒸馏时只在你另选的新文件夹写入指针,供以后强化智能助手。", "cloudUrl": "云知识库地址", "cloudUrlRequired": "请输入云知识库地址", "cloudProvider": "来源(ima…)", diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx index 04949764..75b89513 100644 --- a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx @@ -3,20 +3,33 @@ import { Button, Input, Space, Typography } from "antd"; import { useTranslation } from "react-i18next"; import { knowledgeBasesApi } from "../../api/modules/knowledgeBases"; import { message } from "../../utils/antdMessage"; +import { + canPickKnowledgeFolder, + pickKnowledgeFolder, +} from "./pickKnowledgeFolder"; interface CloudMountPanelProps { - kbId: string; + kbId?: string; + ensureKb?: () => Promise; + onMounted?: () => void; + prominent?: boolean; } -export function CloudMountPanel({ kbId }: CloudMountPanelProps) { +export function CloudMountPanel({ + kbId, + ensureKb, + onMounted, + prominent = false, +}: CloudMountPanelProps) { const { t } = useTranslation(); const [url, setUrl] = useState(""); const [provider, setProvider] = useState("ima"); const [mounted, setMounted] = useState(false); const [busy, setBusy] = useState(false); + const canPick = canPickKnowledgeFolder(); - const load = async () => { - const next = await knowledgeBasesApi.getMount(kbId); + const load = async (id: string) => { + const next = await knowledgeBasesApi.getMount(id); if (next.kind === "cloud") { setMounted(next.mounted); setUrl(next.cloud_url || ""); @@ -25,9 +38,18 @@ export function CloudMountPanel({ kbId }: CloudMountPanelProps) { }; useEffect(() => { - void load().catch(() => undefined); + if (!kbId) return; + void load(kbId).catch(() => undefined); }, [kbId]); + const resolveKb = async () => { + if (kbId) return kbId; + if (!ensureKb) { + throw new Error(t("knowledgeBases.cloudMountFailed")); + } + return ensureKb(); + }; + const mount = async () => { if (!url.trim()) { message.error(t("knowledgeBases.cloudUrlRequired")); @@ -35,13 +57,15 @@ export function CloudMountPanel({ kbId }: CloudMountPanelProps) { } setBusy(true); try { - await knowledgeBasesApi.setMount(kbId, "", "", { + const id = await resolveKb(); + await knowledgeBasesApi.setMount(id, "", "", { kind: "cloud", cloud_url: url.trim(), cloud_provider: provider.trim() || "ima", }); message.success(t("knowledgeBases.cloudMountSaved")); - await load(); + await load(id); + onMounted?.(); } catch (err) { message.error( err instanceof Error @@ -54,11 +78,15 @@ export function CloudMountPanel({ kbId }: CloudMountPanelProps) { }; const distill = async () => { - const folder = window.prompt(t("knowledgeBases.cloudDistillDest")); + let folder = await pickKnowledgeFolder(); + if (!folder && !canPick) { + folder = window.prompt(t("knowledgeBases.cloudDistillDest")) || ""; + } if (!folder) return; setBusy(true); try { - const result = await knowledgeBasesApi.distillMount(kbId, folder); + const id = await resolveKb(); + const result = await knowledgeBasesApi.distillMount(id, folder); message.success( t("knowledgeBases.cloudDistillDone", { path: result.distill_path }), ); @@ -72,7 +100,7 @@ export function CloudMountPanel({ kbId }: CloudMountPanelProps) { }; return ( -
+
{t("knowledgeBases.cloudMountTitle")} @@ -90,7 +118,7 @@ export function CloudMountPanel({ kbId }: CloudMountPanelProps) { value={url} onChange={(event) => setUrl(event.target.value)} placeholder={t("knowledgeBases.cloudUrl")} - style={{ minWidth: 280 }} + style={{ minWidth: 240 }} /> +
+ ) : null} +
+ ); +} diff --git a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.test.tsx b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.test.tsx new file mode 100644 index 00000000..4769cedc --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.test.tsx @@ -0,0 +1,59 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; + +const pickKnowledgeFolder = vi.fn(); +const setMount = vi.fn(); +const getMount = vi.fn(); + +vi.mock("./pickKnowledgeFolder", () => ({ + pickKnowledgeFolder: (...args: unknown[]) => pickKnowledgeFolder(...args), + canPickKnowledgeFolder: () => true, +})); + +vi.mock("../../api/modules/knowledgeBases", () => ({ + knowledgeBasesApi: { + getMount: (...args: unknown[]) => getMount(...args), + setMount: (...args: unknown[]) => setMount(...args), + distillMount: vi.fn(), + }, +})); + +import { LocalMountPanel } from "./LocalMountPanel"; + +describe("", () => { + beforeEach(() => { + pickKnowledgeFolder.mockReset(); + setMount.mockReset(); + getMount.mockReset(); + getMount.mockResolvedValue({ + mounted: false, + source_path: "", + entries: [], + }); + setMount.mockResolvedValue({ + mounted: true, + source_path: "/docs", + entries: [], + }); + }); + + it("fills the path from the folder picker and mounts", async () => { + const user = userEvent.setup(); + pickKnowledgeFolder.mockResolvedValue("/Users/ada/Notes"); + const ensureKb = vi.fn().mockResolvedValue("kb-new"); + + render(); + + await user.click( + screen.getByRole("button", { name: "knowledgeBases.pickFolder" }), + ); + expect(pickKnowledgeFolder).toHaveBeenCalled(); + + await user.click( + screen.getByRole("button", { name: "knowledgeBases.mountAction" }), + ); + expect(ensureKb).toHaveBeenCalled(); + expect(setMount).toHaveBeenCalledWith("kb-new", "/Users/ada/Notes"); + }); +}); diff --git a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx index 50cef9ae..44d2b960 100644 --- a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx +++ b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx @@ -1,35 +1,67 @@ import { useEffect, useState } from "react"; import { Button, Input, List, Space, Tag, Typography } from "antd"; +import { FolderOpen } from "lucide-react"; import { useTranslation } from "react-i18next"; import { knowledgeBasesApi } from "../../api/modules/knowledgeBases"; import { message } from "../../utils/antdMessage"; +import { + canPickKnowledgeFolder, + pickKnowledgeFolder, +} from "./pickKnowledgeFolder"; interface LocalMountPanelProps { - kbId: string; + kbId?: string; + ensureKb?: () => Promise; + onMounted?: () => void; + prominent?: boolean; } -export function LocalMountPanel({ kbId }: LocalMountPanelProps) { +export function LocalMountPanel({ + kbId, + ensureKb, + onMounted, + prominent = false, +}: LocalMountPanelProps) { const { t } = useTranslation(); const [source, setSource] = useState(""); - const [dest, setDest] = useState(""); const [entries, setEntries] = useState< Array<{ path: string; name: string; is_dir: boolean; size: number }> >([]); const [mounted, setMounted] = useState(false); const [busy, setBusy] = useState(false); + const canPick = canPickKnowledgeFolder(); - const load = async () => { - const next = await knowledgeBasesApi.getMount(kbId); + const load = async (id: string) => { + const next = await knowledgeBasesApi.getMount(id); setMounted(next.mounted); setSource(next.source_path || ""); - setDest(next.distill_path || ""); setEntries(next.entries || []); }; useEffect(() => { - void load().catch(() => undefined); + if (!kbId) return; + void load(kbId).catch(() => undefined); }, [kbId]); + const resolveKb = async () => { + if (kbId) return kbId; + if (!ensureKb) { + throw new Error(t("knowledgeBases.mountFailed")); + } + return ensureKb(); + }; + + const pickSource = async () => { + const path = await pickKnowledgeFolder(); + if (path) { + setSource(path); + return; + } + if (!canPick) { + message.info(t("knowledgeBases.pickFolderUnavailable")); + } + }; + const mount = async () => { if (!source.trim()) { message.error(t("knowledgeBases.mountSourceRequired")); @@ -37,9 +69,11 @@ export function LocalMountPanel({ kbId }: LocalMountPanelProps) { } setBusy(true); try { - await knowledgeBasesApi.setMount(kbId, source.trim(), dest.trim()); + const id = await resolveKb(); + await knowledgeBasesApi.setMount(id, source.trim()); message.success(t("knowledgeBases.mountSaved")); - await load(); + await load(id); + onMounted?.(); } catch (err) { message.error( err instanceof Error ? err.message : t("knowledgeBases.mountFailed"), @@ -50,18 +84,22 @@ export function LocalMountPanel({ kbId }: LocalMountPanelProps) { }; const distill = async () => { - const folder = window.prompt(t("knowledgeBases.distillDest")); + let folder = await pickKnowledgeFolder(); + if (!folder && !canPick) { + folder = window.prompt(t("knowledgeBases.distillDest")) || ""; + } if (!folder) return; setBusy(true); try { - const result = await knowledgeBasesApi.distillMount(kbId, folder); + const id = await resolveKb(); + const result = await knowledgeBasesApi.distillMount(id, folder); message.success( t("knowledgeBases.distillDone", { count: result.copied, path: result.distill_path, }), ); - await load(); + await load(id); } catch (err) { message.error( err instanceof Error ? err.message : t("knowledgeBases.distillFailed"), @@ -72,7 +110,7 @@ export function LocalMountPanel({ kbId }: LocalMountPanelProps) { }; return ( -
+
{t("knowledgeBases.mountTitle")} {t("knowledgeBases.mountHint")} @@ -82,8 +120,11 @@ export function LocalMountPanel({ kbId }: LocalMountPanelProps) { value={source} onChange={(event) => setSource(event.target.value)} placeholder={t("knowledgeBases.mountSource")} - style={{ minWidth: 280 }} + style={{ minWidth: 240 }} /> + diff --git a/dashboard/src/pages/KnowledgeBases/index.module.less b/dashboard/src/pages/KnowledgeBases/index.module.less index 9a3f64a1..45bf1e1a 100644 --- a/dashboard/src/pages/KnowledgeBases/index.module.less +++ b/dashboard/src/pages/KnowledgeBases/index.module.less @@ -791,6 +791,78 @@ flex: 1; } +.mountHome { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + width: min(920px, 100%); + margin: 0 auto; + padding: 28px 24px 32px; +} + +.mountHomeTitle { + margin: 8px 0 0 !important; + text-align: center; +} + +.mountHomeLead { + margin: 0 auto 8px !important; + max-width: 640px; + text-align: center; + color: var(--fn-text-secondary); +} + +.mountHomeCards { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; + margin-top: 8px; +} + +.mountCard { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + padding: 16px; + border: 1px solid var(--fn-border-secondary); + border-radius: var(--fn-radius-lg); + background: var(--fn-bg-primary); +} + +.mountCardIcon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + margin-bottom: 4px; + border-radius: 10px; + background: color-mix(in srgb, var(--fn-color-brand) 12%, transparent); + color: var(--fn-color-brand); +} + +.mountHomeAdvanced { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 4px 8px; + margin-top: 12px; + text-align: center; +} + +.settingsTabs { + margin-top: 4px; +} + +.settingsAdvanced { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--fn-border-secondary); +} + .setupMascot { display: block; width: 120px; diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index eaa89e5e..eaf92f3c 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -26,6 +26,7 @@ import { Select, Spin, Switch, + Tabs, Tag, Tooltip, Typography, @@ -118,6 +119,7 @@ import { isKnowledgeMarkdownDocument, } from "../../utils/knowledgeDocPreview"; import { CloudMountPanel } from "./CloudMountPanel"; +import { KnowledgeMountHome } from "./KnowledgeMountHome"; import { LocalMountPanel } from "./LocalMountPanel"; import TextDocumentEditorModal, { type TextDocumentFormat, @@ -1850,8 +1852,32 @@ export default function KnowledgeBasesPage() { const showListPane = !isMobile || mobilePane === "list"; const showDetailPane = !isMobile || mobilePane === "detail"; const showListPanel = showListPane && (isMobile || !listPanelCollapsed); - const showEnableGuide = !loading && !usable; - const showEmptyGuide = !loading && usable && bases.length === 0; + const featureEnabled = Boolean(capability?.feature_enabled); + const showDisabled = !loading && !featureEnabled; + const showMountHome = !loading && featureEnabled && bases.length === 0; + + const ensureKb = async (name: string) => { + if (atBaseLimit) { + message.warning( + t("knowledgeBases.baseLimitReached", { + count: limits.max_bases_per_owner, + }), + ); + throw new Error( + t("knowledgeBases.baseLimitReached", { + count: limits.max_bases_per_owner, + }), + ); + } + const created = await knowledgeBasesApi.create({ + name, + default_open: true, + }); + await loadBases(); + await loadDetail(created.id); + if (isMobile) setMobilePane("detail"); + return created.id; + }; const emptyLayoutClassName = `${styles.emptyLayout}${ isMobile ? ` ${styles.emptyLayoutMobile}` : "" }`; @@ -1871,7 +1897,7 @@ export default function KnowledgeBasesPage() { actions={ canConfigureKb ? ( ) : undefined } @@ -1882,7 +1908,7 @@ export default function KnowledgeBasesPage() {
- ) : showEnableGuide ? ( + ) : showDisabled ? (
, } @@ -1927,34 +1936,13 @@ export default function KnowledgeBasesPage() { } />
- ) : showEmptyGuide ? ( + ) : showMountHome ? (
- , - disabled: atBaseLimit, - }} + void refresh()} />
) : ( @@ -3080,7 +3068,7 @@ export default function KnowledgeBasesPage() { } > -
-
-
- - {t("knowledgeBases.settingsOpen")} - - - {t("knowledgeBases.settingsLead")} - -
- -
-
- - {featureEnabledDraft ? ( - + + {t("knowledgeBases.settingsFoundationLead")} + + +
{t("knowledgeBases.selectModel")} @@ -3338,7 +3317,49 @@ export default function KnowledgeBasesPage() { navigate("/admin/models")}> {t("knowledgeBases.manageModels")} -
+
+ ), + }, + { + key: "image", + label: t("knowledgeBases.settingsTabImage"), + children: ( +
+ + {t("knowledgeBases.imageSettingsDesc")} + +
+
+ + {t("knowledgeBases.imageSettingsTitle")} + + + {t("knowledgeBases.ocrDescription")} + +
+ +
+
+ ), + }, + { + key: "ocr", + label: t("knowledgeBases.settingsTabOcr"), + children: ( +
+ + {t("knowledgeBases.ocrFoundationHint")} +
@@ -3427,9 +3448,28 @@ export default function KnowledgeBasesPage() {
) : null}
+ ), + }, + ]} + /> + +
+
+
+ + {t("knowledgeBases.settingsOpen")} + + + {t("knowledgeBases.settingsLead")} +
- - ) : null} + +
+
({ + pickDesktopFolder: (...args: unknown[]) => pickDesktopFolder(...args), + canPickDesktopFolder: (...args: unknown[]) => canPickDesktopFolder(...args), +})); + +import { + canPickKnowledgeFolder, + pickKnowledgeFolder, +} from "./pickKnowledgeFolder"; + +describe("pickKnowledgeFolder", () => { + it("delegates to the desktop folder picker", async () => { + pickDesktopFolder.mockResolvedValue("/tmp/docs"); + canPickDesktopFolder.mockReturnValue(true); + + await expect(pickKnowledgeFolder()).resolves.toBe("/tmp/docs"); + expect(canPickKnowledgeFolder()).toBe(true); + }); +}); diff --git a/dashboard/src/pages/KnowledgeBases/pickKnowledgeFolder.ts b/dashboard/src/pages/KnowledgeBases/pickKnowledgeFolder.ts new file mode 100644 index 00000000..aa41470b --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/pickKnowledgeFolder.ts @@ -0,0 +1,13 @@ +import { + canPickDesktopFolder, + pickDesktopFolder, +} from "../../utils/desktopFolder"; + +/** Native folder picker on desktop; `null` when cancelled or unavailable. */ +export async function pickKnowledgeFolder(): Promise { + return pickDesktopFolder(); +} + +export function canPickKnowledgeFolder(): boolean { + return canPickDesktopFolder(); +} diff --git a/src/octop/api/routers/knowledge_bases.py b/src/octop/api/routers/knowledge_bases.py index 0d68db51..343a338c 100644 --- a/src/octop/api/routers/knowledge_bases.py +++ b/src/octop/api/routers/knowledge_bases.py @@ -37,6 +37,7 @@ from octop.infra.errors import ErrorCode, OctopError from octop.infra.knowledge.files import document_path from octop.infra.knowledge.gate import ( + assert_knowledge_enabled, assert_knowledge_usable, get_capability, set_feature_enabled, @@ -266,6 +267,16 @@ def _enable_onnx_service(server: OctopServer, model: str) -> None: ) +def _require_enabled(server: OctopServer, request: Request) -> None: + assert server.services is not None + try: + assert_knowledge_enabled(server.services.settings_repo.get, server.services.provider_repo) + except (RuntimeError, ValueError) as exc: + raise _map_knowledge_error( + exc, locale=resolve_request_locale(request), server=server + ) from exc + + def _require_usable(server: OctopServer, request: Request) -> None: assert server.services is not None try: @@ -520,7 +531,7 @@ async def create_base( ) -> dict[str, Any]: locale = resolve_request_locale(request) try: - _require_usable(server, request) + _require_enabled(server, request) base = _knowledge_service(server).create_base( owner_user_id=user.id, name=body.name.strip(), diff --git a/src/octop/infra/knowledge/gate.py b/src/octop/infra/knowledge/gate.py index 05f7c0bd..17095137 100644 --- a/src/octop/infra/knowledge/gate.py +++ b/src/octop/infra/knowledge/gate.py @@ -14,6 +14,7 @@ require_embedding_prerequisites_for_model, ) from octop.infra.knowledge.params import get_advanced_settings +from octop.infra.users.local_session import is_desktop_process _FEATURE_ENABLED_KEY = "knowledge_bases_enabled" _EMBEDDING_BACKEND_KEY = "knowledge_embedding_backend" @@ -75,7 +76,8 @@ def get_capability(settings_get: SettingsGet, provider_repo: Any = None) -> dict if backend == "remote" else embedding_prerequisites_ok_for_model(selected_model) ) - feature_enabled = _as_bool(settings_get(_FEATURE_ENABLED_KEY)) + stored = settings_get(_FEATURE_ENABLED_KEY) + feature_enabled = is_desktop_process() if stored is None else _as_bool(stored) return { "feature_enabled": feature_enabled, "selected_model": selected_model, @@ -93,6 +95,26 @@ def get_capability(settings_get: SettingsGet, provider_repo: Any = None) -> dict } +def apply_desktop_knowledge_default( + settings_get: SettingsGet, + settings_set: SettingsSet, +) -> bool: + """Persist the desktop first-run default once. Does not overwrite an explicit choice.""" + if not is_desktop_process(): + return False + if settings_get(_FEATURE_ENABLED_KEY) is not None: + return False + settings_set(_FEATURE_ENABLED_KEY, "true") + return True + + +def assert_knowledge_enabled(settings_get: SettingsGet, provider_repo: Any = None) -> None: + """Raise when the instance switch is off. Embedding readiness is not required.""" + capability = get_capability(settings_get, provider_repo) + if not capability["feature_enabled"]: + raise RuntimeError("knowledge feature is disabled") + + def set_feature_enabled( settings_get: SettingsGet, settings_set: SettingsSet, diff --git a/src/octop/infra/knowledge/service.py b/src/octop/infra/knowledge/service.py index 1c153e1f..4feb1202 100644 --- a/src/octop/infra/knowledge/service.py +++ b/src/octop/infra/knowledge/service.py @@ -16,7 +16,7 @@ document_path, write_document, ) -from octop.infra.knowledge.gate import assert_knowledge_usable +from octop.infra.knowledge.gate import assert_knowledge_enabled, assert_knowledge_usable from octop.infra.knowledge.index import KnowledgeIndex from octop.infra.knowledge.ocr import ( OCR_IMAGE_SUFFIXES, @@ -103,7 +103,7 @@ def create_base( icon_name: str = "", max_documents: int = MAX_DOCS_PER_KB, ) -> KnowledgeBaseRow: - assert_knowledge_usable( + assert_knowledge_enabled( self._services.settings_repo.get, getattr(self._services, "provider_repo", None) ) if max_documents < 0 or max_documents > MAX_KB_MAX_DOCUMENTS: diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index 3c3bfc39..9094dbd3 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -338,6 +338,7 @@ async def start(self) -> None: db = open_database(config, self.paths) run_migrations(db) self.services = build_shared_services(db=db, paths=self.paths, config=config) + self._apply_desktop_knowledge_defaults() self._ensure_jwt_secret() await self._boot_runtime(config) self._started = True @@ -365,6 +366,7 @@ async def bind_control_plane(self) -> None: db.close() raise self.services = build_shared_services(db=db, paths=self.paths, config=config) + self._apply_desktop_knowledge_defaults() self._ensure_jwt_secret() await self._boot_runtime(config) logger.info( @@ -559,6 +561,18 @@ async def stop(self) -> None: # ----- helpers ----- + def _apply_desktop_knowledge_defaults(self) -> None: + """Turn on knowledge bases for the desktop / FreeOS first-run path.""" + if self.services is None: + return + from octop.infra.knowledge.gate import apply_desktop_knowledge_default + + if apply_desktop_knowledge_default( + self.services.settings_repo.get, + self.services.settings_repo.set, + ): + logger.info("knowledge bases enabled by default (desktop first-run)") + def _apply_desktop_org_defaults(self) -> None: """Turn on the organization module for the desktop first-run path.""" from octop.modules.org_os.service import org_module_from_paths diff --git a/tests/unit/api/test_knowledge_bases.py b/tests/unit/api/test_knowledge_bases.py index 9fca06dc..ab4cc4cf 100644 --- a/tests/unit/api/test_knowledge_bases.py +++ b/tests/unit/api/test_knowledge_bases.py @@ -158,7 +158,7 @@ async def test_create_base_maps_disabled_feature_error(monkeypatch: pytest.Monke def fail(*_args: object, **_kwargs: object) -> None: raise RuntimeError("knowledge feature is disabled") - monkeypatch.setattr(knowledge_bases, "assert_knowledge_usable", fail) + monkeypatch.setattr(knowledge_bases, "assert_knowledge_enabled", fail) with pytest.raises(OctopError) as raised: await knowledge_bases.create_base( @@ -183,7 +183,7 @@ async def test_create_base_uses_selected_model_when_usable( ) server = SimpleNamespace(services=_services()) user = SimpleNamespace(id=1, is_admin=False) - monkeypatch.setattr(knowledge_bases, "assert_knowledge_usable", lambda *_a, **_k: None) + monkeypatch.setattr(knowledge_bases, "assert_knowledge_enabled", lambda *_a, **_k: None) monkeypatch.setattr(knowledge_bases, "_knowledge_service", lambda _server: service) response = await knowledge_bases.create_base( diff --git a/tests/unit/knowledge/test_gate.py b/tests/unit/knowledge/test_gate.py index 55803137..8a11f8bc 100644 --- a/tests/unit/knowledge/test_gate.py +++ b/tests/unit/knowledge/test_gate.py @@ -10,6 +10,7 @@ def test_capability_is_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(gate, "local_embedding_deps_available", lambda: True) monkeypatch.setattr(gate, "is_model_downloaded", lambda _model: True) + monkeypatch.setattr(gate, "is_desktop_process", lambda: False) capability = gate.get_capability(lambda _key: None) @@ -25,6 +26,52 @@ def test_capability_is_disabled_by_default(monkeypatch: pytest.MonkeyPatch) -> N } +def test_capability_defaults_enabled_on_desktop(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gate, "local_embedding_deps_available", lambda: True) + monkeypatch.setattr(gate, "is_model_downloaded", lambda _model: True) + monkeypatch.setattr(gate, "is_desktop_process", lambda: True) + + capability = gate.get_capability(lambda _key: None) + + assert capability["feature_enabled"] is True + assert capability["usable"] is False + + +def test_desktop_explicit_disable_is_respected(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gate, "is_desktop_process", lambda: True) + + capability = gate.get_capability(lambda key: "false" if key == "knowledge_bases_enabled" else None) + + assert capability["feature_enabled"] is False + + +def test_apply_desktop_knowledge_default_writes_once(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(gate, "is_desktop_process", lambda: True) + values: dict[str, str] = {} + + assert gate.apply_desktop_knowledge_default(values.get, values.__setitem__) is True + assert values["knowledge_bases_enabled"] == "true" + assert gate.apply_desktop_knowledge_default(values.get, values.__setitem__) is False + + +def test_assert_knowledge_enabled_allows_missing_embeddings_on_desktop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(gate, "is_desktop_process", lambda: True) + monkeypatch.setattr(gate, "embedding_prerequisites_ok_for_model", lambda _model: False) + gate.assert_knowledge_enabled(lambda _key: None) + + +def test_apply_desktop_knowledge_default_skips_non_desktop( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(gate, "is_desktop_process", lambda: False) + values: dict[str, str] = {} + + assert gate.apply_desktop_knowledge_default(values.get, values.__setitem__) is False + assert values == {} + + def test_enabled_capability_uses_selected_model_not_onnx_service( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -77,6 +124,7 @@ def test_enabling_requires_model_and_persists_verified_selection( def test_assert_knowledge_usable_distinguishes_disabled_from_prerequisites( monkeypatch: pytest.MonkeyPatch, ) -> None: + monkeypatch.setattr(gate, "is_desktop_process", lambda: False) monkeypatch.setattr(gate, "embedding_prerequisites_ok_for_model", lambda _model: False) with pytest.raises(RuntimeError, match="disabled"): gate.assert_knowledge_usable(lambda _key: None) diff --git a/tests/unit/knowledge/test_params.py b/tests/unit/knowledge/test_params.py index cef6509c..41825c6c 100644 --- a/tests/unit/knowledge/test_params.py +++ b/tests/unit/knowledge/test_params.py @@ -45,6 +45,7 @@ def test_capability_includes_advanced_defaults(monkeypatch: pytest.MonkeyPatch) monkeypatch.setattr(gate, "local_embedding_deps_available", lambda: True) monkeypatch.setattr(gate, "is_model_downloaded", lambda _model: False) + monkeypatch.setattr(gate, "is_desktop_process", lambda: False) capability = gate.get_capability(lambda _key: None) diff --git a/tests/unit/knowledge/test_service_acl.py b/tests/unit/knowledge/test_service_acl.py index 7c6bdcc0..57a4658d 100644 --- a/tests/unit/knowledge/test_service_acl.py +++ b/tests/unit/knowledge/test_service_acl.py @@ -29,9 +29,32 @@ def service(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> KnowledgeService paths=PathLayout.from_env(), ) monkeypatch.setattr(service_module, "assert_knowledge_usable", lambda *_args: None) + monkeypatch.setattr(service_module, "assert_knowledge_enabled", lambda *_args: None) return KnowledgeService(services) +def test_create_base_allowed_when_enabled_without_embeddings( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv("OCTOP_HOME", str(tmp_path / "home")) + pool = SqlitePool(tmp_path / "octop.db") + run_migrations(pool) + settings = SettingsRepo(pool) + settings.set("knowledge_bases_enabled", "true") + services = SimpleNamespace( + knowledge_repo=KnowledgeRepo(pool), + settings_repo=settings, + user_repo=UserRepo(pool), + paths=PathLayout.from_env(), + provider_repo=None, + ) + monkeypatch.setattr(service_module, "assert_knowledge_usable", lambda *_args: None) + svc = KnowledgeService(services) + owner = services.user_repo.create(username="owner", password_hash="h", role="user") + kb = svc.create_base(owner_user_id=owner, name="Mounted") + assert kb.name == "Mounted" + + def test_create_base_allows_shared_with_default_open(service: KnowledgeService) -> None: users = service._services.user_repo owner = users.create(username="owner", password_hash="h", role="user") diff --git a/tests/unit/knowledge/test_text_documents.py b/tests/unit/knowledge/test_text_documents.py index 5fa8feac..e44785d8 100644 --- a/tests/unit/knowledge/test_text_documents.py +++ b/tests/unit/knowledge/test_text_documents.py @@ -46,6 +46,10 @@ def test_create_and_update_text_document( "octop.infra.knowledge.service.assert_knowledge_usable", lambda *_a, **_k: None, ) + monkeypatch.setattr( + "octop.infra.knowledge.service.assert_knowledge_enabled", + lambda *_a, **_k: None, + ) svc = KnowledgeService(services) base = svc.create_base(owner_user_id=services.owner_id, name="Docs") created = svc.create_text_document( @@ -80,6 +84,10 @@ def test_upload_spreadsheet_documents( "octop.infra.knowledge.service.assert_knowledge_usable", lambda *_a, **_k: None, ) + monkeypatch.setattr( + "octop.infra.knowledge.service.assert_knowledge_enabled", + lambda *_a, **_k: None, + ) svc = KnowledgeService(services) base = svc.create_base(owner_user_id=services.owner_id, name="Sheets") xlsx = svc.upload_document( @@ -141,6 +149,10 @@ def test_image_upload_requires_ocr( "octop.infra.knowledge.service.assert_knowledge_usable", lambda *_a, **_k: None, ) + monkeypatch.setattr( + "octop.infra.knowledge.service.assert_knowledge_enabled", + lambda *_a, **_k: None, + ) svc = KnowledgeService(services) base = svc.create_base(owner_user_id=services.owner_id, name="Images") From a0af6bb6fb931ac9cea43ff233c0a64b4fbf763c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 05:11:56 +0000 Subject: [PATCH 2/2] Format the knowledge-base first-open changes and note them in the changelog. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + .../KnowledgeMountHome.test.tsx | 8 +- .../KnowledgeBases/KnowledgeMountHome.tsx | 8 +- .../pages/KnowledgeBases/LocalMountPanel.tsx | 5 +- dashboard/src/pages/KnowledgeBases/index.tsx | 581 +++++++++--------- tests/unit/knowledge/test_gate.py | 4 +- 6 files changed, 313 insertions(+), 294 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 34098df2..f766ba5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ ### 变更 +- 知识库桌面 / 首次打开默认可用:打开「知识库」先看到本地文件夹挂接与云知识库挂接(ima 等),不必先找开关。向量模型、图片识别、扫描件 OCR 仍在「基础设置」里,留给后续清洗、标注、蒸馏。原文件夹只读;解析结果仍写入用户另选的新文件夹。 - 侧栏「组织 OS」更名为「组织」(英文 Organization)。 diff --git a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx index 96350584..c9818c73 100644 --- a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx +++ b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.test.tsx @@ -23,12 +23,8 @@ describe("", () => { />, ); - expect( - screen.getByText("knowledgeBases.homeTitle"), - ).toBeInTheDocument(); - expect( - screen.getByText("knowledgeBases.mountTitle"), - ).toBeInTheDocument(); + expect(screen.getByText("knowledgeBases.homeTitle")).toBeInTheDocument(); + expect(screen.getByText("knowledgeBases.mountTitle")).toBeInTheDocument(); expect( screen.getByText("knowledgeBases.cloudMountTitle"), ).toBeInTheDocument(); diff --git a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx index 895c1ba2..af0bf1b3 100644 --- a/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx +++ b/dashboard/src/pages/KnowledgeBases/KnowledgeMountHome.tsx @@ -38,9 +38,7 @@ export function KnowledgeMountHome({
- ensureKb(t("knowledgeBases.defaultLocalName")) - } + ensureKb={() => ensureKb(t("knowledgeBases.defaultLocalName"))} onMounted={onMounted} /> @@ -50,9 +48,7 @@ export function KnowledgeMountHome({
- ensureKb(t("knowledgeBases.defaultCloudName")) - } + ensureKb={() => ensureKb(t("knowledgeBases.defaultCloudName"))} onMounted={onMounted} /> diff --git a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx index 44d2b960..57ce90ec 100644 --- a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx +++ b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx @@ -122,7 +122,10 @@ export function LocalMountPanel({ placeholder={t("knowledgeBases.mountSource")} style={{ minWidth: 240 }} /> - + ); + })} + {featureProviderId && + (remoteProviders.find( + (provider) => + provider.provider_id === featureProviderId, + )?.models.length ?? 0) === 0 ? ( + + {t("knowledgeBases.noModels")} + + ) : null} - {model.downloaded ? null : ( + + ) : ( +
+ {catalog.map((model) => { + const selected = featureModel === model.id; + const size = formatSizeGb(model.size_gb); + const downloading = + onnxDownloading && + downloadProgressModel === model.id; + return ( +
setFeatureModel(model.id)} + onKeyDown={(event) => { + if ( + event.key === "Enter" || + event.key === " " + ) { + event.preventDefault(); + setFeatureModel(model.id); + } + }} + role="button" + tabIndex={0} + > +
+ + {model.name} + + + {model.recommended + ? t("knowledgeBases.recommended") + : null} + {model.recommended ? " · " : null} + {size + ? t("knowledgeBases.approxSize", { size }) + : t("knowledgeBases.sizeUnknown")} + {model.downloaded + ? null + : ` · ${t("knowledgeBases.notDownloaded")}`} + +
+ {model.downloaded ? null : ( + + )} +
+ ); + })} + {catalog.length === 0 ? ( + + {t("knowledgeBases.noModels")} + + ) : null} + {catalog.length > 0 && !onnxExpanded ? ( + + ) : null} +
+ )} + {featureBackend === "onnx" && featureModel ? ( +
+ + model.id === featureModel) + ?.downloaded, + )} + okText={t("knowledgeBases.checkDownloaded")} + failText={t("knowledgeBases.notDownloaded")} + /> +
+ + {t("knowledgeBases.checkEncode")} + + {onnxProbe ? ( + + {onnxProbe.ok + ? t("knowledgeBases.probeOk", { + dim: onnxProbe.dim ?? "?", + ms: Math.round(onnxProbe.latency_ms ?? 0), + }) + : onnxProbe.error ?? + t("knowledgeBases.probeFailed")} + + ) : ( + + {t("knowledgeBases.probeIdle")} + + )} - )} +
- ); - })} - {catalog.length === 0 ? ( + ) : null} - {t("knowledgeBases.noModels")} + {t("knowledgeBases.enableDescription")} - ) : null} - {catalog.length > 0 && !onnxExpanded ? ( - - ) : null} - - )} - {featureBackend === "onnx" && featureModel ? ( -
- - model.id === featureModel) - ?.downloaded, - )} - okText={t("knowledgeBases.checkDownloaded")} - failText={t("knowledgeBases.notDownloaded")} - /> -
- - {t("knowledgeBases.checkEncode")} - - {onnxProbe ? ( - - {onnxProbe.ok - ? t("knowledgeBases.probeOk", { - dim: onnxProbe.dim ?? "?", - ms: Math.round(onnxProbe.latency_ms ?? 0), - }) - : onnxProbe.error ?? t("knowledgeBases.probeFailed")} - - ) : ( - - {t("knowledgeBases.probeIdle")} - - )} - + navigate("/admin/models")}> + {t("knowledgeBases.manageModels")} +
-
- ) : null} - - {t("knowledgeBases.enableDescription")} - - navigate("/admin/models")}> - {t("knowledgeBases.manageModels")} - - ), }, { @@ -3353,101 +3366,109 @@ export default function KnowledgeBasesPage() { key: "ocr", label: t("knowledgeBases.settingsTabOcr"), children: ( -
- - {t("knowledgeBases.ocrFoundationHint")} - -
-
- - {t("knowledgeBases.ocrTitle")} - - - {t("knowledgeBases.ocrDescription")} - -
- -
- {ocrEnabledDraft ? ( -
- { - const backend = event.target.value as "onnx" | "remote"; - setOcrBackend(backend); - setOcrModel( - backend === "onnx" ? "rapidocr" : undefined, - ); - setOcrProviderId(undefined); - }} +
+ - {t("knowledgeBases.ocrLocal")} - - {t("knowledgeBases.ocrRemote")} - - - {ocrBackend === "onnx" ? ( -
-
- - RapidOCR (ONNX) - - - {t("knowledgeBases.ocrLocalHint")} - -
+ {t("knowledgeBases.ocrFoundationHint")} + +
+
+ + {t("knowledgeBases.ocrTitle")} + + + {t("knowledgeBases.ocrDescription")} +
- ) : ( - <> - - - provider.provider_id === ocrProviderId, - )?.models ?? [] - ).map((model) => ({ - value: model.id, - label: model.name, - }))} - disabled={!ocrProviderId} - notFoundContent={t("knowledgeBases.ocrNoModels")} - /> - - )} + > + + {t("knowledgeBases.ocrLocal")} + + + {t("knowledgeBases.ocrRemote")} + + + {ocrBackend === "onnx" ? ( +
+
+ + RapidOCR (ONNX) + + + {t("knowledgeBases.ocrLocalHint")} + +
+
+ ) : ( + <> + + + provider.provider_id === ocrProviderId, + )?.models ?? [] + ).map((model) => ({ + value: model.id, + label: model.name, + }))} + disabled={!ocrProviderId} + notFoundContent={t("knowledgeBases.ocrNoModels")} + /> + + )} +
+ ) : null}
- ) : null} -
), }, ]} diff --git a/tests/unit/knowledge/test_gate.py b/tests/unit/knowledge/test_gate.py index 8a11f8bc..31285b98 100644 --- a/tests/unit/knowledge/test_gate.py +++ b/tests/unit/knowledge/test_gate.py @@ -40,7 +40,9 @@ def test_capability_defaults_enabled_on_desktop(monkeypatch: pytest.MonkeyPatch) def test_desktop_explicit_disable_is_respected(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(gate, "is_desktop_process", lambda: True) - capability = gate.get_capability(lambda key: "false" if key == "knowledge_bases_enabled" else None) + capability = gate.get_capability( + lambda key: "false" if key == "knowledge_bases_enabled" else None + ) assert capability["feature_enabled"] is False