From 067603333f2a5c57f9c467c2989de3e27f7d6bf9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 17 Sep 2026 04:17:31 +0000 Subject: [PATCH 1/2] Fix org embed IA, model-picker jump, and local openXYOS login. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversation model picker now has 模型管理 pointing at /models. Organization defaults to a full-height local embed with a slim blue bar; enable/module toggles and loop/import extras move into drawers. Sidecar CORS includes its own origin so iframe login no longer 500s, and first-run local demo accounts are seeded when demo data is off. Co-authored-by: XYAI Labs --- CHANGELOG.md | 4 + dashboard/src/layouts/sidebarNav.test.ts | 6 + dashboard/src/locales/en.json | 7 +- dashboard/src/locales/zh.json | 7 +- .../components/ChatInputActionsRow.test.tsx | 80 +- .../Chat/components/ChatInputActionsRow.tsx | 8 +- .../Organization/Organization.module.less | 217 ++--- .../src/pages/Organization/index.test.tsx | 96 +++ dashboard/src/pages/Organization/index.tsx | 802 +++++++++--------- .../src/build/windows/nsis/start-sidecar.ps1 | 5 +- modules/openxyos/backend/routes/auth.ts | 14 +- modules/openxyos/backend/seed.ts | 52 +- modules/openxyos/backend/server.ts | 30 +- .../backend/services/auth-provider.ts | 7 +- .../openxyos/backend/utils/cors-origins.ts | 61 ++ modules/openxyos/frontend/src/index.css | 42 + modules/openxyos/frontend/src/main.tsx | 4 + modules/openxyos/frontend/src/openxyos.css | 2 +- modules/openxyos/package.json | 3 +- modules/openxyos/scripts/test-cors-origins.ts | 36 + src/octop/modules/org_os/sidecar_launch.py | 11 +- .../unit/desktop/test_openxyos_provisioner.py | 2 + tests/unit/test_sidecar_launch.py | 11 + 23 files changed, 939 insertions(+), 568 deletions(-) create mode 100644 dashboard/src/pages/Organization/index.test.tsx create mode 100644 modules/openxyos/backend/utils/cors-origins.ts create mode 100644 modules/openxyos/scripts/test-cors-origins.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ec4e077..c4e051d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ ### 修复 +- 对话模型选择器底部增加「模型管理」,跳到侧栏同一条「模型」路由(`/models`)。 +- 组织页默认只显示本机 openXYOS 嵌入和顶部蓝色状态栏;「启用组织模块」「组织OS管理」在状态栏右下。互生长导入/导出、源码下载、循环时间线等收到「组织OS管理」。iframe 高度等于可见预览区,openXYOS 弹窗按 iframe 视口水平垂直居中。 +- 嵌入本机 openXYOS 登录不再因 CORS 漏掉 `http://127.0.0.1:3780` 而返回笼统的「服务器内部错误」。启动环境合并 sidecar 自源;无演示种子时写入本地 demo 账号,保证隔空第一台电脑能登录。 + - Windows 安装期 `start-sidecar.ps1` 不再把数据目录写到只读自动变量 `$home` / `$HOME`(赋值抛 `SessionStateUnauthorizedAccessException`,`$ErrorActionPreference = Continue` 时脚本继续跑,SQLite 会落到 `%USERPROFILE%\\org-os\\xiongyuan.db`)。改为 `$freeosHome`(默认 `%USERPROFILE%\\.freeos`)。 - 预构建 `openxyos-runtime.zip` 现在带上 `backend-dist/migrations/*.sql`。编译后的 `server.js` 用 `__dirname/migrations` 做 `initDatabase()`,缺文件会 ENOENT(`013_audit_bundle.sql`)并让 livez 永远起不来。安装期预配也会把已有 live 目录里的 `backend/migrations` 补到 `backend-dist/migrations`。 - Windows 安装期 `start-sidecar.ps1` 不再用 PowerShell 5.1 默认的 `UseShellExecute=true` 拉起 Node(那样不会继承 `$env:CORS_ORIGIN`,生产环境 `parseOrigins` 立刻抛错,进程退出,3780 无监听,预配空等 90s 后才以退出码 12 失败)。改为 `UseShellExecute=false` 并显式写入 `ProcessStartInfo.EnvironmentVariables`,把 Node stdout/stderr 记入 `%LOCALAPPDATA%\\FreeOS\\openxyos\\start.log`;进程在 livez 前退出则预配以退出码 10 失败并附上日志摘录。 diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 0c6539db..cb66fe75 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -79,6 +79,12 @@ describe("sidebarNav", () => { it("labels organization without the OS suffix", () => { expect(zh.nav.organization).toBe("组织"); expect(en.nav.organization).toBe("Organization"); + expect(zh.organization.enableModule).toBe("启用组织模块"); + expect(zh.organization.manageOs).toBe("组织OS管理"); + expect(en.organization.enableModule).toBe("Enable organization module"); + expect(en.organization.manageOs).toBe("Organization OS"); + expect(zh.chat.modelPickerManage).toBe("模型管理"); + expect(en.chat.modelPickerManage).toBe("Model management"); }); it("locks the employee / colleague / expert glossary", () => { diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 3f7d23e8..caf52f49 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -12,6 +12,11 @@ "heroTitle": "Organization", "heroStory": "FreeOS does the work. openXYOS owns organization and governance. They grow each other.", "toggleLabel": "Enable organization module", + "enableModule": "Enable organization module", + "manageOs": "Organization OS", + "moduleDrawerTitle": "Enable organization module", + "manageDrawerTitle": "Organization OS", + "statusBarTitle": "Organization", "on": "Enabled", "off": "Disabled", "sidecarUp": "Local openXYOS connected", @@ -1401,7 +1406,7 @@ "subagentPickerSearch": "Search subagents", "subagentPickerEmpty": "No installed subagents", "subagentPickerManage": "Manage subagents", - "modelPickerManage": "Manage models", + "modelPickerManage": "Model management", "sessionWorking": "Working", "composerContextLabel": "Message context", "contextWindow": { diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index c9180e71..9cbe109e 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -12,6 +12,11 @@ "heroTitle": "组织", "heroStory": "FreeOS 负责干活,openXYOS 负责组织与治理,两者互相生长。", "toggleLabel": "启用组织模块", + "enableModule": "启用组织模块", + "manageOs": "组织OS管理", + "moduleDrawerTitle": "启用组织模块", + "manageDrawerTitle": "组织OS管理", + "statusBarTitle": "组织", "on": "已启用", "off": "已关闭", "sidecarUp": "本机 openXYOS 已连接", @@ -1401,7 +1406,7 @@ "subagentPickerSearch": "搜索子智能体", "subagentPickerEmpty": "暂无已安装的子智能体", "subagentPickerManage": "管理子智能体", - "modelPickerManage": "管理模型", + "modelPickerManage": "模型管理", "sessionWorking": "正在回复", "composerContextLabel": "消息附加上下文", "contextWindow": { diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx index ed684f9c..7be9f85b 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.test.tsx @@ -1,9 +1,15 @@ -import { fireEvent, render, waitFor } from "@testing-library/react"; +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import { describe, expect, it, vi } from "vitest"; import type { ResolvedModel } from "../../../api/types"; import ChatInputActionsRow from "./ChatInputActionsRow"; +const navigate = vi.fn(); +vi.mock("react-router-dom", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, useNavigate: () => navigate }; +}); + vi.mock("./ContextWindowRing", () => ({ default: () => null, })); @@ -59,4 +65,76 @@ describe("ChatInputActionsRow compact pickers", () => { }); expect(document.querySelector(".ant-drawer-content")).toBeNull(); }); + + it("opens 模型管理 on the sidebar Models route", async () => { + navigate.mockClear(); + const { container } = render( + + + , + ); + + const modelButton = container + .querySelector("svg.lucide-cpu") + ?.closest("button"); + fireEvent.click(modelButton!); + const manage = await screen.findByText("模型管理"); + fireEvent.click(manage); + expect(navigate).toHaveBeenCalledWith("/models"); + }); + + it("still shows 模型管理 when no models are configured", async () => { + const { container } = render( + + + , + ); + + const modelButton = container + .querySelector("svg.lucide-cpu") + ?.closest("button"); + expect(modelButton).not.toBeNull(); + fireEvent.click(modelButton!); + expect(await screen.findByText("模型管理")).toBeInTheDocument(); + }); }); diff --git a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx index 1faa12b5..65726b76 100644 --- a/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx +++ b/dashboard/src/pages/Chat/components/ChatInputActionsRow.tsx @@ -223,9 +223,7 @@ export default function ChatInputActionsRow({ return () => observer.disconnect(); }, [isMobile]); - const showModelPicker = Boolean( - availableModels && availableModels.length > 0 && onModelChange, - ); + const showModelPicker = Boolean(onModelChange); const effectiveModelRef = selectedModel || defaultModel || ""; const selectedModelInfo = availableModels?.find( (model) => modelOptionValue(model) === effectiveModelRef, @@ -533,11 +531,11 @@ export default function ChatInputActionsRow({ onClick={() => { closeCompactPicker(); setModelPickerOpen(false); - navigate("/admin/models"); + navigate("/models"); }} > - {t("chat.modelPickerManage")} + {t("chat.modelPickerManage", "模型管理")} )} diff --git a/dashboard/src/pages/Organization/Organization.module.less b/dashboard/src/pages/Organization/Organization.module.less index 075bfd8d..bae9a0b0 100644 --- a/dashboard/src/pages/Organization/Organization.module.less +++ b/dashboard/src/pages/Organization/Organization.module.less @@ -1,138 +1,121 @@ +/* + * Organization page layout + * ------------------------ + * The main view is a slim blue status bar + a full-area iframe. The iframe + * box is height-constrained to the remaining window (not stretched to match + * leftover chrome). That matters for openXYOS dialogs: `position:fixed` + * inside the iframe is relative to the iframe element's box. A tall iframe + * (old two-column layout) centered modals far below the visible FreeOS + * viewport. Keeping the iframe == the visible pane centers overlays both + * vertically and horizontally. + */ .page { display: flex; flex-direction: column; - gap: 20px; + flex: 1; + min-height: 0; + height: 100%; + gap: 0; } -.workspace { - display: grid; - grid-template-columns: minmax(320px, 42%) minmax(0, 1fr); - gap: 16px; - align-items: stretch; - min-height: 0; +.statusBar { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 12px; + flex-shrink: 0; + min-height: 56px; + padding: 10px 16px; + color: #fff; + background: #0033ff; + box-shadow: 0 8px 24px rgba(0, 51, 255, 0.22); } -.column { +.statusMeta { display: flex; - flex-direction: column; - gap: 16px; + flex-wrap: wrap; + align-items: center; + gap: 8px; min-width: 0; } -.preview { - display: flex; - flex-direction: column; - min-height: 560px; - border-radius: 16px; - border: 1px solid var(--fn-border-primary); - background: var(--fn-bg-primary); - overflow: hidden; +.statusTitle { + margin: 0 8px 0 0; + font-size: 15px; + font-weight: 680; + letter-spacing: -0.02em; } -.previewFullscreen { - position: fixed; - inset: 0; - z-index: 1200; - min-height: 0; - border-radius: 0; - background: var(--fn-bg-primary); +.chip { + display: inline-flex; + align-items: center; + gap: 6px; + border-radius: 999px; + padding: 3px 10px; + font-size: 12px; + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.18); } -.previewHeader { +.statusActions { display: flex; + flex-wrap: wrap; + justify-content: flex-end; align-items: center; - justify-content: space-between; - gap: 12px; - padding: 12px 14px; - border-bottom: 1px solid var(--fn-border-primary); + gap: 8px; } -.previewTitle { - margin: 0; - font-size: 15px; - font-weight: 650; +.statusActions :global(.ant-btn) { + color: #0033ff; + border-color: transparent; + background: #fff; +} + +.statusActions :global(.ant-btn:hover) { + color: #0033ff !important; + border-color: #fff !important; + background: #f4f6ff !important; } -.previewBody { +.embedPane { position: relative; flex: 1; min-height: 0; background: #fff; + overflow: hidden; +} + +.embed { + display: block; + width: 100%; + height: 100%; + border: 0; + background: #fff; } -.previewEmpty { +.previewOffline { position: absolute; inset: 0; display: flex; - flex-direction: column; - align-items: flex-start; + align-items: center; justify-content: center; - gap: 12px; padding: 24px; + color: var(--fn-text-secondary); background: var(--fn-bg-secondary); + pointer-events: none; } -@media (max-width: 1100px) { - .workspace { - grid-template-columns: 1fr; - } - - .preview { - min-height: 420px; - } -} - -.hero { - position: relative; - overflow: hidden; - border-radius: 20px; - padding: 28px 28px 24px; - color: #fff; - background: radial-gradient( - 1200px 400px at 10% -20%, - rgba(107, 140, 255, 0.55), - transparent 60% - ), - radial-gradient( - 800px 360px at 90% 120%, - rgba(0, 51, 255, 0.45), - transparent 55% - ), - linear-gradient(135deg, #001a80 0%, #0033ff 48%, #4b74fa 100%); - box-shadow: 0 18px 48px rgba(0, 51, 255, 0.22); -} - -.heroTitle { - margin: 0 0 8px; - font-size: 28px; - font-weight: 720; - letter-spacing: -0.03em; +.drawerBody { + display: flex; + flex-direction: column; + gap: 16px; } .heroStory { margin: 0; - max-width: 720px; - font-size: 16px; + font-size: 14px; line-height: 1.6; - color: rgba(255, 255, 255, 0.9); -} - -.heroMeta { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 16px; -} - -.chip { - display: inline-flex; - align-items: center; - gap: 6px; - border-radius: 999px; - padding: 4px 10px; - font-size: 12px; - background: rgba(255, 255, 255, 0.14); - border: 1px solid rgba(255, 255, 255, 0.18); + color: var(--fn-text-secondary); } .dual { @@ -150,6 +133,15 @@ .flow { min-height: 72px; } + + .statusBar { + flex-direction: column; + align-items: stretch; + } + + .statusActions { + justify-content: flex-end; + } } .plane { @@ -157,7 +149,6 @@ padding: 20px; background: var(--fn-bg-primary); border: 1px solid var(--fn-border-primary); - box-shadow: 0 8px 24px rgba(15, 23, 42, 0.04); } .planeHead { @@ -234,7 +225,6 @@ display: grid; place-items: center; margin: 8px 0; - animation: spin 12s linear infinite; } .orbitCore { @@ -245,18 +235,6 @@ box-shadow: 0 0 0 6px rgba(0, 51, 255, 0.12); } -@keyframes spin { - to { - transform: rotate(360deg); - } -} - -@media (prefers-reduced-motion: reduce) { - .orbit { - animation: none; - } -} - .actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); @@ -313,11 +291,6 @@ font-weight: 680; } -.recoverBody { - margin: 0 0 12px; - color: var(--fn-text-secondary); -} - .timeline { border-radius: 16px; padding: 18px 20px; @@ -362,15 +335,6 @@ color: var(--fn-text-secondary); } -.embed { - display: block; - width: 100%; - height: 100%; - min-height: 100%; - border: 0; - background: #fff; -} - .empty { border-radius: 16px; padding: 22px; @@ -383,3 +347,10 @@ font-size: 18px; font-weight: 680; } + +.moduleToggle { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} diff --git a/dashboard/src/pages/Organization/index.test.tsx b/dashboard/src/pages/Organization/index.test.tsx new file mode 100644 index 00000000..39b9eec2 --- /dev/null +++ b/dashboard/src/pages/Organization/index.test.tsx @@ -0,0 +1,96 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import OrganizationPage from "./index"; +import type { OrgOverview } from "../../api/modules/orgModule"; + +const overview: OrgOverview = { + enabled: true, + sidecar_reachable: true, + sidecar_url: "http://127.0.0.1:3780", + start_available: false, + install_ready: true, + start_command: "", + home: "/tmp", + last_sync: null, + freeos: { + employees: 1, + employee_states: {}, + agents: 0, + spawned_colleagues: 1, + org_skills: 0, + skill_packages: 0, + mcp: 0, + tasks: 0, + }, + openxyos: { + reachable: true, + url: "http://127.0.0.1:3780", + detail: "ok", + modules: 4, + governance: true, + tenant_id: "1", + approvals: 0, + }, + last_loop: null, + notes: [], + catalog: [ + { + key: "org", + label: "Org", + label_zh: "组织", + description: "Org tree", + description_zh: "组织树", + locked: true, + }, + ], + module_toggles: { org: true }, +}; + +vi.mock("../../api/modules/orgModule", () => ({ + orgModuleApi: { + overview: vi.fn(async () => overview), + setEnabled: vi.fn(), + startSidecar: vi.fn(), + assemble: vi.fn(), + produce: vi.fn(), + pack: vi.fn(), + runLoop: vi.fn(), + setModules: vi.fn(), + downloadSource: vi.fn(), + }, +})); + +vi.mock("../../hooks/useServerTimezone", () => ({ + useServerTimezone: () => "UTC", +})); + +describe("OrganizationPage", () => { + it("defaults to the local embed and keeps extras behind the two status buttons", async () => { + render(); + + await waitFor(() => { + expect( + screen.getByTitle("organization.previewTitle"), + ).toBeInTheDocument(); + }); + const frame = screen.getByTitle("organization.previewTitle"); + expect(frame).toHaveAttribute("src", "http://127.0.0.1:3780/"); + + expect(screen.queryByText("organization.assembleAction")).toBeNull(); + expect(screen.queryByText("organization.startSidecarAction")).toBeNull(); + + fireEvent.click(screen.getByTestId("org-enable-module")); + expect( + await screen.findByText("organization.catalogTitle"), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByTestId("org-manage-os")); + expect( + await screen.findByText("organization.assembleTitle"), + ).toBeInTheDocument(); + expect(screen.getByText("organization.loopTitle")).toBeInTheDocument(); + expect( + screen.getAllByText("organization.downloadSource").length, + ).toBeGreaterThan(0); + }); +}); diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index 83714f90..c5145a9f 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -1,14 +1,12 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { Button, Input, Space, Switch, Tag } from "antd"; +import { Button, Drawer, Input, Space, Switch, Tag } from "antd"; import { ArrowDownUp, Building2, ExternalLink, - Maximize2, - Minimize2, Package, Play, - RefreshCw, + Settings2, Shield, Users, Workflow, @@ -34,6 +32,7 @@ import { resolveOpenxyosSourceDest } from "./pickSourceDest"; import styles from "./Organization.module.less"; type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null; +type DrawerKey = "module" | "manage" | null; type LastReceipt = { kind: "assemble" | "pack" | "loop"; @@ -55,7 +54,7 @@ export default function OrganizationPage() { const [loopProof, setLoopProof] = useState(null); const [lastActionNotes, setLastActionNotes] = useState([]); const [lastReceipt, setLastReceipt] = useState(null); - const [previewFullscreen, setPreviewFullscreen] = useState(false); + const [drawer, setDrawer] = useState(null); const [moduleToggles, setModuleToggles] = useState>( {}, ); @@ -110,15 +109,6 @@ export default function OrganizationPage() { return () => window.clearInterval(id); }, [applyOverview, overview?.sidecar_reachable]); - useEffect(() => { - if (!previewFullscreen) return; - const onKey = (event: KeyboardEvent) => { - if (event.key === "Escape") setPreviewFullscreen(false); - }; - window.addEventListener("keydown", onKey); - return () => window.removeEventListener("keydown", onKey); - }, [previewFullscreen]); - const sidecarUp = Boolean(overview?.sidecar_reachable); const firstRun = useMemo(() => { if (!overview) return false; @@ -403,32 +393,13 @@ export default function OrganizationPage() { }, [showFrame, pushTogglesToPreview]); return ( - - {(sidecarUp || overview?.enabled) && ( - - )} - - - } - > +
-
-

{t("organization.heroTitle")}

-

{t("organization.heroStory")}

-

{t("organization.glossary")}

-
+
+
+

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

{overview?.enabled ? t("organization.on") : t("organization.off")} @@ -441,428 +412,429 @@ export default function OrganizationPage() { {t("organization.lastSync")}: {lastSync}
+
+ + + {showFrame && ( + + )} +
-
-
-
- - {t("organization.toggleLabel")} - void toggle(checked)} - /> +
+ {!sidecarUp && ( +
+ {t("organization.previewOffline")}
+ )} +