From a108688d51582cc39c12bad993a9e6e1989c52b3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 03:34:08 +0000 Subject: [PATCH 1/2] feat(org): Phase 3 in-host Skills org-ui slice Add /organization/skills using shared org-ui SkillsPage wired to host skill_bridge list/read/generate/publish. Host skill packages are listed read-only so Agent Skills stay on FreeOS Personalization. Co-authored-by: XYAI Labs --- CHANGELOG.md | 1 + dashboard/src/locales/en.json | 4 + dashboard/src/locales/zh.json | 4 + dashboard/src/org-ui/api/createClient.test.ts | 9 + dashboard/src/org-ui/api/createClient.ts | 94 ++++- dashboard/src/org-ui/contract.ts | 1 + dashboard/src/org-ui/index.ts | 9 + .../org-ui/pages/skills/SkillsPage.module.css | 165 ++++++++ .../org-ui/pages/skills/SkillsPage.test.tsx | 149 +++++++ .../src/org-ui/pages/skills/SkillsPage.tsx | 388 ++++++++++++++++++ dashboard/src/org-ui/pages/skills/labels.ts | 104 +++++ .../src/pages/Organization/index.test.tsx | 12 + dashboard/src/pages/Organization/index.tsx | 16 + .../src/pages/Organization/orgPathTabs.tsx | 19 +- .../src/pages/Organization/skills/index.tsx | 49 +++ dashboard/src/routes/controlAdminPath.test.ts | 2 + dashboard/src/routes/index.tsx | 8 + docs/architecture-integration.md | 2 +- docs/org-merge-plan.md | 46 ++- scripts/org-export/README.md | 8 +- src/octop/api/openapi_meta.py | 2 +- src/octop/api/routers/org_module.py | 79 +++- src/octop/cli/commands/org.py | 4 +- src/octop/modules/org_os/contract.py | 1 + src/octop/modules/org_os/export_standalone.py | 29 +- .../modules/org_os/skill_bridge/README.md | 5 + .../modules/org_os/skill_bridge/__init__.py | 10 + .../modules/org_os/skill_bridge/inventory.py | 161 ++++++++ tests/unit/cli/test_org_export_standalone.py | 7 + tests/unit/test_org_chart.py | 1 + tests/unit/test_org_governance.py | 1 + tests/unit/test_org_skills.py | 165 ++++++++ 32 files changed, 1532 insertions(+), 23 deletions(-) create mode 100644 dashboard/src/org-ui/pages/skills/SkillsPage.module.css create mode 100644 dashboard/src/org-ui/pages/skills/SkillsPage.test.tsx create mode 100644 dashboard/src/org-ui/pages/skills/SkillsPage.tsx create mode 100644 dashboard/src/org-ui/pages/skills/labels.ts create mode 100644 dashboard/src/pages/Organization/skills/index.tsx create mode 100644 src/octop/modules/org_os/skill_bridge/inventory.py create mode 100644 tests/unit/test_org_skills.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb5f42b..21187e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ ### 新增 +- 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。 - Organization Phase 2:宿主内通知公告。Dashboard `/organization/announcements` 使用共享 `dashboard/src/org-ui` 页面;CRUD 走 `/api/org-module/announcements`(`{FREEOS_HOME}/org/announcements.sqlite`),不需要 Node sidecar。`freeos org export-standalone` 写出同一页面的独立站骨架(完整打包仍待 Phase 5)。 diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index e81a3400..977642eb 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -185,6 +185,7 @@ "navAnnouncements": "Announcements", "navOrgChart": "Org chart", "navEmployees": "Employees", + "navSkills": "Skills", "navGovernance": "Governance", "announcementsTitle": "Announcements", "announcementsBody": "Publish notices and track who has read them. This page lives in the host — no sidecar or second port.", @@ -195,6 +196,9 @@ "employeesTitle": "Employees", "employeesBody": "Directory of people and AI colleagues on the host org tree. Same store as the org chart — no sidecar or second port.", "openEmployees": "Open employees", + "skillsTitle": "Skills", + "skillsBody": "Organization skills are generated wrappers for org APIs. Agent Skills stay on FreeOS Personalization — no second skill runtime or sidecar.", + "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" diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 7b0c26ae..b89091da 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -185,6 +185,7 @@ "navAnnouncements": "通知公告", "navOrgChart": "组织架构", "navEmployees": "员工目录", + "navSkills": "技能", "navGovernance": "治理", "announcementsTitle": "通知公告", "announcementsBody": "发布公告并跟踪已读。此页跑在宿主内,不需要边车或第二端口。", @@ -195,6 +196,9 @@ "employeesTitle": "员工目录", "employeesBody": "宿主组织树上的人机员工名册。与组织架构共用同一份数据,不需要边车或第二端口。", "openEmployees": "打开员工目录", + "skillsTitle": "技能", + "skillsBody": "组织技能是调用组织 API 的生成包装。智能体真正执行的仍是「个性化」里的 Agent Skills,没有第二套运行时或边车。", + "openSkills": "打开技能", "governanceTitle": "治理", "governanceBody": "待人工复核与宿主审计日志。高风险工具在有人拍板前保持拦截,不需要边车或第二端口。", "openGovernance": "打开治理" diff --git a/dashboard/src/org-ui/api/createClient.test.ts b/dashboard/src/org-ui/api/createClient.test.ts index 04d14a94..914a2859 100644 --- a/dashboard/src/org-ui/api/createClient.test.ts +++ b/dashboard/src/org-ui/api/createClient.test.ts @@ -46,6 +46,14 @@ describe("createOrgApiClient", () => { enabled: true, }; } + if (path === "/org-module/skills") { + return { + out_dir: "/tmp/org-skills", + skills: [{ slug: "org-employees", module_key: "employees" }], + catalog: [{ key: "employees", generated: true }], + host_packages: [{ id: "pkg-1", name: "Ops", skill_count: 2 }], + }; + } throw new Error(`unexpected ${path}`); }); const client = createOrgApiClient({ fetchJson }); @@ -60,6 +68,7 @@ describe("createOrgApiClient", () => { expect((await client.org.tree())[0]?.name).toBe("HQ"); 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(fetchJson).toHaveBeenCalled(); }); diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index b3f71bf6..302499b7 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -215,11 +215,75 @@ export interface OrgGovernanceClient { resolve(pauseId: string, approve: boolean): Promise; } +export interface OrgSkill { + slug: string; + module_key: string; + name: string; + description: string; + label_en: string; + label_zh: string; + directory: string; + skill_md: string; + published: boolean; + plugin_dir: string | null; + content?: string; +} + +export interface OrgSkillCatalogRow { + key: string; + slug: string; + label: string; + label_zh: string; + description: string; + description_zh: string; + generated: boolean; + published: boolean; +} + +export interface HostSkillPackage { + id: string; + name: string; + description: string; + skill_count: number; +} + +export interface OrgSkillList { + out_dir: string; + skills: OrgSkill[]; + catalog: OrgSkillCatalogRow[]; + host_packages: HostSkillPackage[]; +} + +export interface OrgSkillGenerateResult { + out_dir: string; + skills: Array<{ + slug: string; + module_key: string; + directory: string; + }>; +} + +export interface OrgSkillPublishResult { + plugin_id: string; + module_key: string; + source_skill: string; + output_dir: string; + notes?: string[]; +} + +export interface OrgSkillsClient { + list(): Promise; + get(slug: string): Promise; + generate(modules?: string[]): Promise; + publish(opts: { slug?: string; skillDir?: string }): Promise; +} + export interface OrgApiClient { announcements: OrgAnnouncementsClient; org: OrgChartClient; employees: OrgEmployeesClient; governance: OrgGovernanceClient; + skills: OrgSkillsClient; } async function unwrap(payload: OrgEnvelope): Promise { @@ -238,10 +302,12 @@ export function createOrgApiClient(opts: { announcementsPrefix?: string; orgPrefix?: string; governancePrefix?: string; + skillsPrefix?: 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 fetchJson = opts.fetchJson; const announcements: OrgAnnouncementsClient = { @@ -465,5 +531,31 @@ export function createOrgApiClient(opts: { }, }; - return { announcements, org, employees, governance }; + const skills: OrgSkillsClient = { + async list() { + return fetchJson(skillsPrefix); + }, + async get(slug) { + return fetchJson(`${skillsPrefix}/${encodeURIComponent(slug)}`); + }, + async generate(modules) { + return fetchJson(`${skillsPrefix}/generate`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(modules ? { modules } : {}), + }); + }, + async publish(opts) { + return fetchJson(`${skillsPrefix}/publish`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + slug: opts.slug || "", + skill_dir: opts.skillDir || "", + }), + }); + }, + }; + + return { announcements, org, employees, governance, skills }; } diff --git a/dashboard/src/org-ui/contract.ts b/dashboard/src/org-ui/contract.ts index 8b1364e7..ab2a27b5 100644 --- a/dashboard/src/org-ui/contract.ts +++ b/dashboard/src/org-ui/contract.ts @@ -3,6 +3,7 @@ export const SHARED_ORG_UI_MODULES = [ "announcements", "organization", "employees", + "skills", "governance", ] as const; diff --git a/dashboard/src/org-ui/index.ts b/dashboard/src/org-ui/index.ts index e65f7ab5..2dc4f5ac 100644 --- a/dashboard/src/org-ui/index.ts +++ b/dashboard/src/org-ui/index.ts @@ -20,9 +20,16 @@ export type { OrgChartClient, OrgDepartment, OrgEmployee, + HostSkillPackage, OrgEmployeesClient, OrgEnvelope, OrgGovernanceClient, + OrgSkill, + OrgSkillCatalogRow, + OrgSkillGenerateResult, + OrgSkillList, + OrgSkillPublishResult, + OrgSkillsClient, } from "./api/createClient"; export { AnnouncementPage } from "./pages/announcements/AnnouncementPage"; export type { AnnouncementPageProps } from "./pages/announcements/AnnouncementPage"; @@ -32,6 +39,8 @@ export { EmployeesPage } from "./pages/employees/EmployeesPage"; export type { EmployeesPageProps } from "./pages/employees/EmployeesPage"; export { EmployeeDetailPage } from "./pages/employees/EmployeeDetailPage"; export type { EmployeeDetailPageProps } from "./pages/employees/EmployeeDetailPage"; +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 type { diff --git a/dashboard/src/org-ui/pages/skills/SkillsPage.module.css b/dashboard/src/org-ui/pages/skills/SkillsPage.module.css new file mode 100644 index 00000000..fcaa70e1 --- /dev/null +++ b/dashboard/src/org-ui/pages/skills/SkillsPage.module.css @@ -0,0 +1,165 @@ +.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 { + 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/skills/SkillsPage.test.tsx b/dashboard/src/org-ui/pages/skills/SkillsPage.test.tsx new file mode 100644 index 00000000..3c8f31b7 --- /dev/null +++ b/dashboard/src/org-ui/pages/skills/SkillsPage.test.tsx @@ -0,0 +1,149 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import { SkillsPage } from "./SkillsPage"; +import type { OrgSkillsClient } from "../../api/createClient"; + +function mockClient(): OrgSkillsClient { + return { + list: vi.fn(async () => ({ + out_dir: "/tmp/org-skills", + skills: [ + { + slug: "org-employees", + module_key: "employees", + name: "org-employees", + description: "Call the employees module", + label_en: "People & agents", + label_zh: "人机资源", + directory: "/tmp/org-skills/org-employees", + skill_md: "/tmp/org-skills/org-employees/SKILL.md", + published: false, + plugin_dir: null, + }, + ], + catalog: [ + { + key: "employees", + slug: "org-employees", + label: "People & agents", + label_zh: "人机资源", + description: "Department employees", + description_zh: "已入部门的员工", + generated: true, + published: false, + }, + { + key: "skills", + slug: "org-skills", + label: "Skill plugins", + label_zh: "技能插件", + description: "Agent skill catalog", + description_zh: "技能目录", + generated: false, + published: false, + }, + ], + host_packages: [ + { + id: "pkg-1", + name: "Ops pack", + description: "Host runtime package", + skill_count: 3, + }, + ], + })), + get: vi.fn(async () => ({ + slug: "org-employees", + module_key: "employees", + name: "org-employees", + description: "Call the employees module", + label_en: "People & agents", + label_zh: "人机资源", + directory: "/tmp/org-skills/org-employees", + skill_md: "/tmp/org-skills/org-employees/SKILL.md", + published: false, + plugin_dir: null, + content: "---\nname: org-employees\n---\n# Employees", + })), + generate: vi.fn(async () => ({ + out_dir: "/tmp/org-skills", + skills: [ + { + slug: "org-employees", + module_key: "employees", + directory: "/tmp/org-skills/org-employees", + }, + ], + })), + publish: vi.fn(async () => ({ + plugin_id: "org-employees", + module_key: "employees", + source_skill: "/tmp/org-skills/org-employees", + output_dir: "/tmp/org-skills/org-employees.plugin", + notes: ["Draft only"], + })), + }; +} + +const adminSession = { + userId: 1, + displayName: "Ada", + role: "admin", + isAdmin: true, +}; + +const memberSession = { + userId: 2, + displayName: "Mo", + role: "user", + isAdmin: false, +}; + +describe("SkillsPage", () => { + it("renders the shared page without an iframe", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-skills")).toBeInTheDocument(); + expect(screen.getByText("Skills")).toBeInTheDocument(); + expect(screen.getByText("People & agents")).toBeInTheDocument(); + expect(document.querySelector("iframe")).toBeNull(); + expect(client.list).toHaveBeenCalled(); + }); + + it("lets an admin generate through the injected client", async () => { + const client = mockClient(); + render(); + fireEvent.click(await screen.findByTestId("org-skills-generate")); + fireEvent.click(await screen.findByTestId("org-skills-confirm-generate")); + await waitFor(() => { + expect(client.generate).toHaveBeenCalled(); + }); + }); + + it("hides generate for non-admins and still lists catalog skills", async () => { + const client = mockClient(); + render(); + expect(await screen.findByTestId("org-ui-skills")).toBeInTheDocument(); + expect(screen.queryByTestId("org-skills-generate")).toBeNull(); + expect( + screen.getByText("Generate and publish are limited to administrators."), + ).toBeInTheDocument(); + expect(screen.getByText("People & agents")).toBeInTheDocument(); + }); + + it("opens skill content and publishes a draft", async () => { + const client = mockClient(); + render(); + fireEvent.click(await screen.findByTestId("org-skill-card-org-employees")); + expect(await screen.findByTestId("org-skills-content")).toHaveTextContent( + "org-employees", + ); + fireEvent.click(screen.getByTestId("org-skills-publish-org-employees")); + fireEvent.click( + await screen.findByTestId("org-skills-confirm-publish-org-employees"), + ); + await waitFor(() => { + expect(client.publish).toHaveBeenCalledWith({ slug: "org-employees" }); + }); + }); +}); diff --git a/dashboard/src/org-ui/pages/skills/SkillsPage.tsx b/dashboard/src/org-ui/pages/skills/SkillsPage.tsx new file mode 100644 index 00000000..2cfcf1a8 --- /dev/null +++ b/dashboard/src/org-ui/pages/skills/SkillsPage.tsx @@ -0,0 +1,388 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Button, Input, Modal, Space, Table, Tabs, Tag, Typography } from "antd"; +import { Package, RefreshCw, Search } from "lucide-react"; +import type { + HostSkillPackage, + OrgSkill, + OrgSkillCatalogRow, + OrgSkillsClient, +} from "../../api/createClient"; +import type { OrgLocale, OrgSession } from "../../shell"; +import { skillsLabels } from "./labels"; +import styles from "./SkillsPage.module.css"; + +export interface SkillsPageProps { + client: OrgSkillsClient; + session: OrgSession; + locale: OrgLocale; + onOpenHostSkills?: () => void; + /** Present so export App.tsx can pass SHARED_ORG_UI_MODULES without unused props. */ + modules?: readonly string[]; +} + +function catalogLabel(row: OrgSkillCatalogRow, locale: OrgLocale): string { + return locale === "zh" ? row.label_zh || row.label : row.label; +} + +function catalogDescription( + row: OrgSkillCatalogRow, + locale: OrgLocale, +): string { + return locale === "zh" + ? row.description_zh || row.description + : row.description; +} + +export function SkillsPage({ + client, + session, + locale, + onOpenHostSkills, +}: SkillsPageProps) { + const labels = skillsLabels(locale); + const [skills, setSkills] = useState([]); + const [catalog, setCatalog] = useState([]); + const [hostPackages, setHostPackages] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(""); + const [notice, setNotice] = useState(""); + const [search, setSearch] = useState(""); + const [busy, setBusy] = useState(""); + const [detail, setDetail] = useState(null); + + const fetchAll = useCallback(async () => { + setLoading(true); + setError(""); + try { + const payload = await client.list(); + setSkills(payload.skills); + setCatalog(payload.catalog); + setHostPackages(payload.host_packages); + } catch { + setSkills([]); + setCatalog([]); + setHostPackages([]); + setError(labels.loadFailed); + } finally { + setLoading(false); + } + }, [client, labels.loadFailed]); + + useEffect(() => { + void fetchAll(); + }, [fetchAll]); + + const query = search.trim().toLowerCase(); + const visibleCatalog = useMemo( + () => + query + ? catalog.filter((row) => { + const hay = `${row.key} ${row.slug} ${row.label} ${row.label_zh} ${row.description} ${row.description_zh}`.toLowerCase(); + return hay.includes(query); + }) + : catalog, + [catalog, query], + ); + const visibleHost = useMemo( + () => + query + ? hostPackages.filter((row) => { + const hay = `${row.name} ${row.description} ${row.id}`.toLowerCase(); + return hay.includes(query); + }) + : hostPackages, + [hostPackages, query], + ); + + const generatedCount = catalog.filter((row) => row.generated).length; + const publishedCount = catalog.filter((row) => row.published).length; + + const generate = async (modules?: string[]) => { + setBusy(modules?.[0] || "all"); + setError(""); + setNotice(""); + try { + await client.generate(modules); + setNotice(labels.generateDone); + await fetchAll(); + } catch { + setError(labels.generateFailed); + } finally { + setBusy(""); + } + }; + + const publish = async (slug: string) => { + setBusy(`publish-${slug}`); + setError(""); + setNotice(""); + try { + await client.publish({ slug }); + setNotice(labels.publishDone); + const next = await client.get(slug); + setDetail(next); + await fetchAll(); + } catch { + setError(labels.publishFailed); + } finally { + setBusy(""); + } + }; + + const openDetail = async (row: OrgSkillCatalogRow) => { + if (!row.generated) return; + try { + setDetail(await client.get(row.slug)); + } catch { + const listed = skills.find((item) => item.slug === row.slug); + setDetail(listed ?? null); + } + }; + + const askGenerate = (modules?: string[]) => { + Modal.confirm({ + title: labels.confirmGenerate, + okText: labels.generate, + okButtonProps: { "data-testid": "org-skills-confirm-generate" }, + onOk: () => generate(modules), + }); + }; + + const askPublish = (slug: string) => { + Modal.confirm({ + title: labels.confirmPublish, + okText: labels.publish, + okButtonProps: { "data-testid": `org-skills-confirm-publish-${slug}` }, + onOk: () => publish(slug), + }); + }; + + return ( +
+
+
+ +
+ + {labels.title} + +

