diff --git a/CHANGELOG.md b/CHANGELOG.md index 21187e89..11eb6ef8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- Organization Phase 3(知识切片):宿主内 Knowledge。Dashboard `/organization/knowledge` 使用共享 `dashboard/src/org-ui` 的 `KnowledgePage`。列表/详情/笔记走 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`(Octop `knowledge_bases` / `knowledge_documents`),与 Chat 检索同一套行。不克隆 openXYOS sidecar notes/files DB。上传、文件夹、嵌入模型仍在 `/knowledge-bases`。`freeos org export-standalone` 同时列出 Knowledge。未迁:Chat、边车知识库、其余 Open-12。 - Organization Phase 3(技能切片):宿主内 Skills。Dashboard `/organization/skills` 使用共享 `dashboard/src/org-ui` 的 `SkillsPage`。列表/详情走 `GET /api/org-module/skills` 与 `GET /api/org-module/skills/{slug}`,生成/发布仍是已有 `POST …/generate|publish`(`{FREEOS_HOME}/org-skills`,`skill_bridge`)。宿主 skill packages 只读列出,编辑仍在「个性化 → 技能包」。这不是第二套技能运行时。`freeos org export-standalone` 同时列出 Skills。未迁:Chat、边车市场/插件中心、其余 Open-12。 - Organization Phase 3(员工目录切片):宿主内 Employees。Dashboard `/organization/employees` 与 `/organization/employees/:id` 使用共享 `dashboard/src/org-ui` 的 `EmployeesPage` / `EmployeeDetailPage`。目录 list/detail/create/update 走 `/api/org-module/org/employees*`,与组织架构共用 `{FREEOS_HOME}/org/org_chart.sqlite`,不另开第二套员工库。lifecycle 同事仍是 `GET /api/org-module/employees`。`freeos org export-standalone` 同时列出 Announcements、Org chart、Employees。未迁:Chat、人才市场/备选/离职清算、绩效、汇报线、技能绑定、头像、其余 Open-12。 - Organization Phase 3(组织架构切片):宿主内 org chart。Dashboard `/organization/org` 使用共享 `dashboard/src/org-ui` 的 `OrgChartPage`;树与 CRUD 走 `/api/org-module/org`(`{FREEOS_HOME}/org/org_chart.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 同时列出 Announcements 与 Org chart。未迁:Chat、汇报线/版本/导入导出图、其余 Open-12。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 977642eb..991abf9c 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -187,6 +187,7 @@ "navEmployees": "Employees", "navSkills": "Skills", "navGovernance": "Governance", + "navKnowledge": "Knowledge", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", "openAnnouncements": "Open announcements", @@ -201,7 +202,10 @@ "openSkills": "Open skills", "governanceTitle": "Governance", "governanceBody": "Pending approvals and the host audit log. High-risk tools stay blocked until a human decides — no sidecar or second port.", - "openGovernance": "Open governance" + "openGovernance": "Open governance", + "knowledgeTitle": "Knowledge", + "knowledgeBody": "Lists the same FreeOS knowledge bases Chat retrieves from. Notes become host documents — no sidecar knowledge DB.", + "openKnowledge": "Open knowledge" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index b89091da..2d8ff639 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -187,6 +187,7 @@ "navEmployees": "员工目录", "navSkills": "技能", "navGovernance": "治理", + "navKnowledge": "知识", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", "openAnnouncements": "打开通知公告", @@ -201,7 +202,10 @@ "openSkills": "打开技能", "governanceTitle": "治理", "governanceBody": "待人工复核与宿主审计日志。高风险工具在有人拍板前保持拦截,不需要边车或第二端口。", - "openGovernance": "打开治理" + "openGovernance": "打开治理", + "knowledgeTitle": "知识", + "knowledgeBody": "列出 Chat 检索用的同一套 FreeOS 知识库。笔记写入宿主文档,没有边车知识库。", + "openKnowledge": "打开知识" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 914a2859..d5434452 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -54,6 +54,15 @@ describe("createOrgApiClient", () => { host_packages: [{ id: "pkg-1", name: "Ops", skill_count: 2 }], }; } + if (path === "/org-module/knowledge") { + return { + host_route: "/knowledge-bases", + store: "host_knowledge_bases", + capability: { feature_enabled: true, usable: true }, + bases: [{ id: "kb-1", name: "Policies" }], + stats: { bases: 1, documents: 0, shared: 0, owned: 1 }, + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -69,6 +78,7 @@ describe("createOrgApiClient", () => { expect((await client.employees.list())[0]?.name).toBe("Ada"); expect((await client.governance.pauses()).pauses[0]?.pause_id).toBe("p1"); expect((await client.skills.list()).skills[0]?.slug).toBe("org-employees"); + expect((await client.knowledge.list()).bases[0]?.name).toBe("Policies"); expect(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 29910cb8..5f15b6cd 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -281,12 +281,97 @@ export interface OrgSkillsClient { }): Promise; } +export interface OrgKnowledgeCapability { + feature_enabled: boolean; + usable: boolean; + prerequisites_ok: boolean; + selected_model: string; + backend: string; +} + +export interface OrgKnowledgeBase { + id: string; + knowledge_base_id: string; + name: string; + description: string; + shared: boolean; + default_open: boolean; + icon_name: string; + document_count: number; + owner_user_id: number; + owned: boolean; + documents?: OrgKnowledgeDocument[]; +} + +export interface OrgKnowledgeDocument { + id: string; + document_id: string; + kb_id: string; + filename: string; + path: string; + content_type: string; + byte_size: number; + is_dir: boolean; + status: string; + chunk_count: number; + created_at: number; + kind: "note" | "file" | string; +} + +export interface OrgKnowledgePreview { + id: string; + document_id: string; + kb_id: string; + filename: string; + content_type: string; + text: string; + kind: "note" | "file" | string; +} + +export interface OrgKnowledgeStats { + bases: number; + documents: number; + shared: number; + owned: number; +} + +export interface OrgKnowledgeList { + host_route: string; + store: string; + capability: OrgKnowledgeCapability; + bases: OrgKnowledgeBase[]; + stats: OrgKnowledgeStats; +} + +export interface OrgKnowledgeWrite { + name: string; + description?: string; + shared?: boolean; +} + +export interface OrgKnowledgeNoteWrite { + title: string; + content?: string; +} + +export interface OrgKnowledgeClient { + list(): Promise; + get(kbId: string): Promise; + preview(kbId: string, docId: string): Promise; + createBase(body: OrgKnowledgeWrite): Promise; + createNote( + kbId: string, + body: OrgKnowledgeNoteWrite, + ): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; employees: OrgEmployeesClient; governance: OrgGovernanceClient; skills: OrgSkillsClient; + knowledge: OrgKnowledgeClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -306,11 +391,13 @@ export function createOrgApiClient(opts: { orgPrefix?: string; governancePrefix?: string; skillsPrefix?: string; + knowledgePrefix?: string; }): OrgApiClient { const prefix = opts.announcementsPrefix ?? "/org-module/announcements"; const orgPrefix = opts.orgPrefix ?? "/org-module/org"; const governancePrefix = opts.governancePrefix ?? "/org-module/governance"; const skillsPrefix = opts.skillsPrefix ?? "/org-module/skills"; + const knowledgePrefix = opts.knowledgePrefix ?? "/org-module/knowledge"; const fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -560,5 +647,47 @@ export function createOrgApiClient(opts: { }, }; - return { announcements, org, employees, governance, skills }; + const knowledge: OrgKnowledgeClient = { + async list() { + return fetchJson(knowledgePrefix); + }, + async get(kbId) { + return fetchJson( + `${knowledgePrefix}/${encodeURIComponent(kbId)}`, + ); + }, + async preview(kbId, docId) { + return fetchJson( + `${knowledgePrefix}/${encodeURIComponent( + kbId, + )}/documents/${encodeURIComponent(docId)}`, + ); + }, + async createBase(body) { + return fetchJson(knowledgePrefix, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + name: body.name, + description: body.description || "", + shared: Boolean(body.shared), + }), + }); + }, + async createNote(kbId, body) { + return fetchJson( + `${knowledgePrefix}/${encodeURIComponent(kbId)}/notes`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + title: body.title, + content: body.content || "", + }), + }, + ); + }, + }; + + return { announcements, org, employees, governance, skills, knowledge }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index ab2a27b5..b02cb759 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -5,6 +5,7 @@ export const SHARED_ORG_UI_MODULES = [ "employees", "skills", "governance", + "knowledge", ] as const; export type SharedOrgUiModule = (typeof SHARED_ORG_UI_MODULES)[number]; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index 2dc4f5ac..0502b26e 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -24,6 +24,15 @@ export type { OrgEmployeesClient, OrgEnvelope, OrgGovernanceClient, + OrgKnowledgeBase, + OrgKnowledgeCapability, + OrgKnowledgeClient, + OrgKnowledgeDocument, + OrgKnowledgeList, + OrgKnowledgeNoteWrite, + OrgKnowledgePreview, + OrgKnowledgeStats, + OrgKnowledgeWrite, OrgSkill, OrgSkillCatalogRow, OrgSkillGenerateResult, @@ -43,6 +52,8 @@ export { SkillsPage } from "./pages/skills/SkillsPage"; export type { SkillsPageProps } from "./pages/skills/SkillsPage"; export { GovernancePage } from "./pages/governance/GovernancePage"; export type { GovernancePageProps } from "./pages/governance/GovernancePage"; +export { KnowledgePage } from "./pages/knowledge/KnowledgePage"; +export type { KnowledgePageProps } from "./pages/knowledge/KnowledgePage"; export type { IdentityBridge, OrgFetcher, diff --git a/dashboard/src/org-ui/pages/knowledge/KnowledgePage.module.css b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.module.css new file mode 100644 index 00000000..7c1f7e09 --- /dev/null +++ b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.module.css @@ -0,0 +1,166 @@ +.page { + max-width: 1040px; + margin: 0 auto; + padding: 8px 4px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; +} + +.titleRow { + display: flex; + align-items: center; + gap: 10px; +} + +.title { + margin: 0; + font-size: 20px; + font-weight: 650; +} + +.subtitle { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + max-width: 640px; + line-height: 1.5; +} + +.stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); + gap: 10px; +} + +.stat { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 10px; + padding: 12px 14px; + background: var(--fn-bg-container, #fff); +} + +.statValue { + font-size: 22px; + font-weight: 650; + line-height: 1.2; +} + +.statLabel { + margin-top: 4px; + font-size: 12px; + color: var(--fn-text-secondary, #667085); +} + +.hint { + margin: 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; + line-height: 1.5; +} + +.filters { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 12px; +} + +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} + +.card { + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + padding: 14px; + background: var(--fn-bg-container, #fff); + cursor: pointer; + text-align: left; + width: 100%; +} + +.card:hover, +.cardSelected { + border-color: var(--fn-primary, #1677ff); +} + +.cardTitle { + margin: 0; + font-size: 15px; + font-weight: 600; +} + +.cardMeta { + margin: 4px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 13px; +} + +.cardBody { + margin: 10px 0 0; + color: var(--fn-text-secondary, #667085); + font-size: 12px; + line-height: 1.5; + min-height: 36px; +} + +.empty { + text-align: center; + padding: 48px 12px; + color: var(--fn-text-secondary, #667085); +} + +.tableWrap { + overflow-x: auto; + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 12px; + background: var(--fn-bg-container, #fff); +} + +.mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; +} + +.body { + margin: 0; + white-space: pre-wrap; + line-height: 1.6; + font-size: 12px; + max-height: 360px; + overflow: auto; + background: var(--fn-bg, #f9fafb); + border: 1px solid var(--fn-border, #e5e7eb); + border-radius: 8px; + padding: 12px; +} + +.row { + display: flex; + justify-content: space-between; + gap: 12px; + padding: 8px 0; + font-size: 14px; + border-bottom: 1px solid var(--fn-border, #f0f0f0); +} + +.row:last-child { + border-bottom: none; +} + +.muted { + color: var(--fn-text-secondary, #667085); +} diff --git a/dashboard/src/org-ui/pages/knowledge/KnowledgePage.test.tsx b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.test.tsx new file mode 100644 index 00000000..e8ce64e7 --- /dev/null +++ b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.test.tsx @@ -0,0 +1,185 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { KnowledgePage } from "./KnowledgePage"; +import type { OrgKnowledgeClient } from "../../api/createClient"; + +function mockClient(): OrgKnowledgeClient { + return { + list: vi.fn(async () => ({ + host_route: "/knowledge-bases", + store: "host_knowledge_bases", + capability: { + feature_enabled: true, + usable: true, + prerequisites_ok: true, + selected_model: "bge", + backend: "onnx", + }, + bases: [ + { + id: "kb-1", + knowledge_base_id: "kb-1", + name: "Policies", + description: "Host knowledge base", + shared: true, + default_open: false, + icon_name: "", + document_count: 1, + owner_user_id: 1, + owned: true, + }, + ], + stats: { bases: 1, documents: 1, shared: 1, owned: 1 }, + })), + get: vi.fn(async () => ({ + id: "kb-1", + knowledge_base_id: "kb-1", + name: "Policies", + description: "Host knowledge base", + shared: true, + default_open: false, + icon_name: "", + document_count: 1, + owner_user_id: 1, + owned: true, + documents: [ + { + id: "doc-1", + document_id: "doc-1", + kb_id: "kb-1", + filename: "handbook.md", + path: "handbook.md", + content_type: "text/markdown", + byte_size: 12, + is_dir: false, + status: "ready", + chunk_count: 1, + created_at: 1, + kind: "note", + }, + ], + })), + preview: vi.fn(async () => ({ + id: "doc-1", + document_id: "doc-1", + kb_id: "kb-1", + filename: "handbook.md", + content_type: "text/markdown", + text: "# Handbook", + kind: "note", + })), + createBase: vi.fn(async () => ({ + id: "kb-2", + knowledge_base_id: "kb-2", + name: "New base", + description: "", + shared: false, + default_open: false, + icon_name: "", + document_count: 0, + owner_user_id: 1, + owned: true, + })), + createNote: vi.fn(async () => ({ + id: "doc-2", + document_id: "doc-2", + kb_id: "kb-1", + filename: "note.md", + path: "note.md", + content_type: "text/markdown", + byte_size: 4, + is_dir: false, + status: "pending", + chunk_count: 0, + created_at: 2, + kind: "note", + })), + }; +} + +const adminSession = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +const memberSession = { + userId: 2, + displayName: "Mo", + role: "user", + isAdmin: false, +}; + +describe("KnowledgePage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-knowledge")).toBeInTheDocument(); + expect(screen.getByText("Knowledge")).toBeInTheDocument(); + expect(screen.getByText("Policies")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets a writer create a host knowledge base through the injected client", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-knowledge-create-base")); + fireEvent.change(screen.getByTestId("org-knowledge-base-name"), { + target: { value: "New base" }, + }); + fireEvent.click(screen.getByTestId("org-knowledge-save-base")); + fireEvent.click(await screen.findByTestId("org-knowledge-confirm-base")); + await waitFor(() => { + expect(client.createBase).toHaveBeenCalledWith({ + name: "New base", + description: "", + shared: false, + }); + }); + }); + + it("hides create actions without write access and still lists host bases", async () => { + const client = mockClient(); + render( + , + ); + expect(await screen.findByTestId("org-ui-knowledge")).toBeInTheDocument(); + expect(screen.queryByTestId("org-knowledge-create-base")).toBeNull(); + expect( + screen.getByText( + "Creating bases and notes requires the knowledge-bases permission.", + ), + ).toBeInTheDocument(); + expect(screen.getByText("Policies")).toBeInTheDocument(); + }); + + it("opens a document from the selected host knowledge base", async () => { + const client = mockClient(); + render( + , + ); + fireEvent.click(await screen.findByTestId("org-knowledge-card-kb-1")); + fireEvent.click(await screen.findByTestId("org-knowledge-doc-doc-1")); + expect( + await screen.findByTestId("org-knowledge-preview"), + ).toHaveTextContent("Handbook"); + expect(client.get).toHaveBeenCalledWith("kb-1"); + expect(client.preview).toHaveBeenCalledWith("kb-1", "doc-1"); + }); +}); diff --git a/dashboard/src/org-ui/pages/knowledge/KnowledgePage.tsx b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.tsx new file mode 100644 index 00000000..7f82d13b --- /dev/null +++ b/dashboard/src/org-ui/pages/knowledge/KnowledgePage.tsx @@ -0,0 +1,550 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { + Button, + Input, + Modal, + Space, + Switch, + Table, + Tabs, + Tag, + Typography, +} from "antd"; +import { BookOpen, RefreshCw, Search } from "lucide-react"; +import type { + OrgKnowledgeBase, + OrgKnowledgeClient, + OrgKnowledgeDocument, + OrgKnowledgePreview, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { knowledgeLabels } from "./labels"; +import styles from "./KnowledgePage.module.css"; + +export interface KnowledgePageProps { + client: OrgKnowledgeClient; + session: OrgSession; + locale: OrgLocale; + canWrite?: boolean; + onOpenHostKnowledge?: () => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +function formatBytes(value: number): string { + if (!Number.isFinite(value) || value <= 0) return "—"; + if (value < 1024) return `${value} B`; + if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)} KB`; + return `${(value / (1024 * 1024)).toFixed(1)} MB`; +} + +export function KnowledgePage({ + client, + session, + locale, + canWrite, + onOpenHostKnowledge, +}: KnowledgePageProps) { + const labels = knowledgeLabels(locale); + const writable = canWrite ?? session.isAdmin; + const [bases, setBases] = useState([]); + const [stats, setStats] = useState({ + bases: 0, + documents: 0, + shared: 0, + owned: 0, + }); + const [featureEnabled, setFeatureEnabled] = useState(true); + const [usable, setUsable] = useState(true); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(""); + const [selectedId, setSelectedId] = useState(""); + const [documents, setDocuments] = useState([]); + const [docsLoading, setDocsLoading] = useState(false); + const [preview, setPreview] = useState(null); + const [baseOpen, setBaseOpen] = useState(false); + const [noteOpen, setNoteOpen] = useState(false); + const [baseName, setBaseName] = useState(""); + const [baseDescription, setBaseDescription] = useState(""); + const [baseShared, setBaseShared] = useState(false); + const [noteTitle, setNoteTitle] = useState(""); + const [noteContent, setNoteContent] = useState(""); + const [activeTab, setActiveTab] = useState("bases"); + + const fetchList = useCallback(async () => { + setLoading(true); + setError(""); + try { + const payload = await client.list(); + setBases(payload.bases); + setStats(payload.stats); + setFeatureEnabled(payload.capability.feature_enabled); + setUsable(payload.capability.usable); + } catch { + setBases([]); + setStats({ bases: 0, documents: 0, shared: 0, owned: 0 }); + setError(labels.loadFailed); + } finally { + setLoading(false); + } + }, [client, labels.loadFailed]); + + useEffect(() => { + void fetchList(); + }, [fetchList]); + + const loadDocuments = useCallback( + async (kbId: string) => { + setDocsLoading(true); + try { + const detail = await client.get(kbId); + setDocuments(detail.documents || []); + } catch { + setDocuments([]); + setError(labels.loadFailed); + } finally { + setDocsLoading(false); + } + }, + [client, labels.loadFailed], + ); + + useEffect(() => { + if (!selectedId) { + setDocuments([]); + return; + } + void loadDocuments(selectedId); + }, [loadDocuments, selectedId]); + + const query = search.trim().toLowerCase(); + const visibleBases = useMemo( + () => + query + ? bases.filter((row) => { + const hay = + `${row.name} ${row.description} ${row.id}`.toLowerCase(); + return hay.includes(query); + }) + : bases, + [bases, query], + ); + const visibleDocs = useMemo( + () => + query + ? documents.filter((row) => { + const hay = + `${row.filename} ${row.path} ${row.kind} ${row.status}`.toLowerCase(); + return hay.includes(query); + }) + : documents, + [documents, query], + ); + + const selected = bases.find((row) => row.id === selectedId) || null; + + const createBase = async () => { + if (!baseName.trim()) return; + setBusy("base"); + setError(""); + setNotice(""); + try { + const created = await client.createBase({ + name: baseName.trim(), + description: baseDescription.trim(), + shared: baseShared, + }); + setNotice(labels.createBaseDone); + setBaseOpen(false); + setBaseName(""); + setBaseDescription(""); + setBaseShared(false); + await fetchList(); + setSelectedId(created.id); + } catch { + setError(labels.createBaseFailed); + } finally { + setBusy(""); + } + }; + + const createNote = async () => { + if (!selectedId || !noteTitle.trim()) return; + setBusy("note"); + setError(""); + setNotice(""); + try { + await client.createNote(selectedId, { + title: noteTitle.trim(), + content: noteContent, + }); + setNotice(labels.createNoteDone); + setNoteOpen(false); + setNoteTitle(""); + setNoteContent(""); + await fetchList(); + await loadDocuments(selectedId); + } catch { + setError(labels.createNoteFailed); + } finally { + setBusy(""); + } + }; + + const openPreview = async (doc: OrgKnowledgeDocument) => { + try { + setPreview(await client.preview(doc.kb_id, doc.id)); + } catch { + setError(labels.previewFailed); + } + }; + + const askCreateBase = () => { + Modal.confirm({ + title: labels.confirmCreateBase, + okText: labels.createBase, + okButtonProps: { "data-testid": "org-knowledge-confirm-base" }, + onOk: () => createBase(), + }); + }; + + const askCreateNote = () => { + Modal.confirm({ + title: labels.confirmCreateNote, + okText: labels.createNote, + okButtonProps: { "data-testid": "org-knowledge-confirm-note" }, + onOk: () => createNote(), + }); + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ + {writable ? ( + + ) : null} + + +
+ + {error ? {error} : null} + {notice ? ( + {notice} + ) : null} + {!featureEnabled ? ( + {labels.disabled} + ) : null} + {featureEnabled && !usable ? ( + {labels.notUsable} + ) : null} + +
+
+
{stats.bases}
+
{labels.bases}
+
+
+
{stats.documents}
+
{labels.documents}
+
+
+
{stats.shared}
+
{labels.shared}
+
+
+
{stats.owned}
+
{labels.owned}
+
+
+ + {writable ? null :

{labels.adminOnly}

} +

{labels.hostHint}

+ {onOpenHostKnowledge ? ( + + ) : null} + + +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={labels.search} + allowClear + data-testid="org-knowledge-search" + /> +
+ {loading ? ( +

{labels.loading}

+ ) : visibleBases.length === 0 ? ( +
+

{labels.emptyBases}

+

{labels.emptyBasesHint}

+
+ ) : ( +
+ {visibleBases.map((row) => ( +
{ + setSelectedId(row.id); + setActiveTab("docs"); + }} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + setSelectedId(row.id); + setActiveTab("docs"); + } + }} + data-testid={`org-knowledge-card-${row.id}`} + > +

{row.name}

+

+ {row.id} +

+

+ {row.description || "—"} +

+ + + {row.document_count} {labels.documents} + + {row.shared ? {labels.shared} : null} + {row.owned ? {labels.owned} : null} + +
+ ))} +
+ )} + + ), + }, + { + key: "docs", + label: labels.docsTab, + children: ( + <> +
+ } + value={search} + onChange={(event) => setSearch(event.target.value)} + placeholder={labels.search} + allowClear + data-testid="org-knowledge-doc-search" + /> + {writable && selectedId ? ( + + ) : null} +
+ {!selectedId ? ( +
+

{labels.selectBase}

+
+ ) : docsLoading ? ( +

{labels.loading}

+ ) : visibleDocs.length === 0 ? ( +
+

{labels.emptyDocs}

+

{labels.emptyDocsHint}

+
+ ) : ( +
+ ({ + onClick: () => void openPreview(row), + "data-testid": `org-knowledge-doc-${row.id}`, + })} + columns={[ + { title: labels.name, dataIndex: "filename" }, + { + title: labels.kind, + dataIndex: "kind", + render: (value: string) => + value === "note" ? labels.note : labels.file, + }, + { title: labels.status, dataIndex: "status" }, + { + title: labels.size, + dataIndex: "byte_size", + render: (value: number) => formatBytes(value), + }, + ]} + /> + + )} + + ), + }, + ]} + /> + + setBaseOpen(false)} + footer={ + + + + + } + > +
+ {labels.name} + setBaseName(event.target.value)} + data-testid="org-knowledge-base-name" + /> +
+
+ {labels.description} + setBaseDescription(event.target.value)} + rows={3} + data-testid="org-knowledge-base-description" + /> +
+
+ {labels.share} + +
+
+ + setNoteOpen(false)} + footer={ + + + + + } + > +