{labels.subtitle}

+
+
+ + {session.isAdmin ? ( + + ) : null} + + +
+ + {error ? {error} : null} + {notice ? ( + {notice} + ) : null} + +
+
+
{generatedCount}
+
{labels.generated}
+
+
+
{publishedCount}
+
{labels.published}
+
+
+
{hostPackages.length}
+
{labels.hostPackages}
+
+
+
{catalog.length}
+
{labels.catalog}
+
+
+ + {session.isAdmin ? null : ( +

{labels.adminOnly}

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

{labels.loading}

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

{labels.emptyOrg}

+

{labels.emptyOrgHint}

+
+ ) : ( +
+ {visibleCatalog.map((row) => ( + + ) : null} + + + ))} +
+ )} + + ), + }, + { + key: "host", + label: labels.hostTab, + children: ( + <> +

{labels.hostHint}

+ {onOpenHostSkills ? ( + + ) : null} + {loading ? ( +

{labels.loading}

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

{labels.emptyHost}

+

{labels.emptyHostHint}

+
+ ) : ( +
+ value || "—", + }, + ]} + /> + + )} + + ), + }, + ]} + /> + + setDetail(null)} + footer={ + + {session.isAdmin && detail && !detail.published ? ( + + ) : null} + + + } + > + {detail ? ( +
+
+ {labels.module} + {detail.module_key} +
+
+ {labels.slug} + {detail.slug} +
+
+ {labels.status} + + {detail.published ? labels.draftReady : labels.generatedOnly} + +
+
+              {detail.content || detail.description}
+            
+
+ ) : null} +
+ + ); +} diff --git a/dashboard/src/org-ui/pages/skills/labels.ts b/dashboard/src/org-ui/pages/skills/labels.ts new file mode 100644 index 00000000..ae891aa3 --- /dev/null +++ b/dashboard/src/org-ui/pages/skills/labels.ts @@ -0,0 +1,104 @@ +import type { OrgLocale } from "../../shell"; + +const EN = { + title: "Skills", + subtitle: + "Organization skills are generated wrappers that call org APIs. Agent Skills stay on FreeOS — this page does not start a second skill runtime.", + loading: "Loading skills…", + refresh: "Refresh", + generate: "Generate from catalog", + generateOne: "Generate", + generating: "Generating…", + publish: "Publish draft", + publishing: "Publishing…", + search: "Search skills, modules, or packages…", + orgTab: "Organization skills", + hostTab: "Host Agent Skills", + generated: "Generated", + published: "Published drafts", + hostPackages: "Host packages", + catalog: "Catalog modules", + emptyOrg: "No organization skills generated yet.", + emptyOrgHint: + "Generate SKILL.md trees from the Open-12 catalog. They call host /api/org-module routes — they are not a sidecar marketplace.", + emptyHost: "No host skill packages yet.", + emptyHostHint: + "Create and edit packages on Personalization → Skill packages. Agents run those skills; this page only lists them.", + adminOnly: "Generate and publish are limited to administrators.", + hostHint: + "These packages are the same FreeOS Agent Skills catalog. Open Personalization to install or edit them.", + openHost: "Open Agent Skills", + detail: "Skill content", + close: "Close", + module: "Module", + slug: "Slug", + status: "Status", + notGenerated: "Not generated", + draftReady: "Draft published", + generatedOnly: "Generated", + hostPackage: "Skill package", + skillCount: "Skills", + loadFailed: "Could not load skills", + generateFailed: "Could not generate skills", + publishFailed: "Could not publish this skill", + generateDone: "Generated organization skills from the catalog.", + publishDone: "Wrote a tenant-toggleable plugin draft. It is not enabled.", + confirmGenerate: + "Write SKILL.md trees under the host org-skills directory? Existing files for the same modules are overwritten.", + confirmPublish: + "Publish a plugin draft next to this skill? It stays disabled until a tenant opts in.", +}; + +const ZH: typeof EN = { + title: "技能", + subtitle: + "组织技能是调用组织 API 的生成包装,不是第二套运行时。智能体真正执行的仍是 FreeOS「个性化 → 技能 / 技能包」。", + loading: "加载技能…", + refresh: "刷新", + generate: "从目录生成", + generateOne: "生成", + generating: "正在生成…", + publish: "发布草稿", + publishing: "正在发布…", + search: "搜索技能、模块或技能包…", + orgTab: "组织技能", + hostTab: "宿主 Agent Skills", + generated: "已生成", + published: "已发布草稿", + hostPackages: "宿主技能包", + catalog: "目录模块", + emptyOrg: "还没有生成组织技能。", + emptyOrgHint: + "从 Open-12 目录生成 SKILL.md。它们走宿主 /api/org-module,不是边车技能市场。", + emptyHost: "还没有宿主技能包。", + emptyHostHint: + "请到「个性化 → 技能包」创建或编辑。智能体运行那些技能;本页只列出,不另造运行时。", + adminOnly: "生成与发布仅管理员可用。", + hostHint: + "这些技能包就是 FreeOS Agent Skills 目录。安装与编辑请到个性化页。", + openHost: "打开 Agent Skills", + detail: "技能内容", + close: "关闭", + module: "模块", + slug: "标识", + status: "状态", + notGenerated: "未生成", + draftReady: "草稿已发布", + generatedOnly: "已生成", + hostPackage: "技能包", + skillCount: "技能数", + loadFailed: "无法加载技能", + generateFailed: "无法生成技能", + publishFailed: "无法发布该技能", + generateDone: "已从目录生成组织技能。", + publishDone: "已写出按租户开关的插件草稿,默认不启用。", + confirmGenerate: + "在宿主 org-skills 目录写入 SKILL.md?同名模块的已有文件会被覆盖。", + confirmPublish: "在该技能旁写出插件草稿?默认保持关闭,需租户自行打开。", +}; + +export type SkillsLabels = typeof EN; + +export function skillsLabels(locale: OrgLocale): SkillsLabels { + return locale === "zh" ? ZH : EN; +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx index 15306efc..99be2ffa 100644 --- a/dashboard/src/pages/Organization/index.test.tsx +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -24,6 +24,10 @@ function renderOrg() { path="/organization/employees" element={
employees
} /> + skills} + /> governance} @@ -161,6 +165,7 @@ describe("OrganizationPage", () => { expect(screen.getByTestId("org-open-announcements")).toBeInTheDocument(); expect(screen.getByTestId("org-open-chart")).toBeInTheDocument(); expect(screen.getByTestId("org-open-employees")).toBeInTheDocument(); + expect(screen.getByTestId("org-open-skills")).toBeInTheDocument(); expect(screen.getByTestId("org-open-governance")).toBeInTheDocument(); expect(screen.queryByTestId("org-mini-browser")).toBeNull(); expect(screen.queryByTestId("org-browser-frame")).toBeNull(); @@ -231,6 +236,13 @@ describe("OrganizationPage", () => { expect(screen.queryByTestId("org-browser-frame")).toBeNull(); }); + it("opens in-host skills without a sidecar iframe", async () => { + renderOrg(); + fireEvent.click(await screen.findByTestId("org-open-skills")); + expect(await screen.findByTestId("org-skills-page")).toBeInTheDocument(); + expect(screen.queryByTestId("org-browser-frame")).toBeNull(); + }); + it("opens in-host governance without a sidecar iframe", async () => { renderOrg(); fireEvent.click(await screen.findByTestId("org-open-governance")); diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index c9c5f757..20bdb5e7 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -450,6 +450,22 @@ export default function OrganizationPage() { {t("organization.openEmployees")} +
+ +

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

+

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

+ +

diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index 14f8bc05..cd3d482b 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,10 +1,16 @@ -import { Building2, Megaphone, Network, Shield, Users } from "lucide-react"; +import { Building2, Megaphone, Network, Package, Shield, Users } from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import type { PathTabsConfig } from "../../layouts/PageShell"; export function useOrgPathTabs( - active: "workbench" | "announcements" | "org" | "employees" | "governance", + active: + | "workbench" + | "announcements" + | "org" + | "employees" + | "skills" + | "governance", ): PathTabsConfig { const { t } = useTranslation(); const navigate = useNavigate(); @@ -31,6 +37,11 @@ export function useOrgPathTabs( label: t("organization.navEmployees"), icon: , }, + { + value: "skills", + label: t("organization.navSkills"), + icon: , + }, { value: "governance", label: t("organization.navGovernance"), @@ -50,6 +61,10 @@ export function useOrgPathTabs( navigate("/organization/employees"); return; } + if (value === "skills") { + navigate("/organization/skills"); + return; + } if (value === "governance") { navigate("/organization/governance"); return; diff --git a/dashboard/src/pages/Organization/skills/index.tsx b/dashboard/src/pages/Organization/skills/index.tsx new file mode 100644 index 00000000..e15bc21b --- /dev/null +++ b/dashboard/src/pages/Organization/skills/index.tsx @@ -0,0 +1,49 @@ +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 { + SkillsPage, + createOrgApiClient, + type OrgSession, +} from "../../../org-ui"; +import { useOrgPathTabs } from "../orgPathTabs"; + +export default function OrganizationSkillsPage() { + const { t, i18n } = useTranslation(); + const navigate = useNavigate(); + const user = useCurrentUser(); + const pathTabs = useOrgPathTabs("skills"); + 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("/personalization/skill-packages")} + /> + + ); +} diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 8ba4c808..2bc1175f 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -70,6 +70,7 @@ describe("pathPermissionKeys", () => { expect(pathPermissionKeys("/organization/announcements")).toBeNull(); expect(pathPermissionKeys("/organization/org")).toBeNull(); expect(pathPermissionKeys("/organization/employees")).toBeNull(); + expect(pathPermissionKeys("/organization/skills")).toBeNull(); expect(pathPermissionKeys("/organization/governance")).toBeNull(); expect(pathPermissionKeys("/projects")).toBeNull(); expect(pathPermissionKeys("/tasks")).toBeNull(); @@ -156,6 +157,7 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/organization/employees/4")).toBe( "organization", ); + expect(resolveSelectedKey("/organization/skills")).toBe("organization"); expect(resolveSelectedKey("/organization/governance")).toBe("organization"); }); diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index b01fe6b2..ea0f29ec 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -28,6 +28,9 @@ const OrganizationEmployeesPage = lazy( const OrganizationEmployeeDetailPage = lazy( () => import("../pages/Organization/employees/detail"), ); +const OrganizationSkillsPage = lazy( + () => import("../pages/Organization/skills"), +); const OrganizationGovernancePage = lazy( () => import("../pages/Organization/governance"), ); @@ -87,6 +90,7 @@ export const pathToKey: Record = { "/organization/announcements": "organization", "/organization/org": "organization", "/organization/employees": "organization", + "/organization/skills": "organization", "/organization/governance": "organization", "/projects": "projects", "/tasks": "projects", @@ -234,6 +238,10 @@ export const routeConfigs: RouteConfig[] = [ path: "/organization/employees/:id", element: , }, + { + path: "/organization/skills", + element: , + }, { path: "/organization/governance", element: , diff --git a/docs/architecture-integration.md b/docs/architecture-integration.md index fe2d7520..9d512c22 100644 --- a/docs/architecture-integration.md +++ b/docs/architecture-integration.md @@ -290,7 +290,7 @@ The Phase A work sits on the existing FreeOS bootstrap: | Self-growth loop | Bridge | `freeos org loop run` · `/api/org-module/loop/run` | | Colleague agents | Host agents table | `org-` · `{home}/org-agents/` · tenant `routing.json` | | Org APIs | Host BFF + optional sidecar | `/api/org-module/*` in-host (announcements + org chart CRUD are in-host); sidecar `/api/org`, `/api/employees`, … until each remaining CRUD slice moves (see [org-merge-plan.md](org-merge-plan.md)) | -| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/governance` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; governance reads host pauses/audit). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | +| Org UI | Host Dashboard (`org-ui`) | Native `/organization` workbench + `/organization/announcements` + `/organization/org` + `/organization/employees` + `/organization/skills` + `/organization/governance` (shared `dashboard/src/org-ui`; directory employees reuse org-chart SQLite; skills list host `org-skills` + Agent skill packages; governance reads host pauses/audit). Standalone site is **exported** from the same source (`freeos org export-standalone`). Sidecar `:3780` iframe is opt-in compat only | ### Auth and tenant mapping diff --git a/docs/org-merge-plan.md b/docs/org-merge-plan.md index 0da1501a..4232d68f 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 + 治理 pauses/audit + `/organization/org` + `/organization/employees` + `/organization/governance` + `org-ui` OrgChartPage / EmployeesPage / 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` + `org-ui` OrgChartPage / EmployeesPage / SkillsPage / GovernancePage + 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 | 未开始 | @@ -58,7 +58,7 @@ Catalog 十二键(`catalog.py` ↔ `open-module-catalog.ts`): | **announcements** | OpenApp `/announcements` | sidecar `/api/announcements` | **Phase 2 切片** | | organization | OpenApp `/org` | sidecar `/api/org` | **Phase 3 切片已落地**:`/organization/org` + `/api/org-module/org` | | employees | OpenApp `/employees` | sidecar `/api/employees` + 宿主 lifecycle `/api/org-module/employees` | **Phase 3 切片已落地**:`/organization/employees` 目录 CRUD 复用 `org_chart.sqlite`;lifecycle / spawn **已在宿主,勿重复造** | -| skills | OpenApp `/skills` | sidecar `/api/skills` + 宿主 `skill_bridge` | Phase 3:目录 UI 迁入;generate/publish **已在宿主** | +| skills | OpenApp `/skills` | sidecar `/api/skills` + 宿主 `skill_bridge` | **Phase 3 切片已落地**:`/organization/skills` 列出 `{FREEOS_HOME}/org-skills` 并 generate/publish;宿主 skill packages 只读列出(Agent Skills 运行时仍在个性化)。未迁:边车市场 / 插件中心 / 付费安装 | | **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 | @@ -116,7 +116,7 @@ uv run freeos org export-standalone --out dist/openxyos-web | `status` · `catalog` · `overview` · `PATCH ""` · `modules` | 宿主 | 保持 | | `assemble` · `produce` · `pack` · `loop/run` | 宿主 loop | 保持 | | `employees` · `employees/transition` · `employees/spawn` | 宿主 lifecycle | 保持;与员工 **目录 CRUD** 分开 | -| `assets/*` · `blueprints/compile` · `skills/generate\|publish` | 宿主工厂 | 保持 | +| `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` 门控 | | `sidecar/*` · `source/download` | 可选兼容 | 留在 Advanced;默认路径不依赖 | @@ -211,7 +211,7 @@ org-ui → IdentityBridge.getSession() | 汇报线、职级、技能绑定、架构版本 | 商业 OrgChart 扩展,不是本垂直切片的最低可用集 | | 导入文件 / 导出 PNG·SVG·PDF | 依赖 sidecar 上传与 html-to-image/jsPDF | | 头像上传与预设图 | 需要独立文件存储 | -| 其余 Open-12(工作台扩展、技能、智能体、任务、知识、反思、设置) | 后续切片 | +| 其余 Open-12(工作台扩展、智能体、任务、知识、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -244,7 +244,39 @@ openXYOS sidecar `/api/employees`(人才市场、备选、资产离职清算 | lifecycle spawn / transition UI | 已在宿主;工作台同事列表与 Experts 已覆盖 | | 人才市场、备选入职、reserve ↔ internal | sidecar 另表;本切片不做第二套 talent DB | | 资产离职清算、绩效、汇报线、技能绑定、头像上传 | 依赖未迁资源或文件存储 | -| 其余 Open-12(技能、智能体、任务、知识、反思、设置) | 后续切片 | +| 其余 Open-12(智能体、任务、知识、反思、设置) | 后续切片 | +| `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | + +--- + +## Phase 3 进度:技能(本切片) + +按同一缝落地,**只迁组织技能目录**。不另造技能运行时:generate/publish **已在** `org_os/skill_bridge`,Agent Skills 仍走宿主技能包。 + +### 数据模型(单一 SoT) + +| 表面 | 存储 | API | 含义 | +|---|---|---|---| +| 组织模块技能 | `{FREEOS_HOME}/org-skills/org-/` | `GET /api/org-module/skills` · `GET …/skills/{slug}` · `POST …/generate` · `POST …/publish` | 目录生成的 SKILL.md;调用组织 API,不是第二套 agent runtime | +| 宿主 Agent Skills | Octop `skill_packages` 表 | 同一 `GET /skills` 的 `host_packages`;编辑仍在 `/personalization/skill-packages` | 智能体真正执行的技能包 | + +openXYOS sidecar `/api/skills`(市场、插件中心、付费安装)仍是可选兼容面,默认路径不读它。 + +### 已落地 + +1. 壳无关组件:`dashboard/src/org-ui/pages/skills`(`SkillsPage`),数据经 `createOrgApiClient().skills`。 +2. Dashboard 子路由:`/organization/skills`(工作台 path tabs + 入口卡)。 +3. 宿主 API:已有 generate / publish;本切片补 `GET /skills` 与 `GET /skills/{slug}`。写操作走 `require_permission("plugins")`(管理员绕过)。 +4. 导出骨架:`freeos org export-standalone` 模块列表与 `App.tsx` 同时导入 `SkillsPage`。 + +### 本切片明确推迟 + +| 推迟项 | 原因 | +|---|---| +| Chat / 从技能页发起会话 | 对话运行时留在 FreeOS/Octop | +| 边车技能市场 / 插件中心 / 付费安装 | sidecar SQL.js;本波不迁商业市场 | +| 在组织页里编辑 Agent Skills | 运行时已在个性化;本页只读列出并跳转 | +| 其余 Open-12(智能体、任务、知识、反思、设置) | 后续切片 | | `freeos org export-standalone` 完整 Vite/Node 打包 | Phase 5 | --- @@ -277,7 +309,7 @@ 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 | --- diff --git a/scripts/org-export/README.md b/scripts/org-export/README.md index fdf0b93c..db1ccf08 100644 --- a/scripts/org-export/README.md +++ b/scripts/org-export/README.md @@ -3,7 +3,8 @@ `freeos org export-standalone --out dist/openxyos-web` writes a scaffold that **imports the same org-ui pages** Dashboard mounts under `/organization/...`. -Phase 3 includes **Announcements**, **Org chart**, **Employees**, and **Governance**: +Phase 3 includes **Announcements**, **Org chart**, **Employees**, **Skills**, +and **Governance**: | Delivery | Route | Component | |---|---|---| @@ -14,11 +15,14 @@ Phase 3 includes **Announcements**, **Org chart**, **Employees**, and **Governan | Dashboard | `/organization/employees` | `dashboard/src/org-ui` → `EmployeesPage` | | Dashboard | `/organization/employees/:id` | `dashboard/src/org-ui` → `EmployeeDetailPage` | | Standalone (this export) | `/employees` | same import | +| Dashboard | `/organization/skills` | `dashboard/src/org-ui` → `SkillsPage` | +| Standalone (this export) | `/skills` | same import | | Dashboard | `/organization/governance` | `dashboard/src/org-ui` → `GovernancePage` | | Standalone (this export) | `/governance` | same import | Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org -chart. Governance pauses/audit live under `{FREEOS_HOME}/governance/`. +chart. Organization skills live under `{FREEOS_HOME}/org-skills/` (skill_bridge). +Governance pauses/audit live under `{FREEOS_HOME}/governance/`. 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 46644560..bc50cdc6 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, and employees directory, sidecar, and growth loop.", + "description": "Organization OS: FreeOS data plane ↔ openXYOS control plane, in-host announcements, org chart, employees, skills, and governance, sidecar, and growth loop.", }, { "name": "browser", diff --git a/src/octop/api/routers/org_module.py b/src/octop/api/routers/org_module.py index 19ce57ed..bccd0b21 100644 --- a/src/octop/api/routers/org_module.py +++ b/src/octop/api/routers/org_module.py @@ -4,6 +4,7 @@ import asyncio from contextlib import suppress +from pathlib import Path from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, Response @@ -317,10 +318,37 @@ class SkillsGenerateBody(BaseModel): class SkillsPublishBody(BaseModel): - skill_dir: str + skill_dir: str = "" + slug: str = "" out_dir: str | None = None +def _org_skills_root(server: OctopServer) -> Path: + from octop.modules.org_os.skill_bridge.inventory import default_org_skills_dir + + return default_org_skills_dir(_service(server).home) + + +def _host_skill_packages(server: OctopServer) -> list[dict[str, Any]]: + services = getattr(server, "services", None) + repo = getattr(services, "skill_package_repo", None) if services is not None else None + if repo is None: + return [] + try: + rows = repo.list_all() + except Exception: + return [] + return [ + { + "id": row.id, + "name": row.name, + "description": row.description, + "skill_count": int(getattr(row, "skill_count", 0) or 0), + } + for row in rows + ] + + @router.post("/governance/check", summary="Policy-check a tool (default-deny high-risk)") async def governance_check( body: GovernanceCheckBody, @@ -395,6 +423,42 @@ async def governance_audit( return {"events": engine.store.tail_audit(limit)} +@router.get("/skills", summary="List generated org-module skills and host skill packages") +async def skills_list( + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + from octop.modules.org_os.skill_bridge.inventory import ( + catalog_coverage, + list_generated_skills, + ) + + root = _org_skills_root(server) + return { + "out_dir": str(root), + "skills": [item.to_list_dict() for item in list_generated_skills(root)], + "catalog": catalog_coverage(root), + "host_packages": _host_skill_packages(server), + } + + +@router.get("/skills/{slug}", summary="Read a generated org-module skill") +async def skills_get( + slug: str, + server: OctopServer = Depends(get_server), + _user: Any = Depends(current_user), +) -> dict[str, Any]: + from octop.modules.org_os.skill_bridge.inventory import read_generated_skill + + try: + record = read_generated_skill(_org_skills_root(server), slug) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"unknown skill: {slug}") from exc + return record.to_detail_dict() + + @router.post("/skills/generate", summary="Generate FreeOS skills from the openXYOS catalog") async def skills_generate( body: SkillsGenerateBody, @@ -419,11 +483,22 @@ async def skills_publish( ) -> dict[str, Any]: from pathlib import Path + from octop.modules.org_os.skill_bridge.inventory import read_generated_skill from octop.modules.org_os.skill_bridge.publish import publish_skill + skill_dir = Path(body.skill_dir) if body.skill_dir else None + if skill_dir is None and body.slug: + try: + skill_dir = read_generated_skill(_org_skills_root(server), body.slug).directory + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except FileNotFoundError as exc: + raise HTTPException(status_code=404, detail=f"unknown skill: {body.slug}") from exc + if skill_dir is None: + raise HTTPException(status_code=400, detail="skill_dir or slug required") out = Path(body.out_dir) if body.out_dir else None try: - draft = publish_skill(Path(body.skill_dir), out_dir=out) + draft = publish_skill(skill_dir, out_dir=out) except ValueError as exc: raise HTTPException(status_code=400, detail=str(exc)) from exc return draft.to_dict() diff --git a/src/octop/cli/commands/org.py b/src/octop/cli/commands/org.py index 1899ce0c..1b7a9830 100644 --- a/src/octop/cli/commands/org.py +++ b/src/octop/cli/commands/org.py @@ -385,8 +385,8 @@ def export_standalone(out_dir: Path) -> None: """Scaffold a standalone Organization web from shared org-ui (Phase 3). Writes the shared module list and an App.tsx that imports AnnouncementPage, - OrgChartPage, EmployeesPage, and GovernancePage. Full Vite + Node packaging - is Phase 5 (TODO in the generated README). + OrgChartPage, EmployeesPage, SkillsPage, and GovernancePage. Full Vite + + Node packaging is Phase 5 (TODO in the generated README). """ from octop.modules.org_os.export_standalone import write_standalone_scaffold diff --git a/src/octop/modules/org_os/contract.py b/src/octop/modules/org_os/contract.py index 9e823805..498bec8d 100644 --- a/src/octop/modules/org_os/contract.py +++ b/src/octop/modules/org_os/contract.py @@ -9,6 +9,7 @@ "announcements", "organization", "employees", + "skills", "governance", ) diff --git a/src/octop/modules/org_os/export_standalone.py b/src/octop/modules/org_os/export_standalone.py index 1c4da9c5..639cb2d9 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, and GovernancePage sources Dashboard mounts -under ``/organization/...``. +OrgChartPage, EmployeesPage, SkillsPage, and GovernancePage sources +Dashboard mounts under ``/organization/...``. """ from __future__ import annotations @@ -27,6 +27,7 @@ | announcements | `/organization/announcements` | `/announcements` | `dashboard/src/org-ui` → `AnnouncementPage` | | organization | `/organization/org` | `/org` | `dashboard/src/org-ui` → `OrgChartPage` | | 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` | Do **not** copy those pages into a second tree. Edit `dashboard/src/org-ui`. @@ -34,6 +35,8 @@ Directory employees share `{FREEOS_HOME}/org/org_chart.sqlite` with the org chart. Host lifecycle colleagues stay on `GET /api/org-module/employees`. 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. ## Phase 3 vs Phase 5 @@ -41,7 +44,7 @@ - Shared module list (`src/modules.json`) - Thin `src/App.tsx` that imports `AnnouncementPage`, `OrgChartPage`, - `EmployeesPage`, and `GovernancePage` + `EmployeesPage`, `SkillsPage`, and `GovernancePage` - Documents IdentityBridge: embedded mode uses FreeOS JWT; standalone uses local JWT Phase 5 (TODO — not implemented here): @@ -68,6 +71,7 @@ GovernancePage, OrgChartPage, SHARED_ORG_UI_MODULES, + SkillsPage, } from "org-ui"; import type { OrgAnnouncementsClient, @@ -75,13 +79,14 @@ OrgEmployeesClient, OrgGovernanceClient, OrgSession, + OrgSkillsClient, } from "org-ui"; /** * 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, - * and /organization/governance. + * /organization/skills, and /organization/governance. */ const session: OrgSession = { userId: 0, @@ -94,6 +99,7 @@ client: OrgAnnouncementsClient; orgClient: OrgChartClient; employeesClient: OrgEmployeesClient; + skillsClient: OrgSkillsClient; governanceClient: OrgGovernanceClient; locale?: "zh" | "en"; }) { @@ -124,6 +130,12 @@ employeeId={0} modules={SHARED_ORG_UI_MODULES} /> + dict[str, Any]: "api": "/api/org-module/org/employees", "store": "{FREEOS_HOME}/org/org_chart.sqlite", }, + "skills": { + "component": "SkillsPage", + "embedded_route": "/organization/skills", + "standalone_route": "/skills", + "api": "/api/org-module/skills", + "store": "{FREEOS_HOME}/org-skills", + }, "governance": { "component": "GovernancePage", "embedded_route": "/organization/governance", diff --git a/src/octop/modules/org_os/skill_bridge/README.md b/src/octop/modules/org_os/skill_bridge/README.md index 9973e486..d70d1e73 100644 --- a/src/octop/modules/org_os/skill_bridge/README.md +++ b/src/octop/modules/org_os/skill_bridge/README.md @@ -22,6 +22,11 @@ uv run freeos org skills generate --module employees Default output: `{FREEOS_HOME}/org-skills/org-/`. +In-host UI: `GET /api/org-module/skills` lists generated trees and host +skill packages; `GET /api/org-module/skills/{slug}` reads `SKILL.md`. +Generate / publish stay admin-gated (`plugins`). This is **not** a second +skill runtime — Agent Skills still live under Personalization. + Each skill: - YAML frontmatter (`name`, `description`, `metadata.freeos.module_key`) diff --git a/src/octop/modules/org_os/skill_bridge/__init__.py b/src/octop/modules/org_os/skill_bridge/__init__.py index c8eb8602..03246c8a 100644 --- a/src/octop/modules/org_os/skill_bridge/__init__.py +++ b/src/octop/modules/org_os/skill_bridge/__init__.py @@ -2,10 +2,20 @@ from octop.modules.org_os.skill_bridge.endpoints import MODULE_ENDPOINTS from octop.modules.org_os.skill_bridge.generate import generate_module_skills +from octop.modules.org_os.skill_bridge.inventory import ( + catalog_coverage, + default_org_skills_dir, + list_generated_skills, + read_generated_skill, +) from octop.modules.org_os.skill_bridge.publish import publish_skill __all__ = [ "MODULE_ENDPOINTS", + "catalog_coverage", + "default_org_skills_dir", "generate_module_skills", + "list_generated_skills", "publish_skill", + "read_generated_skill", ] diff --git a/src/octop/modules/org_os/skill_bridge/inventory.py b/src/octop/modules/org_os/skill_bridge/inventory.py new file mode 100644 index 00000000..f33fae29 --- /dev/null +++ b/src/octop/modules/org_os/skill_bridge/inventory.py @@ -0,0 +1,161 @@ +"""List / read generated org-module skills on the host (no second runtime).""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +from octop.modules.org_os.catalog import OPENXYOS_MODULES +from octop.modules.org_os.skill_bridge.generate import skill_slug + +_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,79}$") +_FRONTMATTER_NAME = re.compile(r"^name:\s*([a-z0-9][a-z0-9._-]*)\s*$", re.MULTILINE) +_MODULE_KEY = re.compile(r"module_key:\s*([a-z0-9-]+)") +_DESCRIPTION = re.compile(r"^description:\s*(.+)$", re.MULTILINE) +_LABEL_EN = re.compile(r"^\s+en:\s*(.+)$", re.MULTILINE) +_LABEL_ZH = re.compile(r"^\s+zh:\s*(.+)$", re.MULTILINE) + + +def default_org_skills_dir(home: Path) -> Path: + return Path(home) / "org-skills" + + +def validate_skill_slug(slug: str) -> str: + cleaned = (slug or "").strip() + if not _SLUG_RE.fullmatch(cleaned): + raise ValueError("invalid skill slug") + return cleaned + + +@dataclass(frozen=True) +class OrgSkillRecord: + slug: str + module_key: str + name: str + description: str + label_en: str + label_zh: str + directory: Path + skill_md: Path + published: bool + plugin_dir: Path | None + content: str = "" + + def to_list_dict(self) -> dict[str, Any]: + return { + "slug": self.slug, + "module_key": self.module_key, + "name": self.name, + "description": self.description, + "label_en": self.label_en, + "label_zh": self.label_zh, + "directory": str(self.directory), + "skill_md": str(self.skill_md), + "published": self.published, + "plugin_dir": str(self.plugin_dir) if self.plugin_dir else None, + } + + def to_detail_dict(self) -> dict[str, Any]: + payload = self.to_list_dict() + payload["content"] = self.content + return payload + + +def _plugin_dir_for(skill_dir: Path, slug: str) -> Path | None: + sibling = skill_dir.parent / f"{slug}.plugin" + if sibling.is_dir() and (sibling / "plugin.yaml").is_file(): + return sibling + return None + + +def _parse_skill_md(text: str, fallback_slug: str, fallback_module: str) -> dict[str, str]: + name_match = _FRONTMATTER_NAME.search(text) + module_match = _MODULE_KEY.search(text) + desc_match = _DESCRIPTION.search(text) + en_match = _LABEL_EN.search(text) + zh_match = _LABEL_ZH.search(text) + slug = name_match.group(1) if name_match else fallback_slug + module_key = module_match.group(1) if module_match else fallback_module + return { + "slug": slug, + "module_key": module_key, + "name": slug, + "description": (desc_match.group(1).strip() if desc_match else ""), + "label_en": (en_match.group(1).strip() if en_match else module_key), + "label_zh": (zh_match.group(1).strip() if zh_match else module_key), + } + + +def _record_from_dir(skill_dir: Path, *, include_content: bool) -> OrgSkillRecord | None: + manifest = skill_dir / "SKILL.md" + if not manifest.is_file(): + return None + text = manifest.read_text(encoding="utf-8") + fallback_module = skill_dir.name.removeprefix("org-") + parsed = _parse_skill_md(text, skill_dir.name, fallback_module) + slug = parsed["slug"] + plugin_dir = _plugin_dir_for(skill_dir, slug) + return OrgSkillRecord( + slug=slug, + module_key=parsed["module_key"], + name=parsed["name"], + description=parsed["description"], + label_en=parsed["label_en"], + label_zh=parsed["label_zh"], + directory=skill_dir, + skill_md=manifest, + published=plugin_dir is not None, + plugin_dir=plugin_dir, + content=text if include_content else "", + ) + + +def list_generated_skills(root: Path) -> list[OrgSkillRecord]: + """Scan ``{home}/org-skills`` for generated SKILL.md trees.""" + if not root.is_dir(): + return [] + rows: list[OrgSkillRecord] = [] + for child in sorted(root.iterdir(), key=lambda path: path.name): + if not child.is_dir() or child.name.endswith(".plugin"): + continue + record = _record_from_dir(child, include_content=False) + if record is not None: + rows.append(record) + return rows + + +def read_generated_skill(root: Path, slug: str) -> OrgSkillRecord: + """Read one generated skill. Raises ValueError for bad slugs, FileNotFoundError if missing.""" + cleaned = validate_skill_slug(slug) + skill_dir = (root / cleaned).resolve() + root_resolved = root.resolve() + if skill_dir != root_resolved and root_resolved not in skill_dir.parents: + raise ValueError("invalid skill slug") + record = _record_from_dir(skill_dir, include_content=True) + if record is None: + raise FileNotFoundError(cleaned) + return record + + +def catalog_coverage(root: Path) -> list[dict[str, Any]]: + """Catalog modules plus whether a generated skill already exists.""" + existing = {row.module_key: row for row in list_generated_skills(root)} + rows: list[dict[str, Any]] = [] + for module in OPENXYOS_MODULES: + key = module["key"] + found = existing.get(key) + rows.append( + { + "key": key, + "slug": skill_slug(key), + "label": module["label"], + "label_zh": module["label_zh"], + "description": module["description"], + "description_zh": module["description_zh"], + "generated": found is not None, + "published": bool(found.published) if found else False, + } + ) + return rows diff --git a/tests/unit/cli/test_org_export_standalone.py b/tests/unit/cli/test_org_export_standalone.py index 823c5674..79dbddfa 100644 --- a/tests/unit/cli/test_org_export_standalone.py +++ b/tests/unit/cli/test_org_export_standalone.py @@ -20,6 +20,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "announcements", "organization", "employees", + "skills", "governance", ] modules = json.loads((out / "src" / "modules.json").read_text(encoding="utf-8")) @@ -27,6 +28,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: "announcements", "organization", "employees", + "skills", "governance", ] assert modules["pages"]["announcements"]["component"] == "AnnouncementPage" @@ -36,6 +38,9 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert modules["pages"]["employees"]["component"] == "EmployeesPage" assert modules["pages"]["employees"]["embedded_route"] == "/organization/employees" assert modules["pages"]["employees"]["api"] == "/api/org-module/org/employees" + assert modules["pages"]["skills"]["component"] == "SkillsPage" + assert modules["pages"]["skills"]["embedded_route"] == "/organization/skills" + assert modules["pages"]["skills"]["api"] == "/api/org-module/skills" assert modules["pages"]["governance"]["component"] == "GovernancePage" assert modules["pages"]["governance"]["embedded_route"] == "/organization/governance" assert modules["pages"]["governance"]["api"] == "/api/org-module/governance" @@ -44,6 +49,7 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "OrgChartPage" in app assert "EmployeesPage" in app assert "EmployeeDetailPage" in app + assert "SkillsPage" in app assert "GovernancePage" in app assert 'from "org-ui"' in app readme = (out / "README.md").read_text(encoding="utf-8") @@ -51,4 +57,5 @@ def test_export_standalone_lists_shared_org_ui_modules(tmp_path: Path) -> None: assert "AnnouncementPage" in readme assert "OrgChartPage" in readme assert "EmployeesPage" in readme + assert "SkillsPage" in readme assert "GovernancePage" in readme diff --git a/tests/unit/test_org_chart.py b/tests/unit/test_org_chart.py index 62f2f973..30cbf6a5 100644 --- a/tests/unit/test_org_chart.py +++ b/tests/unit/test_org_chart.py @@ -43,6 +43,7 @@ def test_shared_organization_module_is_in_catalog() -> None: assert "organization" in SHARED_ORG_UI_MODULES assert "announcements" in SHARED_ORG_UI_MODULES assert "employees" in SHARED_ORG_UI_MODULES + assert "skills" in SHARED_ORG_UI_MODULES assert "governance" 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 e0576696..64067e4f 100644 --- a/tests/unit/test_org_governance.py +++ b/tests/unit/test_org_governance.py @@ -52,6 +52,7 @@ def test_shared_governance_module_is_in_catalog() -> None: assert "announcements" in SHARED_ORG_UI_MODULES assert "organization" in SHARED_ORG_UI_MODULES assert "employees" in SHARED_ORG_UI_MODULES + assert "skills" in SHARED_ORG_UI_MODULES assert_shared_modules_in_catalog() diff --git a/tests/unit/test_org_skills.py b/tests/unit/test_org_skills.py new file mode 100644 index 00000000..47f23413 --- /dev/null +++ b/tests/unit/test_org_skills.py @@ -0,0 +1,165 @@ +"""In-host org skills list/read BFF used by the org-ui Skills page.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace + +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.org_module import router +from octop.infra.errors import OctopError +from octop.infra.users.identity import Role, User +from octop.modules.org_os.catalog import catalog_keys +from octop.modules.org_os.contract import ( + SHARED_ORG_UI_MODULES, + assert_shared_modules_in_catalog, +) +from octop.modules.org_os.skill_bridge.generate import generate_module_skills +from octop.modules.org_os.skill_bridge.inventory import ( + catalog_coverage, + list_generated_skills, + read_generated_skill, + validate_skill_slug, +) +from octop.modules.org_os.skill_bridge.publish import publish_skill + + +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, *, packages: list[object] | None = None) -> 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()) + + services = None + if packages is not None: + services = SimpleNamespace(skill_package_repo=SimpleNamespace(list_all=lambda: packages)) + 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 + return TestClient(app) + + +def test_shared_skills_module_is_in_catalog() -> None: + assert "skills" in SHARED_ORG_UI_MODULES + assert "governance" in SHARED_ORG_UI_MODULES + assert_shared_modules_in_catalog() + + +def test_inventory_lists_and_reads_generated_skills(tmp_path: Path) -> None: + root = tmp_path / "org-skills" + generated = generate_module_skills(root, module_keys=["employees", "skills"]) + assert {item.slug for item in generated} == {"org-employees", "org-skills"} + rows = list_generated_skills(root) + assert [row.slug for row in rows] == ["org-employees", "org-skills"] + assert all(not row.published for row in rows) + detail = read_generated_skill(root, "org-employees") + assert detail.module_key == "employees" + assert "name: org-employees" in detail.content + assert detail.skill_md.is_file() + coverage = catalog_coverage(root) + assert {row["key"] for row in coverage} == set(catalog_keys()) + employees = next(row for row in coverage if row["key"] == "employees") + assert employees["generated"] is True + assert employees["slug"] == "org-employees" + + +def test_inventory_marks_published_plugin_draft(tmp_path: Path) -> None: + root = tmp_path / "org-skills" + generated = generate_module_skills(root, module_keys=["governance"]) + publish_skill(generated[0].directory) + listed = list_generated_skills(root) + assert listed[0].published is True + assert listed[0].plugin_dir is not None + assert (listed[0].plugin_dir / "plugin.yaml").is_file() + + +def test_validate_skill_slug_rejects_traversal() -> None: + try: + validate_skill_slug("../etc/passwd") + raise AssertionError("expected ValueError") + except ValueError: + pass + try: + validate_skill_slug("org/employees") + raise AssertionError("expected ValueError") + except ValueError: + pass + + +def test_api_lists_generated_and_host_packages(tmp_path: Path) -> None: + generate_module_skills(tmp_path / "org-skills", module_keys=["employees"]) + pkg = SimpleNamespace(id="pkg-1", name="Ops", description="Host pack", skill_count=2) + admin = _client(tmp_path, _admin(), packages=[pkg]) + listed = admin.get("/api/org-module/skills") + assert listed.status_code == 200 + body = listed.json() + assert body["out_dir"].endswith("org-skills") + slugs = {row["slug"] for row in body["skills"]} + assert "org-employees" in slugs + assert body["host_packages"] == [ + {"id": "pkg-1", "name": "Ops", "description": "Host pack", "skill_count": 2} + ] + employees = next(row for row in body["catalog"] if row["key"] == "employees") + assert employees["generated"] is True + + detail = admin.get("/api/org-module/skills/org-employees") + assert detail.status_code == 200 + assert "name: org-employees" in detail.json()["content"] + missing = admin.get("/api/org-module/skills/org-missing") + assert missing.status_code == 404 + bad = admin.get("/api/org-module/skills/../secret") + assert bad.status_code == 400 + + +def test_api_member_can_read_but_cannot_generate_or_publish(tmp_path: Path) -> None: + generate_module_skills(tmp_path / "org-skills", module_keys=["employees"]) + member = _client(tmp_path, _member()) + listed = member.get("/api/org-module/skills") + assert listed.status_code == 200 + assert listed.json()["skills"][0]["slug"] == "org-employees" + denied_gen = member.post("/api/org-module/skills/generate", json={}) + assert denied_gen.status_code == 403 + denied_pub = member.post( + "/api/org-module/skills/publish", + json={"slug": "org-employees"}, + ) + assert denied_pub.status_code == 403 + + +def test_api_admin_generate_and_publish_by_slug(tmp_path: Path) -> None: + admin = _client(tmp_path, _admin()) + generated = admin.post( + "/api/org-module/skills/generate", + json={"modules": ["announcements"]}, + ) + assert generated.status_code == 200 + assert generated.json()["skills"][0]["slug"] == "org-announcements" + listed = admin.get("/api/org-module/skills") + assert any(row["slug"] == "org-announcements" for row in listed.json()["skills"]) + + published = admin.post( + "/api/org-module/skills/publish", + json={"slug": "org-announcements"}, + ) + assert published.status_code == 200 + assert published.json()["plugin_id"] == "org-announcements" + assert published.json()["notes"] + after = admin.get("/api/org-module/skills/org-announcements") + assert after.json()["published"] is True From e008b5c8cd483d939f9b98343311903381bef06d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 19 Sep 2026 03:37:16 +0000 Subject: [PATCH 2/2] fix(org): format Skills page and reject invalid skill slugs Avoid nested buttons on catalog cards, Prettier-wrap org-ui client types, and assert 400 on uppercase slugs instead of path traversal. Co-authored-by: XYAI Labs --- dashboard/src/org-ui/api/createClient.ts | 5 ++- .../src/org-ui/pages/skills/SkillsPage.tsx | 39 ++++++++++++++----- dashboard/src/org-ui/pages/skills/labels.ts | 3 +- .../src/pages/Organization/orgPathTabs.tsx | 9 ++++- tests/unit/test_org_skills.py | 2 +- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/dashboard/src/org-ui/api/createClient.ts b/dashboard/src/org-ui/api/createClient.ts index 302499b7..29910cb8 100644 --- a/dashboard/src/org-ui/api/createClient.ts +++ b/dashboard/src/org-ui/api/createClient.ts @@ -275,7 +275,10 @@ export interface OrgSkillsClient { list(): Promise; get(slug: string): Promise; generate(modules?: string[]): Promise; - publish(opts: { slug?: string; skillDir?: string }): Promise; + publish(opts: { + slug?: string; + skillDir?: string; + }): Promise; } export interface OrgApiClient { diff --git a/dashboard/src/org-ui/pages/skills/SkillsPage.tsx b/dashboard/src/org-ui/pages/skills/SkillsPage.tsx index 2cfcf1a8..97b71b17 100644 --- a/dashboard/src/org-ui/pages/skills/SkillsPage.tsx +++ b/dashboard/src/org-ui/pages/skills/SkillsPage.tsx @@ -1,5 +1,14 @@ import { useCallback, useEffect, useMemo, useState } from "react"; -import { Button, Input, Modal, Space, Table, Tabs, Tag, Typography } from "antd"; +import { + Button, + Input, + Modal, + Space, + Table, + Tabs, + Tag, + Typography, +} from "antd"; import { Package, RefreshCw, Search } from "lucide-react"; import type { HostSkillPackage, @@ -77,7 +86,8 @@ export function SkillsPage({ () => query ? catalog.filter((row) => { - const hay = `${row.key} ${row.slug} ${row.label} ${row.label_zh} ${row.description} ${row.description_zh}`.toLowerCase(); + const hay = + `${row.key} ${row.slug} ${row.label} ${row.label_zh} ${row.description} ${row.description_zh}`.toLowerCase(); return hay.includes(query); }) : catalog, @@ -87,7 +97,8 @@ export function SkillsPage({ () => query ? hostPackages.filter((row) => { - const hay = `${row.name} ${row.description} ${row.id}`.toLowerCase(); + const hay = + `${row.name} ${row.description} ${row.id}`.toLowerCase(); return hay.includes(query); }) : hostPackages, @@ -246,11 +257,18 @@ export function SkillsPage({ ) : (

{visibleCatalog.map((row) => ( - +
))}
)} @@ -309,7 +327,10 @@ export function SkillsPage({ {loading ? (

{labels.loading}

) : visibleHost.length === 0 ? ( -
+

{labels.emptyHost}

{labels.emptyHostHint}

diff --git a/dashboard/src/org-ui/pages/skills/labels.ts b/dashboard/src/org-ui/pages/skills/labels.ts index ae891aa3..bff56f15 100644 --- a/dashboard/src/org-ui/pages/skills/labels.ts +++ b/dashboard/src/org-ui/pages/skills/labels.ts @@ -74,8 +74,7 @@ const ZH: typeof EN = { emptyHostHint: "请到「个性化 → 技能包」创建或编辑。智能体运行那些技能;本页只列出,不另造运行时。", adminOnly: "生成与发布仅管理员可用。", - hostHint: - "这些技能包就是 FreeOS Agent Skills 目录。安装与编辑请到个性化页。", + hostHint: "这些技能包就是 FreeOS Agent Skills 目录。安装与编辑请到个性化页。", openHost: "打开 Agent Skills", detail: "技能内容", close: "关闭", diff --git a/dashboard/src/pages/Organization/orgPathTabs.tsx b/dashboard/src/pages/Organization/orgPathTabs.tsx index cd3d482b..8fb91765 100644 --- a/dashboard/src/pages/Organization/orgPathTabs.tsx +++ b/dashboard/src/pages/Organization/orgPathTabs.tsx @@ -1,4 +1,11 @@ -import { Building2, Megaphone, Network, Package, Shield, Users } from "lucide-react"; +import { + Building2, + Megaphone, + Network, + Package, + Shield, + Users, +} from "lucide-react"; import { useTranslation } from "react-i18next"; import { useNavigate } from "react-router-dom"; import type { PathTabsConfig } from "../../layouts/PageShell"; diff --git a/tests/unit/test_org_skills.py b/tests/unit/test_org_skills.py index 47f23413..41d254b2 100644 --- a/tests/unit/test_org_skills.py +++ b/tests/unit/test_org_skills.py @@ -124,7 +124,7 @@ def test_api_lists_generated_and_host_packages(tmp_path: Path) -> None: assert "name: org-employees" in detail.json()["content"] missing = admin.get("/api/org-module/skills/org-missing") assert missing.status_code == 404 - bad = admin.get("/api/org-module/skills/../secret") + bad = admin.get("/api/org-module/skills/ORG-EMPLOYEES") assert bad.status_code == 400