{selected?.name}

+
+ {labels.noteTitle} + setNoteTitle(event.target.value)} + data-testid="org-knowledge-note-title" + /> +
+
+ {labels.noteContent} + setNoteContent(event.target.value)} + rows={6} + data-testid="org-knowledge-note-content" + /> +
+
+ + setPreview(null)} + footer={ + + } + > + {preview ? ( +
+
+ {labels.name} + {preview.filename} +
+
+              {preview.text}
+            
+
+ ) : null} +
+ + ); +} diff --git a/dashboard/src/org-ui/pages/knowledge/labels.ts b/dashboard/src/org-ui/pages/knowledge/labels.ts new file mode 100644 index 00000000..987d2147 --- /dev/null +++ b/dashboard/src/org-ui/pages/knowledge/labels.ts @@ -0,0 +1,112 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Knowledge", + subtitle: + "Organization Knowledge lists the same FreeOS knowledge bases Chat and Agents retrieve from. It is not a second notes database and does not start a sidecar.", + loading: "Loading knowledge…", + refresh: "Refresh", + search: "Search bases or documents…", + basesTab: "Knowledge bases", + docsTab: "Documents", + bases: "Bases", + documents: "Documents", + shared: "Shared", + owned: "Yours", + emptyBases: "No host knowledge bases yet.", + emptyBasesHint: + "Create a base here, or open Knowledge Bases to mount a folder. Agents retrieve these same rows — there is no org-only knowledge table.", + emptyDocs: "No documents in this knowledge base.", + emptyDocsHint: + "Add a markdown note here, or upload files on the host Knowledge Bases page.", + selectBase: "Select a knowledge base to see its documents.", + adminOnly: + "Creating bases and notes requires the knowledge-bases permission.", + hostHint: + "Upload, folders, embedding, and IMA mounts stay on host Knowledge Bases. This page only lists and adds notes.", + openHost: "Open Knowledge Bases", + createBase: "New knowledge base", + createNote: "New note", + detail: "Document", + close: "Close", + name: "Name", + description: "Description", + share: "Shared with the instance", + noteTitle: "Title", + noteContent: "Content", + status: "Status", + kind: "Kind", + note: "Note", + file: "File", + size: "Size", + disabled: + "Knowledge bases are turned off for this instance. Turn them on in Foundation settings.", + notUsable: + "Knowledge bases are on, but embeddings are not ready. Configure a model on the host Knowledge Bases page before new notes can be indexed.", + loadFailed: "Could not load knowledge bases", + createBaseFailed: "Could not create this knowledge base", + createNoteFailed: "Could not create this note", + previewFailed: "Could not open this document", + createBaseDone: "Created a host knowledge base.", + createNoteDone: "Saved a markdown note in the host knowledge base.", + confirmCreateBase: + "Create a host knowledge base? Agents can retrieve it in chat.", + confirmCreateNote: + "Save this note as a markdown document in the selected host knowledge base?", +}; + +const ZH: typeof EN = { + title: "知识", + subtitle: + "组织知识列出的是 Chat / 智能体检索用的同一套 FreeOS 知识库,不是第二套笔记库,也不启动边车。", + loading: "加载知识…", + refresh: "刷新", + search: "搜索知识库或文档…", + basesTab: "知识库", + docsTab: "文档", + bases: "知识库", + documents: "文档", + shared: "已共享", + owned: "我的", + emptyBases: "还没有宿主知识库。", + emptyBasesHint: + "可在此新建,或打开「知识库」挂载目录。智能体检索的就是这些行——没有单独的组织知识表。", + emptyDocs: "该知识库还没有文档。", + emptyDocsHint: "可在此添加 Markdown 笔记,或到宿主「知识库」页上传文件。", + selectBase: "先选择一个知识库,再查看文档。", + adminOnly: "新建知识库和笔记需要「知识库」权限。", + hostHint: + "上传、文件夹、向量模型和 IMA 挂载仍在宿主「知识库」页。本页只列出并添加笔记。", + openHost: "打开知识库", + createBase: "新建知识库", + createNote: "新建笔记", + detail: "文档", + close: "关闭", + name: "名称", + description: "描述", + share: "实例内共享", + noteTitle: "标题", + noteContent: "正文", + status: "状态", + kind: "类型", + note: "笔记", + file: "文件", + size: "大小", + disabled: "本实例已关闭知识库。请到基础设置中打开。", + notUsable: + "知识库已打开,但嵌入模型尚未就绪。请先在宿主「知识库」页配置模型,新笔记才能被索引。", + loadFailed: "无法加载知识库", + createBaseFailed: "无法创建该知识库", + createNoteFailed: "无法创建该笔记", + previewFailed: "无法打开该文档", + createBaseDone: "已创建宿主知识库。", + createNoteDone: "已将 Markdown 笔记写入宿主知识库。", + confirmCreateBase: "创建宿主知识库?智能体可在对话中检索它。", + confirmCreateNote: "把这篇笔记存为所选宿主知识库中的 Markdown 文档?", +}; + +export type KnowledgeLabels = typeof EN; + +export function knowledgeLabels(locale: OrgLocale): KnowledgeLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 99be2ffa..47b46716 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -32,6 +32,10 @@ function renderOrg() { path="/organization/governance" element={
governance
} /> + knowledge} + /> experts} @@ -252,6 +256,13 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host knowledge without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-knowledge")); + expect(await screen.findByTestId("org-knowledge-page")).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("imports into FreeOS and opens Experts so colleagues are selectable", async () => { vi.mocked(orgModuleApi.assemble).mockResolvedValue({ sidecar_reachable: false, diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 20bdb5e7..383892ea 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Button, Drawer, Input, Space, Switch, Tag } from "antd"; import { ArrowDownUp, + BookOpen, Building2, Download, Package, @@ -482,6 +483,22 @@ export default function OrganizationPage() { {t("organization.openGovernance")} +
+ +

+ {t("organization.knowledgeTitle")} +

+

+ {t("organization.knowledgeBody")} +

+ +
{firstRun && ( diff --git a/dashboard/src/pages/Organization/knowledge/index.tsx b/dashboard/src/pages/Organization/knowledge/index.tsx new file mode 100644 index 00000000..bbeb0f24 --- /dev/null +++ b/dashboard/src/pages/Organization/knowledge/index.tsx @@ -0,0 +1,51 @@ +import { useMemo } from "react"; +import { useNavigate } from "react-router-dom"; +import { useTranslation } from "react-i18next"; +import { request } from "../../../api/request"; +import { useCurrentUser } from "../../../hooks/useCurrentUser"; +import PageShell from "../../../layouts/PageShell"; +import { + KnowledgePage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { userCan } from "../../../utils/permissions"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationKnowledgePage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("knowledge"); + const client = useMemo( + () => + createOrgApiClient({ + fetchJson: (path, init) => request(path, init), + }), + [], + ); + const locale = i18n.language?.toLowerCase().startsWith("zh") ? "zh" : "en"; + const session: OrgSession = { + userId: user?.id ?? 0, + displayName: user?.display_name || user?.username || "", + role: user?.role || "user", + isAdmin: user?.role === "admin", + }; + + return ( + + navigate("/knowledge-bases")} + /> + + ); +} diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index 8fb91765..296485c3 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,4 +1,5 @@ import { + BookOpen, Building2, Megaphone, Network, @@ -17,7 +18,8 @@ export function useOrgPathTabs( | "org" | "employees" | "skills" - | "governance", + | "governance" + | "knowledge", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -54,6 +56,11 @@ export function useOrgPathTabs( label: t("organization.navGovernance"), icon: , }, + { + value: "knowledge", + label: t("organization.navKnowledge"), + icon: , + }, ], onChange: (value) => { if (value === "announcements") { @@ -76,6 +83,10 @@ export function useOrgPathTabs( navigate("/organization/governance"); return; } + if (value === "knowledge") { + navigate("/organization/knowledge"); + return; + } navigate("/organization"); }, }; diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 2bc1175f..c4336e75 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -72,6 +72,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/employees")).toBeNull(); expect(pathPermissionKeys("/organization/skills")).toBeNull(); expect(pathPermissionKeys("/organization/governance")).toBeNull(); + expect(pathPermissionKeys("/organization/knowledge")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); expect(pathPermissionKeys("/token-usage")).toBeNull(); @@ -159,6 +160,7 @@ describe("unknown dashboard paths", () => { ); expect(resolveSelectedKey("/organization/skills")).toBe("organization"); expect(resolveSelectedKey("/organization/governance")).toBe("organization"); + expect(resolveSelectedKey("/organization/knowledge")).toBe("organization"); }); it("are caught by the not-found route", () => { diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index ea0f29ec..4f7e4bde 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -34,6 +34,9 @@ const OrganizationSkillsPage = lazy( const OrganizationGovernancePage = lazy( () => import("../pages/Organization/governance"), ); +const OrganizationKnowledgePage = lazy( + () => import("../pages/Organization/knowledge"), +); const ProjectsPage = lazy(() => import("../pages/Projects")); // Misc @@ -92,6 +95,7 @@ export const pathToKey: Record = { "/organization/employees": "organization", "/organization/skills": "organization", "/organization/governance": "organization", + "/organization/knowledge": "organization", "/projects": "projects", "/tasks": "projects", "/connectors": "personalization", @@ -246,6 +250,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/governance", element: , }, + { + path: "/organization/knowledge", + element: , + }, { path: "/projects", element: }, { path: "/tasks", element: }, { diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index 4232d68f..02d7f5be 100644 --- a/docs/org-merge-plan.md +++ b/docs/org-merge-plan.md @@ -1,6 +1,6 @@ # 组织模块合并计划(openXYOS → FreeOS Dashboard) -**状态:** Phase 3 组织架构、员工目录、技能与治理 UI 切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage + export-standalone 模块列表)。其余 Open-12 仍待切片。 +**状态:** Phase 3 组织架构、员工目录、技能、治理 UI 与知识切片已落地(宿主 org tree / 员工 CRUD + skill_bridge list/read + 治理 pauses/audit + 宿主知识库桥接 + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` + `/organization/knowledge` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage / KnowledgePage + export-standalone 模块列表)。其余 Open-12 仍待切片。 **日期:** 2026-09-19 **依据:** Phase 0 迁移图、[architecture-integration.md](architecture-integration.md)、[asset-loop.md](asset-loop.md)、[ADR 001](adr/001-single-process-model.md)、#55 宿主内 Organization @@ -25,7 +25,7 @@ Phase 1 冻结了合同。Phase 2(通知公告)与 Phase 3 的组织架构 | **0** 宿主内地基 | `org_os` 控制面、`/api/org-module/*` BFF、Dashboard 原生工作台、sidecar 改为可选 | **已完成**(#55) | | **1** 合同冻结 | ADR + 本计划:单源双交付、包布局、API 归属、IdentityBridge、Phase 2 验收 | **本文** | | **2** 垂直切片 | **通知公告 Announcements** 按合同落地(Dashboard 路由 + 可导出同一页面) | **已完成** | -| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构、员工目录、技能与治理 UI 已迁;其余未开始 | +| **3** Open-12 其余页 | 工作台、组织架构、员工、技能、智能体、任务、知识、反思、治理 UI、设置 | **进行中**:组织架构、员工目录、技能、治理 UI 与知识已迁;其余未开始 | | **4** 身份与 CRUD | 已迁页面走 IdentityBridge;业务 CRUD 按切片迁入宿主;未迁路由仍可代理到可选 sidecar | 与 2–3 交叉推进 | | **5** 导出与安装器 | `freeos org export-standalone` 产出独立站;默认安装器保持零 Node | 未开始 | @@ -62,7 +62,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | **chat** | OpenApp `/chat` | sidecar `/api/chats` | **不迁。** 对话走 FreeOS/Octop | | agents | OpenApp `/agents`(Agent Studio) | 源码有 `routes/agent-studio.ts`,**`server.ts` 当前未 `app.use`** | Phase 3 前必须先核实挂载;未挂载则只迁 UI、API 在宿主补齐 | | tasks | OpenApp `/tasks` | sidecar `/api/tasks` | Phase 3 | -| knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | Phase 3;与宿主知识库边界保持「组织资料 ≠ Octop RAG 库」直到单独立项 | +| knowledge | OpenApp `/knowledge` | sidecar `/api/knowledge` | **Phase 3 切片已落地**:`/organization/knowledge` 列出宿主 `/api/knowledge-bases` 同一套行;不克隆 sidecar notes/files DB | | reflections | OpenApp `/reflections` | sidecar `/api/reflections` | Phase 3 | | governance UI | OpenApp `/governance` | sidecar `/api/governance` + 宿主 `org_os/governance` | **Phase 3 切片已落地**:`/organization/governance` 展示待审批 / 审计 / 裁决;PEP/PDP 与 durable pause **已在宿主,勿重写引擎**。未迁:边车权限矩阵、通信规则、流程模板 | | settings | OpenApp `/settings` | sidecar `/api/settings`、`/api/module-settings` | Phase 3:租户/模块开关;宿主已有 `PATCH /api/org-module` 与 `/modules` | @@ -118,6 +118,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | `employees` · `employees/transition` · `employees/spawn` | 宿主 lifecycle | 保持;与员工 **目录 CRUD** 分开 | | `assets/*` · `blueprints/compile` · `skills` list/read/generate/publish | 宿主工厂 | **已挂 UI**。list/read 补目录;generate/publish 仍管理员/`plugins` 门控 | | `governance/check\|resolve\|audit` · `pauses` | 宿主 PEP/PDP | **已挂 UI**。`GET /pauses` 补列出待审批;resolve 仍管理员/`plugins` 门控 | +| `knowledge` list/read/create/notes | 宿主知识库桥接 | **已挂 UI**。包装 `KnowledgeService`;写操作 `knowledge_bases` 门控。不克隆 sidecar notes DB | | `sidecar/*` · `source/download` | 可选兼容 | 留在 Advanced;默认路径不依赖 | ### 业务 CRUD(当前 sidecar Express → 按切片迁入宿主) @@ -127,7 +128,8 @@ uv run freeos org export-standalone --out dist/openxyos-web | 公告 | `GET/POST/PUT/DELETE /api/announcements` | **先迁。** 宿主实现(建议 `/api/org-module/announcements` 或宿主挂 `/api/announcements`),`org-ui` client factory 指向宿主。独立导出站可同路径或经适配器改 base URL | | 组织树 | sidecar `/api/org` | **已迁。** 宿主 `/api/org-module/org`(tree / departments / employees 目录 CRUD),`{FREEOS_HOME}/org/org_chart.sqlite`。未迁:versions、reporting-lines、skills、import、头像上传 | | 员工目录 | sidecar `/api/employees` | **已迁目录 CRUD。** 宿主 `/api/org-module/org/employees*`,与 org chart **同一** `{FREEOS_HOME}/org/org_chart.sqlite`。lifecycle 仍是 `GET /api/org-module/employees`(同事 registry)。未迁:人才市场、备选入职、资产离职清算、绩效、汇报线、技能绑定、头像上传 | -| 任务 / 知识 / 反思 / 设置 | sidecar `/api/tasks` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | +| 任务 / 反思 / 设置 | sidecar `/api/tasks` 等 | Phase 3 其余页:同一模式,迁完才摘代理 | +| 知识 | sidecar `/api/knowledge` | **已迁为宿主桥接。** `GET /api/org-module/knowledge*` 读 Octop `knowledge_bases` / `knowledge_documents`;笔记写成宿主 markdown 文档。sidecar notes DB 仍是可选兼容 | | 未迁资源 | sidecar 或 `/api/org-module/sidecar/api/` 代理 | 可选 sidecar 仍可用;默认安装不启动它 | | Chat | sidecar `/api/chats` | **永久不迁** | @@ -276,7 +278,7 @@ openXYOS sidecar `/api/skills`(市场、插件中心、付费安装)仍是 | Chat / 从技能页发起会话 | 对话运行时留在 FreeOS/Octop | | 边车技能市场 / 插件中心 / 付费安装 | sidecar SQL.js;本波不迁商业市场 | | 在组织页里编辑 Agent Skills | 运行时已在个性化;本页只读列出并跳转 | -| 其余 Open-12(智能体、任务、知识、反思、设置) | 后续切片 | +| 其余 Open-12(智能体、任务、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -309,7 +311,40 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 | Chat / 从治理页发起会话 | 对话运行时留在 FreeOS/Octop | | 边车权限矩阵 / 通信规则 / 流程模板 CRUD | sidecar SQL.js;本波不重写引擎、不迁商业矩阵编辑器 | | IM `/approve` 接线 | 引擎与 CLI 已能裁决;IM 另切片 | -| 其余 Open-12(智能体、任务、知识、反思、设置) | 后续切片 | +| 其余 Open-12(智能体、任务、反思、设置) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + +## Phase 3 进度:知识(本切片) + +按同一缝落地,**只迁组织知识目录**。不另造笔记/文件 SQLite:列出并写入的是宿主 FreeOS 知识库(Chat / Agent RAG 同一套行)。 + +### 与 FreeOS 知识库的关系 + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 组织知识页 | Octop `knowledge_bases` / `knowledge_documents` | `GET /api/org-module/knowledge` · `GET …/{kb_id}` · `POST …` · `POST …/{kb_id}/notes` | **桥接宿主 KB**。卡片/笔记 UX 参考 openXYOS KnowledgePage,数据不是 sidecar SQL.js | +| 宿主知识库页 | 同上 | `/api/knowledge-bases*` · Dashboard `/knowledge-bases` | 上传、文件夹、嵌入模型、IMA 挂载仍在这里 | +| sidecar 知识 | openXYOS notes + files | 可选 `/api/knowledge` | **不迁、不克隆。** 默认路径不读它 | + +组织知识 **不是** 第二套「组织资料」表,也 **没有** 把 sidecar 笔记合并进 Octop RAG。它只是 Organization 壳下的同一套宿主知识库视图;笔记会变成所选 KB 里的 markdown 文档,因此对话检索能看到。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/knowledge`(`KnowledgePage`),数据经 `createOrgApiClient().knowledge`。 +2. Dashboard 子路由:`/organization/knowledge`(工作台 path tabs + 入口卡)。 +3. 宿主 API:本切片补 `/api/org-module/knowledge*`,包装已有 `KnowledgeService`。写操作走 `require_permission("knowledge_bases")`(管理员绕过)。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `KnowledgePage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从知识页发起检索对话 | 对话运行时留在 FreeOS/Octop | +| 边车 notes/files CRUD 与解析流水线 | sidecar SQL.js;本波不克隆第二套知识库 | +| 上传、文件夹、嵌入设置、IMA 挂载 | 已在宿主 `/knowledge-bases`;本页只列并加笔记 | +| 其余 Open-12(智能体、任务、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -325,7 +360,7 @@ openXYOS sidecar `/api/governance`(权限矩阵、通信规则、流程模板 | 手维护第二份独立站 | 独立站必须从 `org-ui` 生成 | | 先重写全部 Express → FastAPI | 按切片迁 CRUD | | 重写 `GovernanceEngine` | 治理执行已在宿主;本波只迁 UI | -| 把 Octop 知识库与组织知识混成一个表 | 除非单独立项 | +| 克隆 sidecar 知识库 / 另造组织笔记表 | 组织知识桥接宿主 KB;不把 sidecar notes 与 Octop RAG 合成新表 | | 本 PR 搬页面或改安装器 | Phase 1 只文档 | --- diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index db1ccf08..86df2201 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -4,7 +4,7 @@ **imports the same org-ui pages** Dashboard mounts under `/organization/...`. Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, -and **Governance**: +**Governance**, and **Knowledge**: | Delivery | Route | Component | |---|---|---| @@ -19,10 +19,14 @@ and **Governance**: | Standalone (this export) | `/skills` | same import | | Dashboard | `/organization/governance` | `dashboard/src/org-ui` → `GovernancePage` | | Standalone (this export) | `/governance` | same import | +| Dashboard | `/organization/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | +| Standalone (this export) | `/knowledge` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). Governance pauses/audit live under `{FREEOS_HOME}/governance/`. +Organization Knowledge lists host FreeOS knowledge bases (same rows as +`/knowledge-bases`); it does not clone the sidecar notes/files DB. Do not copy the pages into a second tree. ## Phase 5 TODO diff --git a/src/octop/api/openapi_meta.py b/src/octop/api/openapi_meta.py index bc50cdc6..f60ec35c 100644 --- a/src/octop/api/openapi_meta.py +++ b/src/octop/api/openapi_meta.py @@ -162,7 +162,7 @@ }, { "name": "org-module", - "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, and governance, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, governance, and knowledge, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_knowledge.py b/src/octop/api/routers/org_knowledge.py new file mode 100644 index 00000000..e8866964 --- /dev/null +++ b/src/octop/api/routers/org_knowledge.py @@ -0,0 +1,267 @@ +"""In-host org knowledge BFF — lists FreeOS knowledge bases, not a sidecar notes DB. + +Organization Knowledge is a view over the same Octop ``knowledge_bases`` / +``knowledge_documents`` rows that Chat and Agents retrieve from. It does **not** +clone openXYOS ``/api/knowledge`` (SQL.js notes + files). Sidecar knowledge +remains optional compat. +""" + +from __future__ import annotations + +from contextlib import suppress +from typing import Any + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel, Field + +from octop.api.deps import current_user, get_server, require_permission +from octop.api.routers.knowledge_bases import _map_knowledge_error +from octop.infra.knowledge.gate import get_capability +from octop.infra.knowledge.service import KnowledgeService +from octop.infra.server import OctopServer +from octop.infra.users.identity import User +from octop.infra.utils.locale import resolve_request_locale + +router = APIRouter() + +_HOST_ROUTE = "/knowledge-bases" +_TEXT_TYPES = {"text/plain", "text/markdown"} + + +class KnowledgeCreateBaseBody(BaseModel): + name: str = Field(min_length=1, max_length=200) + description: str = Field(default="", max_length=2000) + shared: bool = False + + +class KnowledgeCreateNoteBody(BaseModel): + title: str = Field(min_length=1, max_length=200) + content: str = Field(default="") + + +def _is_admin(user: User) -> bool: + return bool(user.is_admin) + + +def _knowledge_service(server: OctopServer) -> KnowledgeService | None: + services = getattr(server, "services", None) + if services is None or getattr(services, "knowledge_repo", None) is None: + return None + return KnowledgeService(services) + + +def _capability(server: OctopServer) -> dict[str, Any]: + services = getattr(server, "services", None) + settings = getattr(services, "settings_repo", None) if services is not None else None + if settings is None: + return { + "feature_enabled": False, + "usable": False, + "prerequisites_ok": False, + "selected_model": "", + "backend": "onnx", + } + cap = get_capability(settings.get, getattr(services, "provider_repo", None)) + return { + "feature_enabled": bool(cap.get("feature_enabled")), + "usable": bool(cap.get("usable")), + "prerequisites_ok": bool(cap.get("prerequisites_ok")), + "selected_model": str(cap.get("selected_model") or ""), + "backend": str(cap.get("backend") or "onnx"), + } + + +def _base_payload(row: Any, *, actor_user_id: int) -> dict[str, Any]: + owner = int(getattr(row, "owner_user_id", 0) or 0) + return { + "id": row.id, + "knowledge_base_id": row.id, + "name": row.name, + "description": getattr(row, "description", "") or "", + "shared": bool(getattr(row, "shared", False)), + "default_open": bool(getattr(row, "default_open", False)), + "icon_name": getattr(row, "icon_name", "") or "", + "document_count": int(getattr(row, "doc_count", 0) or 0), + "owner_user_id": owner, + "owned": owner == int(actor_user_id), + } + + +def _doc_payload(row: Any) -> dict[str, Any]: + content_type = str(getattr(row, "content_type", "") or "") + return { + "id": row.id, + "document_id": row.id, + "kb_id": row.kb_id, + "filename": row.filename, + "path": getattr(row, "path", "") or row.filename, + "content_type": content_type, + "byte_size": int(getattr(row, "byte_size", 0) or 0), + "is_dir": bool(getattr(row, "is_dir", False)), + "status": getattr(row, "status", "") or "", + "chunk_count": int(getattr(row, "chunk_count", 0) or 0), + "created_at": int(getattr(row, "created_at", 0) or 0), + "kind": "note" if content_type in _TEXT_TYPES else "file", + } + + +def _stats(bases: list[dict[str, Any]]) -> dict[str, int]: + return { + "bases": len(bases), + "documents": sum(int(row.get("document_count") or 0) for row in bases), + "shared": sum(1 for row in bases if row.get("shared")), + "owned": sum(1 for row in bases if row.get("owned")), + } + + +@router.get("/knowledge", summary="List host knowledge bases visible to the current user") +async def knowledge_list( + server: OctopServer = Depends(get_server), + user: User = Depends(current_user), +) -> dict[str, Any]: + svc = _knowledge_service(server) + bases: list[dict[str, Any]] = [] + if svc is not None: + bases = [ + _base_payload(row, actor_user_id=user.id) + for row in svc.list_visible_bases(actor_user_id=user.id, is_admin=_is_admin(user)) + ] + return { + "host_route": _HOST_ROUTE, + "store": "host_knowledge_bases", + "capability": _capability(server), + "bases": bases, + "stats": _stats(bases), + } + + +@router.get("/knowledge/{kb_id}", summary="Get a host knowledge base and its documents") +async def knowledge_get( + kb_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(current_user), +) -> dict[str, Any]: + locale = resolve_request_locale(request) + svc = _knowledge_service(server) + if svc is None: + raise _map_knowledge_error(LookupError("knowledge base not found"), locale=locale) + try: + base = svc.get_readable_base(kb_id, actor_user_id=user.id, is_admin=_is_admin(user)) + documents = [ + _doc_payload(row) + for row in svc.list_documents(kb_id, actor_user_id=user.id, is_admin=_is_admin(user)) + if not getattr(row, "is_dir", False) + ] + except Exception as exc: + raise _map_knowledge_error(exc, locale=locale, server=server) from exc + payload = _base_payload(base, actor_user_id=user.id) + payload["document_count"] = len(documents) + payload["documents"] = documents + return payload + + +@router.get( + "/knowledge/{kb_id}/documents/{doc_id}", + summary="Preview a host knowledge document (notes use extracted text)", +) +async def knowledge_preview( + kb_id: str, + doc_id: str, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(current_user), +) -> dict[str, Any]: + locale = resolve_request_locale(request) + svc = _knowledge_service(server) + if svc is None: + raise _map_knowledge_error(LookupError("knowledge document not found"), locale=locale) + try: + with suppress(ValueError, LookupError): + text_doc = svc.read_text_document( + kb_id, doc_id, actor_user_id=user.id, is_admin=_is_admin(user) + ) + return { + "id": text_doc["id"], + "document_id": text_doc["id"], + "kb_id": kb_id, + "filename": text_doc["filename"], + "content_type": text_doc.get("content_type") or "text/markdown", + "text": text_doc["text"], + "kind": "note", + } + preview = svc.preview_document( + kb_id, doc_id, actor_user_id=user.id, is_admin=_is_admin(user) + ) + return { + "id": preview["id"], + "document_id": preview["id"], + "kb_id": kb_id, + "filename": preview["filename"], + "content_type": "", + "text": preview["text"], + "kind": "file", + } + except Exception as exc: + raise _map_knowledge_error(exc, locale=locale, server=server) from exc + + +@router.post("/knowledge", summary="Create a host knowledge base (same store as /knowledge-bases)") +async def knowledge_create_base( + body: KnowledgeCreateBaseBody, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, Any]: + locale = resolve_request_locale(request) + svc = _knowledge_service(server) + if svc is None: + raise _map_knowledge_error( + RuntimeError("knowledge services are not initialized"), locale=locale + ) + try: + base = svc.create_base( + owner_user_id=user.id, + name=body.name.strip(), + description=body.description.strip(), + shared=body.shared, + ) + except Exception as exc: + raise _map_knowledge_error(exc, locale=locale, server=server) from exc + return _base_payload(base, actor_user_id=user.id) + + +@router.post( + "/knowledge/{kb_id}/notes", + summary="Create a markdown note in a host knowledge base", +) +async def knowledge_create_note( + kb_id: str, + body: KnowledgeCreateNoteBody, + request: Request, + server: OctopServer = Depends(get_server), + user: User = Depends(require_permission("knowledge_bases")), +) -> dict[str, Any]: + locale = resolve_request_locale(request) + svc = _knowledge_service(server) + if svc is None: + raise _map_knowledge_error( + RuntimeError("knowledge services are not initialized"), locale=locale + ) + try: + document = svc.create_text_document( + kb_id, + actor_user_id=user.id, + name=body.title.strip(), + format="md", + content=body.content, + is_admin=_is_admin(user), + ) + except Exception as exc: + raise _map_knowledge_error(exc, locale=locale, server=server) from exc + if server.services is not None: + with suppress(Exception): + from octop.infra.knowledge.jobs import enqueue_index_document + + enqueue_index_document(server.services, kb_id, document.id) + return _doc_payload(document) diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index bccd0b21..abe46dcc 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -13,6 +13,7 @@ from octop.api.deps import current_user, get_server, require_permission from octop.api.routers.org_announcements import router as announcements_router from octop.api.routers.org_chart import router as org_chart_router +from octop.api.routers.org_knowledge import router as knowledge_router from octop.infra.server import OctopServer from octop.infra.utils.locale import resolve_request_locale from octop.modules.org_os.catalog import OPENXYOS_MODULES @@ -38,6 +39,7 @@ router = APIRouter() router.include_router(announcements_router) router.include_router(org_chart_router) +router.include_router(knowledge_router) class OrgModulePatch(BaseModel): diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 498bec8d..b94c0489 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -11,6 +11,7 @@ "employees", "skills", "governance", + "knowledge", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 639cb2d9..38a6a14b 100644 --- a/src/octop/modules/org_os/export_standalone.py +++ b/src/octop/modules/org_os/export_standalone.py @@ -2,8 +2,8 @@ Full Vite + Node server packaging is Phase 5. This writes a snapshot that lists the shared org-ui modules and points at the same AnnouncementPage, -OrgChartPage, EmployeesPage, SkillsPage, and GovernancePage sources -Dashboard mounts under ``/organization/...``. +OrgChartPage, EmployeesPage, SkillsPage, GovernancePage, and KnowledgePage +sources Dashboard mounts under ``/organization/...``. """ from __future__ import annotations @@ -29,6 +29,7 @@ | employees | `/organization/employees` | `/employees` | `dashboard/src/org-ui` → `EmployeesPage` / `EmployeeDetailPage` | | skills | `/organization/skills` | `/skills` | `dashboard/src/org-ui` → `SkillsPage` | | governance | `/organization/governance` | `/governance` | `dashboard/src/org-ui` → `GovernancePage` | +| knowledge | `/organization/knowledge` | `/knowledge` | `dashboard/src/org-ui` → `KnowledgePage` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. @@ -37,6 +38,8 @@ Governance pauses and audit stay on `{FREEOS_HOME}/governance/`. Organization skills stay on `{FREEOS_HOME}/org-skills/` (skill_bridge). Host Agent Skills remain FreeOS skill packages — not a second runtime. +Organization Knowledge lists the same FreeOS knowledge bases Chat retrieves +from. It does not clone the openXYOS sidecar notes/files DB. ## Phase 3 vs Phase 5 @@ -44,7 +47,7 @@ - Shared module list (`src/modules.json`) - Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, - `EmployeesPage`, `SkillsPage`, and `GovernancePage` + `EmployeesPage`, `SkillsPage`, `GovernancePage`, and `KnowledgePage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -69,6 +72,7 @@ EmployeeDetailPage, EmployeesPage, GovernancePage, + KnowledgePage, OrgChartPage, SHARED_ORG_UI_MODULES, SkillsPage, @@ -78,6 +82,7 @@ OrgChartClient, OrgEmployeesClient, OrgGovernanceClient, + OrgKnowledgeClient, OrgSession, OrgSkillsClient, } from "org-ui"; @@ -86,7 +91,7 @@ * Standalone shell stub. Phase 5 wires a real IdentityBridge + local JWT. * Page components are the same ones Dashboard mounts at * /organization/announcements, /organization/org, /organization/employees, - * /organization/skills, and /organization/governance. + * /organization/skills, /organization/governance, and /organization/knowledge. */ const session: OrgSession = { userId: 0, @@ -101,6 +106,7 @@ employeesClient: OrgEmployeesClient; skillsClient: OrgSkillsClient; governanceClient: OrgGovernanceClient; + knowledgeClient: OrgKnowledgeClient; locale?: "zh" | "en"; }) { return ( @@ -142,6 +148,12 @@ locale={props.locale ?? "en"} modules={SHARED_ORG_UI_MODULES} /> + ); } @@ -154,7 +166,7 @@ "description": ( "Standalone Organization web export skeleton. " "Pages come from dashboard/src/org-ui " - "(Phase 3: Announcements + Org chart + Employees + Skills + Governance). " + "(Phase 3: Announcements + Org chart + Employees + Skills + Governance + Knowledge). " "Full Node packaging is Phase 5." ), "type": "module", @@ -214,6 +226,13 @@ def write_standalone_scaffold(out_dir: Path) -> dict[str, Any]: "api": "/api/org-module/governance", "store": "{FREEOS_HOME}/governance", }, + "knowledge": { + "component": "KnowledgePage", + "embedded_route": "/organization/knowledge", + "standalone_route": "/knowledge", + "api": "/api/org-module/knowledge", + "store": "host knowledge_bases / knowledge_documents", + }, }, "todo": "Phase 5: full Vite + server packaging; do not fork org-ui pages", } diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 79dbddfa..d7a2d94d 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -22,6 +22,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "employees", "skills", "governance", + "knowledge", ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) assert modules["shared_org_ui_modules"] == [ @@ -30,6 +31,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "employees", "skills", "governance", + "knowledge", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" assert modules["pages"]["announcements"]["embedded_route"] == "/organization/announcements" @@ -44,6 +46,9 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert modules["pages"]["governance"]["component"] == "GovernancePage" assert modules["pages"]["governance"]["embedded_route"] == "/organization/governance" assert modules["pages"]["governance"]["api"] == "/api/org-module/governance" + assert modules["pages"]["knowledge"]["component"] == "KnowledgePage" + assert modules["pages"]["knowledge"]["embedded_route"] == "/organization/knowledge" + assert modules["pages"]["knowledge"]["api"] == "/api/org-module/knowledge" app = (out / "src" / "App.tsx").read_text(encoding="utf-8") assert "AnnouncementPage" in app assert "OrgChartPage" in app @@ -51,6 +56,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "EmployeeDetailPage" in app assert "SkillsPage" in app assert "GovernancePage" in app + assert "KnowledgePage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") assert "Phase 5" in readme @@ -59,3 +65,4 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "EmployeesPage" in readme assert "SkillsPage" in readme assert "GovernancePage" in readme + assert "KnowledgePage" in readme diff --git a/tests/unit/test_org_chart.py b/tests/unit/test_org_chart.py index 30cbf6a5..36857088 100644 --- a/tests/unit/test_org_chart.py +++ b/tests/unit/test_org_chart.py @@ -45,6 +45,7 @@ def test_shared_organization_module_is_in_catalog() -> None: assert "employees" in SHARED_ORG_UI_MODULES assert "skills" in SHARED_ORG_UI_MODULES assert "governance" in SHARED_ORG_UI_MODULES + assert "knowledge" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog() diff --git a/tests/unit/test_org_governance.py b/tests/unit/test_org_governance.py index 64067e4f..a5a6738a 100644 --- a/tests/unit/test_org_governance.py +++ b/tests/unit/test_org_governance.py @@ -53,6 +53,7 @@ def test_shared_governance_module_is_in_catalog() -> None: assert "organization" in SHARED_ORG_UI_MODULES assert "employees" in SHARED_ORG_UI_MODULES assert "skills" in SHARED_ORG_UI_MODULES + assert "knowledge" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog() diff --git a/tests/unit/test_org_knowledge.py b/tests/unit/test_org_knowledge.py new file mode 100644 index 00000000..0a8f2298 --- /dev/null +++ b/tests/unit/test_org_knowledge.py @@ -0,0 +1,286 @@ +"""In-host org knowledge BFF — lists FreeOS knowledge bases, not a sidecar DB.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from types import SimpleNamespace + +import pytest +from fastapi import FastAPI +from fastapi.responses import JSONResponse +from fastapi.testclient import TestClient + +from octop.api.deps import current_user, get_server +from octop.api.routers import org_knowledge +from octop.api.routers.org_module import router +from octop.infra.errors import OctopError +from octop.infra.users.identity import Role, User +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) + + +@dataclass +class _Base: + id: str = "kb-1" + pk: int = 1 + owner_user_id: int = 1 + name: str = "Policies" + description: str = "Host knowledge" + default_open: bool = False + shared: bool = True + icon_name: str = "" + embedding_model: str = "bge" + embedding_dim: int = 0 + doc_count: int = 1 + max_documents: int = 100 + created_at: int = 1 + updated_at: int = 1 + + +@dataclass +class _Document: + id: str = "doc-1" + pk: int = 1 + kb_id: str = "kb-1" + path: str = "handbook.md" + filename: str = "handbook.md" + is_dir: bool = False + content_type: str = "text/markdown" + byte_size: int = 12 + content_hash: str = "" + status: str = "ready" + error_message: str = "" + chunk_count: int = 1 + created_at: int = 1 + updated_at: int = 1 + + +class _FakeKnowledge: + def __init__(self) -> None: + self.bases = [_Base()] + self.documents = [_Document()] + self.created_bases: list[str] = [] + self.created_notes: list[tuple[str, str]] = [] + + def list_visible_bases(self, *, actor_user_id: int, is_admin: bool = False) -> list[_Base]: + if is_admin: + return list(self.bases) + return [row for row in self.bases if row.owner_user_id == actor_user_id or row.shared] + + def get_readable_base(self, kb_id: str, *, actor_user_id: int, is_admin: bool = False) -> _Base: + for row in self.list_visible_bases(actor_user_id=actor_user_id, is_admin=is_admin): + if row.id == kb_id: + return row + raise LookupError("knowledge base not found") + + def list_documents( + self, kb_id: str, *, actor_user_id: int, is_admin: bool = False, prefix: str | None = None + ) -> list[_Document]: + self.get_readable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + return [row for row in self.documents if row.kb_id == kb_id] + + def create_base( + self, + *, + owner_user_id: int, + name: str, + description: str = "", + default_open: bool = False, + shared: bool = False, + icon_name: str = "", + max_documents: int = 100, + ) -> _Base: + created = _Base( + id="kb-2", + owner_user_id=owner_user_id, + name=name, + description=description, + shared=shared, + doc_count=0, + ) + self.bases.append(created) + self.created_bases.append(name) + return created + + def create_text_document( + self, + kb_id: str, + *, + actor_user_id: int, + name: str, + format: str, + content: str = "", + is_admin: bool = False, + path: str | None = None, + ) -> _Document: + self.get_readable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + created = _Document( + id="doc-2", + kb_id=kb_id, + path=f"{name}.md", + filename=f"{name}.md", + byte_size=len(content), + status="pending", + chunk_count=0, + ) + self.documents.append(created) + self.created_notes.append((kb_id, name)) + return created + + def read_text_document( + self, kb_id: str, doc_id: str, *, actor_user_id: int, is_admin: bool = False + ) -> dict[str, str]: + self.get_readable_base(kb_id, actor_user_id=actor_user_id, is_admin=is_admin) + return { + "id": doc_id, + "filename": "handbook.md", + "content_type": "text/markdown", + "text": "# Handbook", + } + + def preview_document( + self, kb_id: str, doc_id: str, *, actor_user_id: int, is_admin: bool = False + ) -> dict[str, str]: + return self.read_text_document( + kb_id, doc_id, actor_user_id=actor_user_id, is_admin=is_admin + ) + + +def _admin() -> User: + return User(id=1, username="admin", role=Role.ADMIN, display_name="Ada") + + +def _member() -> User: + return User(id=2, username="member", role=Role.USER, display_name="Mo") + + +def _client( + tmp_path: Path, + user: User, + monkeypatch: pytest.MonkeyPatch, + *, + knowledge: _FakeKnowledge | None = None, + with_repo: bool = True, +) -> TestClient: + app = FastAPI() + app.include_router(router, prefix="/api/org-module") + + @app.exception_handler(OctopError) + async def _octop(_request: object, exc: OctopError) -> JSONResponse: + return JSONResponse(status_code=exc.status, content=exc.to_envelope()) + + fake = knowledge if knowledge is not None else _FakeKnowledge() + services = None + if with_repo: + services = SimpleNamespace( + knowledge_repo=SimpleNamespace(), + settings_repo=SimpleNamespace(get=lambda _key: "true"), + provider_repo=None, + ) + server = SimpleNamespace( + paths=SimpleNamespace(config=tmp_path / "config.json", root=tmp_path), + services=services, + ) + app.dependency_overrides[current_user] = lambda: user + app.dependency_overrides[get_server] = lambda: server + monkeypatch.setattr( + org_knowledge, + "_knowledge_service", + lambda _server: fake if with_repo else None, + ) + monkeypatch.setattr( + org_knowledge, + "_capability", + lambda _server: { + "feature_enabled": True, + "usable": True, + "prerequisites_ok": True, + "selected_model": "bge", + "backend": "onnx", + }, + ) + return TestClient(app) + + +def test_shared_knowledge_module_is_in_catalog() -> None: + assert "knowledge" in SHARED_ORG_UI_MODULES + assert "skills" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_api_lists_host_knowledge_bases(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + admin = _client(tmp_path, _admin(), monkeypatch) + listed = admin.get("/api/org-module/knowledge") + assert listed.status_code == 200 + body = listed.json() + assert body["store"] == "host_knowledge_bases" + assert body["host_route"] == "/knowledge-bases" + assert body["stats"]["bases"] == 1 + assert body["bases"][0]["name"] == "Policies" + assert body["bases"][0]["owned"] is True + assert body["capability"]["usable"] is True + + +def test_api_lists_empty_when_knowledge_repo_missing( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + admin = _client(tmp_path, _admin(), monkeypatch, with_repo=False) + listed = admin.get("/api/org-module/knowledge") + assert listed.status_code == 200 + body = listed.json() + assert body["bases"] == [] + assert body["stats"]["bases"] == 0 + + +def test_api_reads_documents_and_preview(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + admin = _client(tmp_path, _admin(), monkeypatch) + detail = admin.get("/api/org-module/knowledge/kb-1") + assert detail.status_code == 200 + assert detail.json()["documents"][0]["kind"] == "note" + preview = admin.get("/api/org-module/knowledge/kb-1/documents/doc-1") + assert preview.status_code == 200 + assert "# Handbook" in preview.json()["text"] + missing = admin.get("/api/org-module/knowledge/kb-missing") + assert missing.status_code == 404 + + +def test_api_member_can_read_but_cannot_create( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = _FakeKnowledge() + member = _client(tmp_path, _member(), monkeypatch, knowledge=fake) + listed = member.get("/api/org-module/knowledge") + assert listed.status_code == 200 + assert listed.json()["bases"][0]["name"] == "Policies" + denied_base = member.post("/api/org-module/knowledge", json={"name": "Nope"}) + assert denied_base.status_code == 403 + denied_note = member.post( + "/api/org-module/knowledge/kb-1/notes", + json={"title": "Nope", "content": "x"}, + ) + assert denied_note.status_code == 403 + + +def test_api_admin_creates_host_base_and_note( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + fake = _FakeKnowledge() + admin = _client(tmp_path, _admin(), monkeypatch, knowledge=fake) + created = admin.post( + "/api/org-module/knowledge", + json={"name": "Playbooks", "description": "ops", "shared": True}, + ) + assert created.status_code == 200 + assert created.json()["name"] == "Playbooks" + assert fake.created_bases == ["Playbooks"] + + note = admin.post( + "/api/org-module/knowledge/kb-1/notes", + json={"title": "Standup", "content": "notes"}, + ) + assert note.status_code == 200 + assert note.json()["kind"] == "note" + assert fake.created_notes == [("kb-1", "Standup")] diff --git a/tests/unit/test_org_skills.py b/tests/unit/test_org_skills.py index 41d254b2..b66df9dc 100644 --- a/tests/unit/test_org_skills.py +++ b/tests/unit/test_org_skills.py @@ -59,6 +59,7 @@ async def _octop(_request: object, exc: OctopError) -> JSONResponse: def test_shared_skills_module_is_in_catalog() -> None: assert "skills" in SHARED_ORG_UI_MODULES assert "governance" in SHARED_ORG_UI_MODULES + assert "knowledge" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog()