From 67eb7e648168748cddb906b2edd956aee5b8f660 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 01:01:52 +0000 Subject: [PATCH] Reshape FreeOS around user jobs and local AI discovery. Replace admin-oriented sidebar groups with a flat workbench list (conversations, experts, models, knowledge, org OS, personalization, projects/tasks/chats, token usage). Fold channels, connectors, skill packages, and former system-settings ops into Personalization tabs. Scan documented local AI app paths (Claude, Codex, Cursor, WorkBuddy, Continue, Windsurf, Ollama, and related) read-only and let users register discovered skills, plugins, and MCP servers into FreeOS without rewriting third-party files. Keep P0 desktop reliability: Windows terminal reconnect, faster openXYOS sidecar start, local/cloud knowledge mounts, hardware-aware local models, group @everyone, and local vs cloud token stats. Co-authored-by: XYAI Labs --- dashboard/src/api/modules/hostApps.ts | 42 ++ dashboard/src/api/modules/knowledgeBases.ts | 65 +++ dashboard/src/api/modules/localModels.ts | 43 ++ dashboard/src/api/modules/orgModule.ts | 16 + dashboard/src/api/modules/projects.ts | 53 ++ dashboard/src/layouts/Sidebar.module.less | 28 + dashboard/src/layouts/Sidebar.tsx | 192 ++++-- dashboard/src/layouts/sidebarNav.test.ts | 71 +-- dashboard/src/layouts/sidebarNav.tsx | 146 ++--- dashboard/src/locales/en.json | 108 +++- dashboard/src/locales/zh.json | 108 +++- .../components/HostAppDiscoverPanel.tsx | 157 +++++ .../src/pages/Agent/Personalization/index.tsx | 179 ++++-- .../pages/Agent/Personalization/tabs.test.ts | 30 + .../src/pages/Agent/Personalization/tabs.ts | 88 +++ .../Chat/components/MentionPickerMenu.test.ts | 19 + .../Chat/components/MentionPickerMenu.tsx | 19 +- .../pages/Chat/hooks/useSlashMentionInput.ts | 17 +- .../pages/Chat/utils/expertMention.test.ts | 11 + .../src/pages/Chat/utils/expertMention.ts | 9 + .../src/pages/Control/CronJobs/index.tsx | 11 +- .../Control/RemoteDesktop/index.module.less | 12 + .../src/pages/Control/RemoteDesktop/index.tsx | 67 ++- .../src/pages/Control/Terminal/index.tsx | 7 +- .../src/pages/Control/Terminal/useTerminal.ts | 6 +- .../src/pages/Control/TokenUsage/index.tsx | 30 +- .../pages/Control/Workbench/index.module.less | 12 + .../src/pages/Control/Workbench/index.tsx | 64 +- .../pages/KnowledgeBases/CloudMountPanel.tsx | 106 ++++ .../pages/KnowledgeBases/LocalMountPanel.tsx | 115 ++++ dashboard/src/pages/KnowledgeBases/index.tsx | 4 + dashboard/src/pages/Organization/index.tsx | 169 +++++- dashboard/src/pages/Projects/index.tsx | 251 ++++++++ .../Models/components/LocalHardwarePanel.tsx | 124 ++++ dashboard/src/pages/Settings/Models/index.tsx | 3 + .../SystemSettings/SystemSettings.module.less | 20 +- dashboard/src/pages/SystemSettings/index.tsx | 96 +-- .../src/pages/SystemSettings/tabs.test.ts | 8 +- dashboard/src/pages/SystemSettings/tabs.ts | 10 +- dashboard/src/routes/controlAdminPath.test.ts | 15 +- dashboard/src/routes/index.tsx | 195 ++++--- dashboard/src/routes/prefetch.ts | 26 +- dashboard/src/utils/localModelUsage.test.ts | 19 + dashboard/src/utils/localModelUsage.ts | 37 ++ dashboard/src/utils/permissions.ts | 33 +- desktop/portable/bundle-org-sidecar.sh | 11 +- desktop/src/main.go | 4 +- desktop/src/process_sidecar.go | 10 +- desktop/src/process_sidecar_test.go | 18 + .../frontend/src/components/Sidebar.tsx | 15 + .../openxyos/frontend/src/stores/modules.ts | 9 + src/octop/api/app.py | 6 + src/octop/api/openapi_meta.py | 12 + src/octop/api/routers/host_apps.py | 91 +++ src/octop/api/routers/knowledge_bases.py | 173 ++++++ src/octop/api/routers/local_models.py | 43 ++ src/octop/api/routers/org_module.py | 72 ++- src/octop/api/routers/projects.py | 196 +++++++ src/octop/api/routers/terminal.py | 194 +++++- src/octop/i18n/en.json | 24 + src/octop/i18n/zh.json | 24 + .../infra/agents/providers/local_probe.py | 162 ++++++ src/octop/infra/host_apps/__init__.py | 5 + src/octop/infra/host_apps/register.py | 149 +++++ src/octop/infra/host_apps/scan.py | 550 ++++++++++++++++++ src/octop/infra/knowledge/local_mount.py | 226 +++++++ src/octop/infra/projects/__init__.py | 1 + src/octop/infra/projects/store.py | 192 ++++++ src/octop/infra/server.py | 2 +- src/octop/modules/org_os/catalog.py | 4 + src/octop/modules/org_os/module_toggles.py | 51 ++ src/octop/modules/org_os/notes.py | 137 +++++ src/octop/modules/org_os/overview.py | 2 +- src/octop/modules/org_os/sidecar_launch.py | 31 +- src/octop/modules/org_os/source_download.py | 35 ++ tests/unit/api/test_terminal_platform.py | 6 +- tests/unit/api/test_terminal_sessions.py | 33 +- tests/unit/test_host_apps_register.py | 42 ++ tests/unit/test_host_apps_scan.py | 74 +++ tests/unit/test_knowledge_mount.py | 75 +++ tests/unit/test_local_probe.py | 21 + tests/unit/test_module_toggles.py | 17 + tests/unit/test_org_module.py | 2 +- tests/unit/test_org_notes.py | 19 + tests/unit/test_projects_store.py | 36 ++ 85 files changed, 5080 insertions(+), 535 deletions(-) create mode 100644 dashboard/src/api/modules/hostApps.ts create mode 100644 dashboard/src/api/modules/localModels.ts create mode 100644 dashboard/src/api/modules/projects.ts create mode 100644 dashboard/src/pages/Agent/Personalization/components/HostAppDiscoverPanel.tsx create mode 100644 dashboard/src/pages/Agent/Personalization/tabs.test.ts create mode 100644 dashboard/src/pages/Agent/Personalization/tabs.ts create mode 100644 dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx create mode 100644 dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx create mode 100644 dashboard/src/pages/Projects/index.tsx create mode 100644 dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx create mode 100644 dashboard/src/utils/localModelUsage.test.ts create mode 100644 dashboard/src/utils/localModelUsage.ts create mode 100644 src/octop/api/routers/host_apps.py create mode 100644 src/octop/api/routers/local_models.py create mode 100644 src/octop/api/routers/projects.py create mode 100644 src/octop/infra/agents/providers/local_probe.py create mode 100644 src/octop/infra/host_apps/__init__.py create mode 100644 src/octop/infra/host_apps/register.py create mode 100644 src/octop/infra/host_apps/scan.py create mode 100644 src/octop/infra/knowledge/local_mount.py create mode 100644 src/octop/infra/projects/__init__.py create mode 100644 src/octop/infra/projects/store.py create mode 100644 src/octop/modules/org_os/module_toggles.py create mode 100644 src/octop/modules/org_os/notes.py create mode 100644 src/octop/modules/org_os/source_download.py create mode 100644 tests/unit/test_host_apps_register.py create mode 100644 tests/unit/test_host_apps_scan.py create mode 100644 tests/unit/test_knowledge_mount.py create mode 100644 tests/unit/test_local_probe.py create mode 100644 tests/unit/test_module_toggles.py create mode 100644 tests/unit/test_org_notes.py create mode 100644 tests/unit/test_projects_store.py diff --git a/dashboard/src/api/modules/hostApps.ts b/dashboard/src/api/modules/hostApps.ts new file mode 100644 index 00000000..848a1055 --- /dev/null +++ b/dashboard/src/api/modules/hostApps.ts @@ -0,0 +1,42 @@ +import { request } from "../request"; + +export interface HostAppItem { + id: string; + name: string; + path?: string; + kind: string; + format?: string; + transport?: string; + command?: string; + args?: string[]; + url?: string; + source_path?: string; + enabled?: boolean; +} + +export interface HostAppReport { + id: string; + label: string; + installed: boolean; + paths_checked: string[]; + paths_found: string[]; + skills: HostAppItem[]; + plugins: HostAppItem[]; + mcp: HostAppItem[]; + notes: string; +} + +export const hostAppsApi = { + list: () => + request<{ hosts: HostAppReport[]; readonly: boolean }>("/host-apps"), + importItem: (body: { + host_id: string; + item_id: string; + kind: "skill" | "plugin" | "mcp"; + }) => + request>("/host-apps/import", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), +}; diff --git a/dashboard/src/api/modules/knowledgeBases.ts b/dashboard/src/api/modules/knowledgeBases.ts index a7b1ba9f..b9e690d1 100644 --- a/dashboard/src/api/modules/knowledgeBases.ts +++ b/dashboard/src/api/modules/knowledgeBases.ts @@ -314,4 +314,69 @@ export const knowledgeBasesApi = { body: JSON.stringify({ new_name: newName }), }, ), + + getMount: (id: string) => + request<{ + mounted: boolean; + readonly?: boolean; + kind?: "local" | "cloud"; + source_path?: string; + distill_path?: string; + cloud_url?: string; + cloud_provider?: string; + entries: Array<{ + path: string; + name: string; + is_dir: boolean; + size: number; + }>; + }>(`/knowledge-bases/${id}/mount`), + + setMount: ( + id: string, + source_path: string, + distill_path?: string, + extra?: { + kind?: "local" | "cloud"; + cloud_url?: string; + cloud_provider?: string; + }, + ) => + request<{ + mounted: boolean; + source_path: string; + distill_path: string; + entries: Array<{ + path: string; + name: string; + is_dir: boolean; + size: number; + }>; + }>(`/knowledge-bases/${id}/mount`, { + method: "PUT", + body: JSON.stringify({ + source_path, + distill_path: distill_path || "", + kind: extra?.kind || "local", + cloud_url: extra?.cloud_url || "", + cloud_provider: extra?.cloud_provider || "", + }), + }), + + clearMount: (id: string) => + request<{ ok: boolean }>(`/knowledge-bases/${id}/mount`, { + method: "DELETE", + }), + + distillMount: (id: string, distill_path: string) => + request<{ + source_path: string; + distill_path: string; + copied: number; + skipped: number; + readonly_source: boolean; + }>(`/knowledge-bases/${id}/mount/distill`, { + method: "POST", + body: JSON.stringify({ distill_path }), + }), }; diff --git a/dashboard/src/api/modules/localModels.ts b/dashboard/src/api/modules/localModels.ts new file mode 100644 index 00000000..94e6c9a0 --- /dev/null +++ b/dashboard/src/api/modules/localModels.ts @@ -0,0 +1,43 @@ +import { request } from "../request"; + +export interface LocalHardware { + os: string; + arch: string; + cpu_count: number; + ram_gb: number; + gpu: string; + ollama_binary: boolean; + ollama_reachable: boolean; +} + +export interface LocalInstalledModel { + name: string; + path: string; + size: number; + source: string; +} + +export interface LocalRecommendedModel { + id: string; + reason: string; + install: string; +} + +export interface LocalProbe { + hardware: LocalHardware; + installed: LocalInstalledModel[]; + recommended: LocalRecommendedModel[]; +} + +export const localModelsApi = { + probe: () => request("/local-models/probe"), + install: (name: string) => + request<{ ok: boolean; name: string; size: number; source: string }>( + "/local-models/install", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name }), + }, + ), +}; diff --git a/dashboard/src/api/modules/orgModule.ts b/dashboard/src/api/modules/orgModule.ts index 717a2399..a3794be5 100644 --- a/dashboard/src/api/modules/orgModule.ts +++ b/dashboard/src/api/modules/orgModule.ts @@ -62,6 +62,7 @@ export interface OrgOverview { last_loop: Record | null; notes: string[]; catalog: OrgCapability[]; + module_toggles?: Record; } export interface OrgSidecarStart { @@ -139,4 +140,19 @@ export const orgModuleApi = { headers: { "Content-Type": "application/json" }, body: JSON.stringify({}), }), + setModules: (updates: Record) => + request<{ updates: Record }>("/org-module/modules", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ updates }), + }), + downloadSource: (dest: string) => + request<{ ok: boolean; path: string; source: string }>( + "/org-module/source/download", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ dest }), + }, + ), }; diff --git a/dashboard/src/api/modules/projects.ts b/dashboard/src/api/modules/projects.ts new file mode 100644 index 00000000..8b043ac7 --- /dev/null +++ b/dashboard/src/api/modules/projects.ts @@ -0,0 +1,53 @@ +import { request } from "../request"; + +export interface ProjectLink { + id: string; + title: string; + agent_id?: string; +} + +export interface Project { + id: string; + name: string; + work_dir: string; + owner_user_id: number; + conversation_ids: string[]; + task_ids: string[]; + conversations?: ProjectLink[]; + tasks?: ProjectLink[]; + created_at: number; + updated_at: number; +} + +export const projectsApi = { + list: () => request<{ projects: Project[] }>("/projects"), + create: (body: { name: string; work_dir?: string }) => + request("/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + get: (id: string) => request(`/projects/${id}`), + update: ( + id: string, + body: { + name?: string; + work_dir?: string; + conversation_ids?: string[]; + task_ids?: string[]; + }, + ) => + request(`/projects/${id}`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }), + remove: (id: string) => + request<{ ok: boolean }>(`/projects/${id}`, { method: "DELETE" }), + link: (id: string, kind: "conversation" | "task", ref_id: string) => + request(`/projects/${id}/links`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ kind, ref_id }), + }), +}; diff --git a/dashboard/src/layouts/Sidebar.module.less b/dashboard/src/layouts/Sidebar.module.less index 55ba74b5..e2a62eb9 100644 --- a/dashboard/src/layouts/Sidebar.module.less +++ b/dashboard/src/layouts/Sidebar.module.less @@ -198,6 +198,34 @@ } } +.composeRow { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 12px 8px; +} + +.composeBtn { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + border: none; + border-radius: var(--fn-radius-md); + background: color-mix(in srgb, var(--fn-accent, #0033ff) 8%, transparent); + color: var(--fn-text-secondary); + font-size: 12px; + line-height: 1.3; + text-align: left; + padding: 7px 10px; + cursor: pointer; +} + +.composeBtn:hover { + background: color-mix(in srgb, var(--fn-accent, #0033ff) 14%, transparent); + color: var(--fn-text-primary); +} + .minimalPaneToggleCollapsed { display: flex; flex-direction: column; diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index eed7090f..f69d924d 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -6,7 +6,14 @@ import AvatarDropdown from "../components/AvatarDropdown"; import BrandMark from "../components/BrandMark"; import AppVersionBadge from "../components/AppVersionBadge"; import CurrentVersionBadge from "../components/CurrentVersionBadge"; -import { ArrowRightLeft, X, ChevronDown } from "lucide-react"; +import { + ArrowRightLeft, + X, + ChevronDown, + MessageSquarePlus, + FolderPlus, + ListPlus, +} from "lucide-react"; import { useLayoutMode } from "../context/LayoutModeContext"; import { useUserRole } from "../hooks/useUserRole"; import { useCurrentUser, useSetCurrentUser } from "../hooks/useCurrentUser"; @@ -254,8 +261,8 @@ function NavList({ isMobile?: boolean; isGroupCollapsed: (groupKey: string) => boolean; toggleGroup: (groupKey: string) => void; - /** all = classic; primary = top flat entries; grouped = capabilities */ - sectionFilter?: "all" | "primary" | "grouped"; + /** all = classic; primary = top flat entries; grouped/footer = capabilities */ + sectionFilter?: "all" | "primary" | "grouped" | "footer"; hideGroupHeaderKeys?: ReadonlySet; }) { const { t } = useTranslation(); @@ -265,8 +272,11 @@ function NavList({ const { mobileEnabled } = useServerCapabilities(); const navSections = buildNavSections(user, { mobileEnabled }).filter( (section) => { - if (sectionFilter === "primary") return !section.groupKey; - if (sectionFilter === "grouped") return Boolean(section.groupKey); + const placement = section.placement ?? "primary"; + if (sectionFilter === "primary") + return placement === "primary" && !section.groupKey; + if (sectionFilter === "grouped" || sectionFilter === "footer") + return placement === "footer" || Boolean(section.groupKey); return true; }, ); @@ -304,18 +314,40 @@ function NavList({
{visibleItems.map((item) => ( - +
+ c.key === selectedKey), + ) + } + isMobile={isMobile} + onNavigate={onNavigate} + onExpandChatRail={onExpandChatRail} + showChatRailExpand={showChatRailExpand} + role={role} + hasUpdate={hasUpdate} + t={t} + /> + {item.children?.map((child) => ( +
+ +
+ ))} +
))}
@@ -346,18 +378,40 @@ function NavList({ {!groupCollapsed && (
{visibleItems.map((item) => ( - +
+ c.key === selectedKey), + ) + } + isMobile={isMobile} + onNavigate={onNavigate} + onExpandChatRail={onExpandChatRail} + showChatRailExpand={showChatRailExpand} + role={role} + hasUpdate={hasUpdate} + t={t} + /> + {item.children?.map((child) => ( +
+ +
+ ))} +
))}
)} @@ -487,15 +541,54 @@ export default function Sidebar({ ); const primaryItems = - navSections.find((section) => !section.groupKey)?.items ?? []; + navSections + .filter( + (section) => + (section.placement ?? "primary") === "primary" && !section.groupKey, + ) + .flatMap((section) => section.items) ?? []; const groupedItems = navSections - .filter((section) => section.groupKey) + .filter( + (section) => section.placement === "footer" || Boolean(section.groupKey), + ) .flatMap((section) => section.groupKey && isGroupCollapsed(section.groupKey) ? [] : section.items, ); + const composeActions = ( +
+ + + +
+ ); + const paneToggle = ( { - if (section.groupKey && isGroupCollapsed(section.groupKey)) { - return []; - } - return section.items; - })} + items={primaryItems} selectedKey={selectedKey} onNavigate={handleNavigate} role={role} @@ -520,15 +608,39 @@ export default function Sidebar({ t={t} /> + ) : ( + <> + {composeActions} + + + ); + + const footerNav = isRailCollapsed ? ( + ) : ( ); @@ -685,6 +797,7 @@ export default function Sidebar({ paddingBottom: "calc(8px + env(safe-area-inset-bottom, 0px))", }} > + {!isMinimal ? footerNav : null} {userFooter} @@ -738,6 +851,7 @@ export default function Sidebar({ {navScrollBody} + {!isMinimal ? footerNav : null} {userFooter} ); diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 383b19e8..d611364f 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -14,68 +14,45 @@ const adminUser = { } as OctopUser; describe("sidebarNav", () => { - it("marks catalog keys as grouped", () => { - for (const key of SIDEBAR_GROUPED_NAV_KEYS) { - expect(isGroupedNavKey(key)).toBe(true); - } - expect(isGroupedNavKey("chat")).toBe(false); - expect(isGroupedNavKey("experts")).toBe(false); + it("has no Capabilities / System Settings sidebar groups", () => { + expect(SIDEBAR_GROUPED_NAV_KEYS).toEqual([]); expect(isGroupedNavKey("system-settings")).toBe(false); - expect(isGroupedNavKey("workbench")).toBe(false); + expect(isGroupedNavKey("personalization")).toBe(false); }); - it("places grouped keys only under sections with groupKey", () => { + it("is a single user-job primary list", () => { const sections = buildNavSections(adminUser, { mobileEnabled: true }); - const flatKeys = new Set( - sections - .filter((s) => !s.groupKey) - .flatMap((s) => s.items.map((i) => i.key)), - ); - const groupedKeys = new Set( - sections - .filter((s) => s.groupKey) - .flatMap((s) => s.items.map((i) => i.key)), - ); - for (const key of groupedKeys) { - expect(isGroupedNavKey(key)).toBe(true); - expect(flatKeys.has(key)).toBe(false); - } - for (const key of flatKeys) { - expect(isGroupedNavKey(key)).toBe(false); - } - }); - - it("renames settings to capabilities and folds control/admin into system-settings", () => { - const sections = buildNavSections(adminUser, { mobileEnabled: true }); - expect(sections.map((s) => s.groupKey)).toEqual([ - undefined, - "nav.capabilities", - ]); - const primary = sections[0].items.map((i) => i.key); - expect(primary).toContain("system-settings"); - expect(primary).not.toContain("workbench"); - expect(primary).not.toContain("admin-users"); - const grouped = sections[1].items.map((i) => i.key); - expect(grouped).toEqual([ - "personalization", - "channels", - "connectors", - "skill-packages", + expect(sections).toHaveLength(1); + expect(sections[0].groupKey).toBeUndefined(); + expect(sections[0].placement).toBe("primary"); + expect(sections[0].items.map((i) => i.key)).toEqual([ + "chat", + "experts", + "models", "knowledge-bases", + "organization", + "personalization", + "projects", + "token-usage", ]); }); - it("hides system-settings when the user has no control or admin modules", () => { + it("hides models and knowledge when the user lacks those permissions", () => { const guest = { id: 2, username: "guest", role: "user", permissions: [], } as OctopUser; - const sections = buildNavSections(guest); - const keys = sections.flatMap((s) => s.items.map((i) => i.key)); + const keys = buildNavSections(guest).flatMap((s) => + s.items.map((i) => i.key), + ); + expect(keys).not.toContain("models"); + expect(keys).not.toContain("knowledge-bases"); expect(keys).not.toContain("system-settings"); - expect(keys).not.toContain("workbench"); expect(keys).toContain("personalization"); + expect(keys).toContain("projects"); + expect(keys).toContain("chat"); + expect(keys).toContain("experts"); }); }); diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx index d75e90b2..f940d33b 100644 --- a/dashboard/src/layouts/sidebarNav.tsx +++ b/dashboard/src/layouts/sidebarNav.tsx @@ -1,19 +1,15 @@ import type { ReactNode } from "react"; import { MessageSquareText, - Timer, - SlidersHorizontal, - Waypoints, - Link2, Database, Activity, Sparkles, - Package, GraduationCap, Building2, + FolderKanban, + Cpu, } from "lucide-react"; import type { OctopUser } from "../api/modules/auth"; -import { canSeeSystemSettings } from "../pages/SystemSettings/tabs"; import { navAllowed } from "../utils/permissions"; export const EXPANDED_WIDTH = 220; @@ -28,26 +24,22 @@ export interface NavItem { icon: ReactNode; labelKey: string; badge?: string; + children?: NavItem[]; } export interface NavSection { /** When omitted, items render flat without a group header. */ groupKey?: string; + /** primary = middle list; footer = above the avatar. */ + placement?: "primary" | "footer"; items: NavItem[]; } /** - * Catalog of nav item keys that live under the Capabilities group. - * Permission-independent — used for pane/route helpers; visibility still - * comes from {@link buildNavSections}. + * No separate Capabilities / System Settings sidebar groups. + * Kept so rail helpers stay import-compatible. */ -export const SIDEBAR_GROUPED_NAV_KEYS = [ - "personalization", - "channels", - "connectors", - "skill-packages", - "knowledge-bases", -] as const; +export const SIDEBAR_GROUPED_NAV_KEYS = [] as const; const GROUPED_NAV_KEY_SET = new Set(SIDEBAR_GROUPED_NAV_KEYS); @@ -57,99 +49,61 @@ export function isGroupedNavKey(key: string): boolean { export function buildNavSections( user: OctopUser | null, - opts?: { mobileEnabled?: boolean }, + _opts?: { mobileEnabled?: boolean }, ): NavSection[] { - const sections: NavSection[] = [ + const items: NavItem[] = [ { - items: [ - { - key: "chat", - path: "/chat", - icon: , - labelKey: "nav.chat", - }, - { - key: "experts", - path: "/experts", - icon: , - labelKey: "nav.experts", - }, - { - key: "organization", - path: "/organization", - icon: , - labelKey: "nav.organization", - }, - { - key: "tasks", - path: "/tasks", - icon: , - labelKey: "nav.tasks", - }, - { - key: "token-usage", - path: "/token-usage", - icon: , - labelKey: "nav.tokenUsage", - }, - ...(canSeeSystemSettings(user, { mobileEnabled: opts?.mobileEnabled }) - ? [ - { - key: "system-settings", - path: "/system-settings", - icon: ( - - ), - labelKey: "nav.systemSettings", - } satisfies NavItem, - ] - : []), - ], + key: "chat", + path: "/chat", + icon: , + labelKey: "nav.conversations", }, - ]; - - const settingsItems: NavItem[] = [ { - key: "personalization", - path: "/personalization/skills", - icon: , - labelKey: "nav.personalization", + key: "experts", + path: "/experts", + icon: , + labelKey: "nav.experts", }, ]; - if (navAllowed(user, "channels")) { - settingsItems.push({ - key: "channels", - path: "/personalization/channels", - icon: , - labelKey: "nav.channels", - }); - } - if (navAllowed(user, "connectors")) { - settingsItems.push({ - key: "connectors", - path: "/connectors", - icon: , - labelKey: "nav.connectors", - }); - } - if (navAllowed(user, "skill-packages")) { - settingsItems.push({ - key: "skill-packages", - path: "/skill-packages", - icon: , - labelKey: "nav.skillPackages", + if (navAllowed(user, "models")) { + items.push({ + key: "models", + path: "/models", + icon: , + labelKey: "nav.models", }); } if (navAllowed(user, "knowledge-bases")) { - settingsItems.push({ + items.push({ key: "knowledge-bases", path: "/knowledge-bases", icon: , labelKey: "nav.knowledgeBases", }); } - if (settingsItems.length > 0) { - sections.push({ groupKey: "nav.capabilities", items: settingsItems }); - } - return sections; + items.push({ + key: "organization", + path: "/organization", + icon: , + labelKey: "nav.organization", + }); + items.push({ + key: "personalization", + path: "/personalization/skills", + icon: , + labelKey: "nav.personalization", + }); + items.push({ + key: "projects", + path: "/projects", + icon: , + labelKey: "nav.workspace", + }); + items.push({ + key: "token-usage", + path: "/token-usage", + icon: , + labelKey: "nav.tokenUsage", + }); + return [{ placement: "primary", items }]; } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index 05afe2fa..fa8273ae 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -81,11 +81,38 @@ "previewOffline": "Sidecar is offline. Start it to preview the organization console here.", "previewLoading": "Starting openXYOS…", "fullscreen": "Fullscreen", - "exitFullscreen": "Exit fullscreen" + "exitFullscreen": "Exit fullscreen", + "downloadSource": "Download latest openXYOS source", + "downloadSourceHint": "Download the latest openXYOS main-branch source zip from GitHub into a folder you choose, for other deployments.", + "downloadSourceDest": "Save to folder", + "downloadSourceDone": "Downloaded to {{path}}", + "downloadSourceFailed": "Could not download the source", + "catalogEnabled": "Shown", + "catalogDisabled": "Hidden", + "catalogToggleHint": "When off, the module disappears from the openXYOS preview sidebar.", + "autoStartingSidecar": "Starting the openXYOS sidecar…" }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." }, + "projects": { + "title": "Projects", + "subtitle": "A project can hold several conversations and tasks, and optionally a work folder.", + "tabProjects": "Projects", + "tabTasks": "Tasks", + "tabConversations": "Conversations", + "conversationsEmpty": "No project conversations yet. Start one from a project or chat.", + "empty": "No projects yet. Create one to group conversations and tasks.", + "name": "Project name", + "nameRequired": "Enter a project name", + "workDir": "Work folder", + "workDirHint": "Optional local folder for this project", + "noWorkDir": "No work folder", + "counts": "{{conversations}} conversations · {{tasks}} tasks", + "created": "Project created", + "createFailed": "Could not create the project", + "loadFailed": "Could not load projects" + }, "desktopChrome": { "group": "Window controls", "minimize": "Minimize", @@ -250,11 +277,17 @@ }, "nav": { "chat": "Conversation", + "conversations": "Conversations", + "projects": "Projects", + "workspace": "Projects / Tasks / Chats", + "newConversation": "New conversation", + "newTask": "New task", + "newProject": "New project", "common": "Common", "control": "Control", "experts": "Experts", "organization": "Organization OS", - "tasks": "Automation", + "tasks": "Tasks", "connectors": "Connectors", "acp": "ACP", "agents": "Agents", @@ -403,6 +436,30 @@ "enableGuideStepModelDetail": "Choose local ONNX (download the model first) or an online embedding provider, then save.", "noDescription": "No description", "selectBase": "Select a knowledge base to view its documents", + "mountTitle": "Local directory mount", + "mountHint": "Mount a folder as read-only. FreeOS never deletes or edits files in that folder. Parse copies must go to a new folder you choose.", + "mountSource": "Local folder path", + "mountSourceRequired": "Enter a local folder to mount", + "mountAction": "Mount read-only", + "mountSaved": "Folder mounted (read-only)", + "mountFailed": "Could not mount the folder", + "mountDir": "Folder", + "mountFile": "File", + "distillAction": "Parse to a new folder", + "distillDest": "Choose a new folder for parse results (not the mounted folder)", + "distillDone": "Copied {{count}} files to {{path}}", + "distillFailed": "Could not copy parse results", + "cloudMountTitle": "Cloud knowledge (ima and similar)", + "cloudMountHint": "Attach a cloud corpus such as ima. FreeOS does not download or parse those files locally. Distillation writes a pointer into a new folder you choose, for later agent strengthening.", + "cloudUrl": "Cloud knowledge URL", + "cloudUrlRequired": "Enter a cloud knowledge URL", + "cloudProvider": "Provider (ima…)", + "cloudMountAction": "Mount cloud KB", + "cloudMountSaved": "Cloud knowledge attached (no local parse)", + "cloudMountFailed": "Could not attach the cloud knowledge base", + "cloudAttachAction": "Attach for distillation", + "cloudDistillDest": "Choose a new folder for the cloud pointer (not a local parse)", + "cloudDistillDone": "Wrote a cloud pointer to {{path}}", "backToList": "Back to list", "createdBy": "Created by {{name}}", "baseId": "Knowledge base ID", @@ -2419,6 +2476,9 @@ "detail": "Details", "noData": "No data available", "noRecordsInRange": "No records in this range", + "localTokens": "Local model tokens", + "cloudTokens": "Cloud model tokens", + "localVsCloud": "Local vs cloud", "callDetail": "Per-Call Usage Details", "callDetailDesc": "Token consumption breakdown for each model call", "callTable": "Call-Level Token Details", @@ -2882,6 +2942,19 @@ "localDeleteFailed": "Failed to delete model", "localDownloadPending": "Preparing to download...", "localDownloading": "Downloading {{repo}}... This may take a few minutes.", + "localHardwareTitle": "This PC", + "localHardwareHint": "Detected RAM, CPU, optional NVIDIA GPU, Ollama, and already-downloaded GGUF / Ollama models. Chat can switch to a registered local model to reduce cloud use.", + "localCpu": "CPU cores", + "localRam": "RAM", + "localGpu": "GPU", + "localGpuNone": "not detected", + "localInstalled": "Already on this PC", + "localInstalledEmpty": "No local models found yet", + "localRecommended": "Recommended for this machine", + "localInstall": "Download and register", + "localInstallDone": "Installed {{name}}", + "localInstallFailed": "Install failed", + "localProbeFailed": "Could not scan local models", "localDownloadConfirmTitle": "Confirm model download", "localDownloadConfirmOnnx": "Download {{name}} (about {{size}}). This may take a few minutes.", "localDownloadConfirmOllama": "Pull {{name}} via Ollama. Size depends on the model and may take a while.", @@ -3330,7 +3403,21 @@ "searching": "Searching…", "filesEmpty": "No matching files", "filesNeedAgent": "Start the agent to mention workspace files", - "filesFailed": "Could not search workspace files" + "filesFailed": "Could not search workspace files", + "everyone": "Everyone", + "group": "Group" + }, + "hostApps": { + "title": "Already installed on this PC", + "hint": "Read-only scan of Claude, Codex, Cursor, WorkBuddy, Continue, and similar apps. Import copies into FreeOS; third-party files are never edited.", + "empty": "None of the known desktop AI apps were found on this computer.", + "installed": "Found", + "missing": "Not installed", + "noItems": "App is present, but no skills / plugins / MCP configs were found in documented paths.", + "importAction": "Install into FreeOS", + "imported": "Registered in FreeOS. You can invoke it from chat after the agent reloads.", + "importFailed": "Could not register this item", + "loadFailed": "Could not scan local AI apps" }, "modelConfig": { "promptTitle": "LLM Model Required", @@ -3731,15 +3818,26 @@ }, "personalization": { "title": "Personalization", - "description": "Configure this agent's skills, tools, plugins, subagents, channels, personality, and memory.", + "description": "Skills, channels, connectors, skill packages, and the host operations that used to live in System Settings.", "tabs": { "skills": "Skills", "tools": "Tools", "plugins": "Plugins", "subagents": "Subagents", "channels": "Channels", + "connectors": "Connectors / MCP", + "skill-packages": "Skill packages", "mbti": "MBTI", - "memory": "Memory" + "memory": "Memory", + "workbench": "Workbench", + "remote-desktop": "Remote desktop", + "acp": "ACP", + "users": "Users", + "storage": "Storage", + "host-plugins": "Host plugins", + "security": "Security", + "advanced": "Advanced", + "agent-config": "Agent config" }, "cards": { "identity": "Identity", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 76f87524..a72cfeb1 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -81,11 +81,38 @@ "previewOffline": "边车未运行。启动后可在此预览组织控制台。", "previewLoading": "正在启动 openXYOS…", "fullscreen": "全屏", - "exitFullscreen": "退出全屏" + "exitFullscreen": "退出全屏", + "downloadSource": "下载最新 openXYOS 源码", + "downloadSourceHint": "从 GitHub 下载 openXYOS 主分支源码 zip,保存到你选择的文件夹,便于在其他环境部署。", + "downloadSourceDest": "保存到文件夹", + "downloadSourceDone": "已下载到 {{path}}", + "downloadSourceFailed": "下载源码失败", + "catalogEnabled": "已显示", + "catalogDisabled": "已隐藏", + "catalogToggleHint": "关闭后,该模块会从右侧 openXYOS 预览的侧栏消失。", + "autoStartingSidecar": "正在自动启动 openXYOS 边车…" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" }, + "projects": { + "title": "项目", + "subtitle": "一个项目可以包含多段对话和多个任务,也可以指定工作目录。", + "tabProjects": "项目", + "tabTasks": "任务", + "tabConversations": "对话", + "conversationsEmpty": "还没有项目对话。可以从项目或对话页新建。", + "empty": "还没有项目。新建一个来组织对话和任务。", + "name": "项目名称", + "nameRequired": "请输入项目名称", + "workDir": "工作目录", + "workDirHint": "可选,为本项目指定本地文件夹", + "noWorkDir": "未指定工作目录", + "counts": "{{conversations}} 个对话 · {{tasks}} 个任务", + "created": "已创建项目", + "createFailed": "无法创建项目", + "loadFailed": "无法加载项目" + }, "desktopChrome": { "group": "窗口控制", "minimize": "最小化", @@ -250,11 +277,17 @@ }, "nav": { "chat": "对话", + "conversations": "对话", + "projects": "项目", + "workspace": "项目 / 任务 / 对话区", + "newConversation": "新建对话", + "newTask": "新建任务", + "newProject": "新建项目", "common": "常用", "control": "控制", "experts": "专家", "organization": "组织 OS", - "tasks": "自动化", + "tasks": "任务", "connectors": "连接器", "acp": "ACP", "agents": "Agents", @@ -403,6 +436,30 @@ "enableGuideStepModelDetail": "选择本地 ONNX(需先下载模型)或在线 Embedding 提供商,保存后即可使用。", "noDescription": "暂无描述", "selectBase": "选择一个知识库以查看文档", + "mountTitle": "挂载本地目录", + "mountHint": "以只读方式挂载本地文件夹。FreeOS 不会删除或修改该目录中的文件。解析结果必须保存到你另外指定的新文件夹。", + "mountSource": "本地文件夹路径", + "mountSourceRequired": "请输入要挂载的本地文件夹", + "mountAction": "只读挂载", + "mountSaved": "已只读挂载该文件夹", + "mountFailed": "挂载失败", + "mountDir": "文件夹", + "mountFile": "文件", + "distillAction": "解析到新文件夹", + "distillDest": "请选择保存解析结果的新文件夹(不能是已挂载的目录)", + "distillDone": "已复制 {{count}} 个文件到 {{path}}", + "distillFailed": "无法复制解析结果", + "cloudMountTitle": "云知识库(ima 等)", + "cloudMountHint": "挂载 ima 等云端语料。FreeOS 不会在本地解析这些文件。蒸馏时只在你指定的新文件夹写入指针,供后续强化智能体。", + "cloudUrl": "云知识库地址", + "cloudUrlRequired": "请输入云知识库地址", + "cloudProvider": "来源(ima…)", + "cloudMountAction": "挂载云知识库", + "cloudMountSaved": "已挂载云知识库(不做本地解析)", + "cloudMountFailed": "无法挂载云知识库", + "cloudAttachAction": "挂到蒸馏目录", + "cloudDistillDest": "请选择保存云知识库指针的新文件夹(不会本地解析原文)", + "cloudDistillDone": "已将云知识库指针写入 {{path}}", "backToList": "返回列表", "createdBy": "创建者:{{name}}", "baseId": "知识库 ID", @@ -2419,6 +2476,9 @@ "detail": "明细", "noData": "暂无数据", "noRecordsInRange": "区间内无记录", + "localTokens": "本地模型用量", + "cloudTokens": "云端模型用量", + "localVsCloud": "本地 / 云端", "callDetail": "模型调用消耗详情", "callDetailDesc": "当日各会话的每次模型调用 Token 消耗明细", "callTable": "模型调用 Token 明细", @@ -2880,6 +2940,19 @@ "localDeleteFailed": "删除模型失败", "localDownloadPending": "准备下载...", "localDownloading": "正在下载 {{repo}}... 可能需要几分钟。", + "localHardwareTitle": "本机", + "localHardwareHint": "已检测内存、CPU、NVIDIA 显卡(如有)、Ollama 以及本机已有的 GGUF / Ollama 模型。注册后可在对话中切换到本地模型,减少云端算力。", + "localCpu": "CPU 核心", + "localRam": "内存", + "localGpu": "显卡", + "localGpuNone": "未检测到", + "localInstalled": "本机已有模型", + "localInstalledEmpty": "尚未发现本地模型", + "localRecommended": "适合这台电脑的推荐", + "localInstall": "一键下载并注册", + "localInstallDone": "已安装 {{name}}", + "localInstallFailed": "安装失败", + "localProbeFailed": "无法扫描本地模型", "localDownloadConfirmTitle": "确认下载模型", "localDownloadConfirmOnnx": "即将下载 {{name}}(约 {{size}})。下载可能需要几分钟,请保持网络畅通。", "localDownloadConfirmOllama": "即将通过 Ollama 拉取 {{name}}。下载体积取决于模型,可能需要较长时间。", @@ -3327,7 +3400,21 @@ "searching": "正在搜索…", "filesEmpty": "没有匹配的文件", "filesNeedAgent": "Agent 运行后才能引用文件", - "filesFailed": "无法搜索工作区文件" + "filesFailed": "无法搜索工作区文件", + "everyone": "所有人", + "group": "群聊" + }, + "hostApps": { + "title": "本机已安装", + "hint": "只读扫描 Claude、Codex、Cursor、WorkBuddy、Continue 等常见桌面 AI。导入到 FreeOS 后即可调用,不会改写第三方软件自己的文件。", + "empty": "这台电脑上没有发现已知的桌面 AI 应用。", + "installed": "已发现", + "missing": "未安装", + "noItems": "应用在,但文档约定路径里没有技能 / 插件 / MCP 配置。", + "importAction": "安装到 FreeOS", + "imported": "已登记到 FreeOS。智能体重新加载后即可在对话里调用。", + "importFailed": "无法登记该项", + "loadFailed": "无法扫描本机 AI 应用" }, "modelConfig": { "promptTitle": "需要配置大语言模型", @@ -3861,15 +3948,26 @@ }, "personalization": { "title": "个性化", - "description": "配置当前智能体的技能、工具、插件、子智能体、通道、人格与记忆。", + "description": "技能、通道、连接器、技能包,以及原先系统设置里面向日常使用的能力。", "tabs": { "skills": "技能", "tools": "工具", "plugins": "插件", "subagents": "子智能体", "channels": "通道", + "connectors": "连接器 / MCP", + "skill-packages": "技能包", "mbti": "MBTI", - "memory": "记忆" + "memory": "记忆", + "workbench": "工作台", + "remote-desktop": "远程桌面", + "acp": "ACP", + "users": "用户", + "storage": "存储", + "host-plugins": "主机插件", + "security": "安全", + "advanced": "高级", + "agent-config": "智能体配置" }, "cards": { "identity": "身份认同", diff --git a/dashboard/src/pages/Agent/Personalization/components/HostAppDiscoverPanel.tsx b/dashboard/src/pages/Agent/Personalization/components/HostAppDiscoverPanel.tsx new file mode 100644 index 00000000..733eeaf3 --- /dev/null +++ b/dashboard/src/pages/Agent/Personalization/components/HostAppDiscoverPanel.tsx @@ -0,0 +1,157 @@ +import { useEffect, useMemo, useState } from "react"; +import { Button, Empty, List, Space, Tag, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import { + hostAppsApi, + type HostAppItem, + type HostAppReport, +} from "../../../../api/modules/hostApps"; +import { message } from "../../../../utils/antdMessage"; + +type DiscoverKind = "skill" | "plugin" | "mcp"; + +interface HostAppDiscoverPanelProps { + kinds: DiscoverKind[]; +} + +function itemsFor(host: HostAppReport, kinds: DiscoverKind[]): HostAppItem[] { + const out: HostAppItem[] = []; + if (kinds.includes("skill")) out.push(...host.skills); + if (kinds.includes("plugin")) out.push(...host.plugins); + if (kinds.includes("mcp")) out.push(...host.mcp); + return out; +} + +export default function HostAppDiscoverPanel({ + kinds, +}: HostAppDiscoverPanelProps) { + const { t } = useTranslation(); + const [hosts, setHosts] = useState([]); + const [loading, setLoading] = useState(true); + const [busyKey, setBusyKey] = useState(""); + + const load = async () => { + setLoading(true); + try { + const next = await hostAppsApi.list(); + setHosts(next.hosts); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("hostApps.loadFailed"), + ); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + void load(); + }, []); + + const visible = useMemo( + () => + hosts.filter( + (host) => host.installed || itemsFor(host, kinds).length > 0, + ), + [hosts, kinds], + ); + + const importItem = async (host: HostAppReport, item: HostAppItem) => { + const kind = ( + item.kind === "mcp" || item.kind === "plugin" || item.kind === "skill" + ? item.kind + : kinds[0] + ) as DiscoverKind; + const key = `${host.id}:${item.id}`; + setBusyKey(key); + try { + await hostAppsApi.importItem({ + host_id: host.id, + item_id: item.id, + kind, + }); + message.success(t("hostApps.imported")); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("hostApps.importFailed"), + ); + } finally { + setBusyKey(""); + } + }; + + return ( +
+ {t("hostApps.title")} + + {t("hostApps.hint")} + + + ), + }} + dataSource={visible} + renderItem={(host) => { + const items = itemsFor(host, kinds); + return ( + + + {host.label} + + {host.installed + ? t("hostApps.installed") + : t("hostApps.missing")} + + + } + description={ + + {host.notes ? {host.notes} : null} + {host.paths_found.length > 0 ? ( + + {host.paths_found.join(" · ")} + + ) : null} + {items.length === 0 ? ( + {t("hostApps.noItems")} + ) : ( + items.map((item) => ( + + {item.kind} + {item.name} + + + )) + )} + + } + /> + + ); + }} + /> +
+ ); +} diff --git a/dashboard/src/pages/Agent/Personalization/index.tsx b/dashboard/src/pages/Agent/Personalization/index.tsx index d9f93890..57fd833a 100644 --- a/dashboard/src/pages/Agent/Personalization/index.tsx +++ b/dashboard/src/pages/Agent/Personalization/index.tsx @@ -1,76 +1,107 @@ -import { useCallback, useMemo } from "react"; +import { lazy, Suspense, useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { Empty } from "antd"; import { + Blocks, Bot, Brain, + HardDrive, + Monitor, Notebook, + Package, + PanelsTopLeft, + Plug, Puzzle, + Settings2, + Share2, + Shield, + SlidersHorizontal, Sparkles, + Users, Waypoints, Wrench, } from "lucide-react"; import PageShell, { pageShellStyles } from "../../../layouts/PageShell"; +import PageLoading from "../../../components/PageLoading"; import { useAgent } from "../../../context/AgentContext"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { usePathTabs } from "../../../hooks/usePathTabs"; import { useCurrentUser } from "../../../hooks/useCurrentUser"; -import { userCan } from "../../../utils/permissions"; +import { useServerCapabilities } from "../../../hooks/useServerCapabilities"; import SkillsTabs from "../Skills/components/SkillsTabs"; import ToolsPanel from "../Tools/ToolsPanel"; import SubagentManager from "../../Experts/components/SubagentManager"; import MBTISelector from "./components/MBTISelector"; import AgentPluginsPanel from "./components/AgentPluginsPanel"; +import HostAppDiscoverPanel from "./components/HostAppDiscoverPanel"; import MemoryPanel from "../Memory/MemoryPanel"; import ChannelsPanel from "../Channels/ChannelsPanel"; +import { + PERSONALIZATION_TABS, + allowedPersonalizationTabs, + type PersonalizationTab, +} from "./tabs"; import styles from "./index.module.less"; -export type PersonalizationTab = - | "skills" - | "subagents" - | "tools" - | "plugins" - | "mbti" - | "memory" - | "channels"; - -const PERSONALIZATION_TABS = [ - "skills", - "subagents", - "tools", - "plugins", - "mbti", - "memory", - "channels", -] as const satisfies readonly PersonalizationTab[]; - -const TAB_ICONS = { +export type { PersonalizationTab }; + +const ConnectorsPage = lazy(() => import("../Connectors")); +const SkillPackagesPage = lazy(() => import("../../SkillPackages")); +const ACPPage = lazy(() => import("../ACP")); +const AgentConfigPage = lazy(() => import("../Config")); +const AdminUsersPage = lazy(() => import("../../Admin/Users")); +const AdminStoragePage = lazy(() => import("../../Admin/Storage")); +const AdminPluginsPage = lazy(() => import("../../Admin/Plugins")); +const AdminSecurityPage = lazy(() => import("../../Settings/Security")); +const AdvancedSettingsPage = lazy( + () => import("../../Settings/AdvancedSettings"), +); +const WorkbenchPage = lazy(() => import("../../Control/Workbench")); +const RemoteDesktopPage = lazy(() => import("../../Control/RemoteDesktop")); + +const TAB_ICONS: Record = { skills: Sparkles, - subagents: Bot, + channels: Waypoints, + connectors: Plug, + "skill-packages": Package, tools: Wrench, plugins: Puzzle, + subagents: Bot, mbti: Brain, memory: Notebook, - channels: Waypoints, -} as const; + workbench: PanelsTopLeft, + "remote-desktop": Monitor, + acp: Share2, + users: Users, + storage: HardDrive, + "host-plugins": Blocks, + security: Shield, + advanced: SlidersHorizontal, + "agent-config": Settings2, +}; export default function PersonalizationPage() { const { t } = useTranslation(); const isMobile = useIsMobile(); const user = useCurrentUser(); + const { mobileEnabled } = useServerCapabilities(); const { activeAgentId, agents } = useAgent(); const activeAgent = agents.find((a) => a.agent_id === activeAgentId); + const allowed = useMemo( + () => allowedPersonalizationTabs(user, { mobileEnabled }), + [user, mobileEnabled], + ); + const allowedSet = useMemo(() => new Set(allowed), [allowed]); const isAllowed = useCallback( - (tab: PersonalizationTab) => - tab !== "channels" || userCan(user, "channels"), - [user], + (tab: PersonalizationTab) => allowedSet.has(tab), + [allowedSet], ); const { activeTab, handleTabChange, isMounted } = usePathTabs({ basePath: "/personalization", tabs: PERSONALIZATION_TABS, storageKey: "octop:personalization:tab", - defaultTab: "skills", + defaultTab: allowed[0] ?? "skills", isAllowed, }); @@ -78,24 +109,47 @@ export default function PersonalizationPage() { () => ({ value: activeTab, onChange: handleTabChange, - options: PERSONALIZATION_TABS.filter((value) => isAllowed(value)).map( - (value) => { - const Icon = TAB_ICONS[value]; - return { - value, - label: t(`personalization.tabs.${value}`), - icon: , - }; - }, - ), + options: allowed.map((value) => { + const Icon = TAB_ICONS[value]; + return { + value, + label: t(`personalization.tabs.${value}`), + icon: , + }; + }), }), - [activeTab, handleTabChange, isAllowed, t], + [activeTab, handleTabChange, allowed, t], ); const pageTitle = `${t("personalization.title")} / ${t( `personalization.tabs.${activeTab}`, )}`; + const hostPanel = (tab: PersonalizationTab) => + isMounted(tab) && allowedSet.has(tab) ? ( +
+
+ }> + {tab === "workbench" ? ( + + ) : null} + {tab === "remote-desktop" ? : null} + {tab === "acp" ? : null} + {tab === "users" ? : null} + {tab === "storage" ? : null} + {tab === "host-plugins" ? : null} + {tab === "security" ? : null} + {tab === "advanced" ? : null} + {tab === "agent-config" ? : null} + +
+
+ ) : null; + return (
+
@@ -136,11 +191,43 @@ export default function PersonalizationPage() { aria-hidden={activeTab !== "plugins"} >
+
)} + {isMounted("connectors") && allowedSet.has("connectors") && ( +
+
+ + }> + + +
+
+ )} + + {isMounted("skill-packages") && allowedSet.has("skill-packages") && ( +
+
+ }> + + +
+
+ )} + {isMounted("subagents") && (
)} - {isMounted("channels") && ( + {isMounted("channels") && allowedSet.has("channels") && (
)} + + {hostPanel("workbench")} + {hostPanel("remote-desktop")} + {hostPanel("acp")} + {hostPanel("users")} + {hostPanel("storage")} + {hostPanel("host-plugins")} + {hostPanel("security")} + {hostPanel("advanced")} + {hostPanel("agent-config")}
); diff --git a/dashboard/src/pages/Agent/Personalization/tabs.test.ts b/dashboard/src/pages/Agent/Personalization/tabs.test.ts new file mode 100644 index 00000000..59167957 --- /dev/null +++ b/dashboard/src/pages/Agent/Personalization/tabs.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { OctopUser } from "../../../api/modules/auth"; +import { + allowedPersonalizationTabs, + SYSTEM_SETTINGS_TO_PERSONALIZATION, +} from "./tabs"; + +const adminUser = { + id: 1, + username: "admin", + role: "admin", + permissions: ["*"], +} as OctopUser; + +describe("personalization tabs", () => { + it("includes connectors, skill packages, and former system-settings host tabs", () => { + const tabs = allowedPersonalizationTabs(adminUser, { mobileEnabled: true }); + expect(tabs).toContain("skills"); + expect(tabs).toContain("channels"); + expect(tabs).toContain("connectors"); + expect(tabs).toContain("skill-packages"); + expect(tabs).toContain("workbench"); + expect(tabs).toContain("users"); + expect(tabs).not.toContain("models"); + }); + + it("maps the models system-settings tab to the top-level models page", () => { + expect(SYSTEM_SETTINGS_TO_PERSONALIZATION.models).toBe("/models"); + }); +}); diff --git a/dashboard/src/pages/Agent/Personalization/tabs.ts b/dashboard/src/pages/Agent/Personalization/tabs.ts new file mode 100644 index 00000000..b95c7d0f --- /dev/null +++ b/dashboard/src/pages/Agent/Personalization/tabs.ts @@ -0,0 +1,88 @@ +import type { OctopUser } from "../../../api/modules/auth"; +import { canSeeSystemSettings } from "../../SystemSettings/tabs"; +import { navAllowed, userCan } from "../../../utils/permissions"; + +export const PERSONALIZATION_CORE_TABS = [ + "skills", + "channels", + "connectors", + "skill-packages", + "tools", + "plugins", + "subagents", + "mbti", + "memory", +] as const; + +export const PERSONALIZATION_HOST_TABS = [ + "workbench", + "remote-desktop", + "acp", + "users", + "storage", + "host-plugins", + "security", + "advanced", + "agent-config", +] as const; + +export const PERSONALIZATION_TABS = [ + ...PERSONALIZATION_CORE_TABS, + ...PERSONALIZATION_HOST_TABS, +] as const; + +export type PersonalizationTab = (typeof PERSONALIZATION_TABS)[number]; + +export function isPersonalizationTab( + value: string, +): value is PersonalizationTab { + return (PERSONALIZATION_TABS as readonly string[]).includes(value); +} + +export function allowedPersonalizationTabs( + user: OctopUser | null, + opts?: { mobileEnabled?: boolean }, +): PersonalizationTab[] { + const tabs: PersonalizationTab[] = [ + "skills", + "tools", + "plugins", + "subagents", + "mbti", + "memory", + ]; + if (userCan(user, "channels")) tabs.splice(1, 0, "channels"); + if (navAllowed(user, "connectors")) + tabs.splice(tabs.indexOf("tools"), 0, "connectors"); + if (navAllowed(user, "skill-packages")) { + tabs.splice(tabs.indexOf("tools"), 0, "skill-packages"); + } + if (navAllowed(user, "workbench")) tabs.push("workbench"); + if ( + userCan(user, "desktop") || + (opts?.mobileEnabled && userCan(user, "mobile")) + ) { + tabs.push("remote-desktop"); + } + if (navAllowed(user, "acp")) tabs.push("acp"); + if (navAllowed(user, "admin-users")) tabs.push("users"); + if (navAllowed(user, "admin-storage")) tabs.push("storage"); + if (navAllowed(user, "admin-plugins")) tabs.push("host-plugins"); + if (navAllowed(user, "admin-security")) tabs.push("security"); + if (navAllowed(user, "admin-advanced")) tabs.push("advanced"); + if (canSeeSystemSettings(user, opts)) tabs.push("agent-config"); + return tabs.filter((tab, index, all) => all.indexOf(tab) === index); +} + +export const SYSTEM_SETTINGS_TO_PERSONALIZATION: Record = { + workbench: "workbench", + "remote-desktop": "remote-desktop", + acp: "acp", + users: "users", + models: "/models", + storage: "storage", + plugins: "host-plugins", + security: "security", + advanced: "advanced", + "agent-config": "agent-config", +}; diff --git a/dashboard/src/pages/Chat/components/MentionPickerMenu.test.ts b/dashboard/src/pages/Chat/components/MentionPickerMenu.test.ts index 0c3f2923..734ad350 100644 --- a/dashboard/src/pages/Chat/components/MentionPickerMenu.test.ts +++ b/dashboard/src/pages/Chat/components/MentionPickerMenu.test.ts @@ -2,6 +2,25 @@ import { describe, expect, it } from "vitest"; import { buildMentionItems, firstFileMentionIndex } from "./MentionPickerMenu"; describe("buildMentionItems", () => { + it("adds @所有人 when more than one expert can be mentioned", () => { + const items = buildMentionItems( + "", + [], + [ + { agent_id: "a1", name: "分析师" }, + { agent_id: "a2", name: "研究员" }, + ], + [], + [], + { everyoneLabel: "所有人" }, + ); + expect(items[0]).toEqual({ + kind: "everyone", + label: "所有人", + token: "所有人", + }); + }); + it("includes installed subagents as @slug picks", () => { const items = buildMentionItems( "", diff --git a/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx b/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx index 0e599d97..44150575 100644 --- a/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx +++ b/dashboard/src/pages/Chat/components/MentionPickerMenu.tsx @@ -18,12 +18,14 @@ export type MentionPick = | { kind: "connector"; name: string; label: string } | { kind: "agent"; agent_id: string; label: string } | { kind: "subagent"; slug: string; label: string } - | { kind: "file"; path: string; label: string }; + | { kind: "file"; path: string; label: string } + | { kind: "everyone"; label: string; token: string }; export function mentionPickKey(item: MentionPick): string { if (item.kind === "file") return `file:${item.path}`; if (item.kind === "subagent") return `subagent:${item.slug}`; if (item.kind === "connector") return `connector:${item.name}`; + if (item.kind === "everyone") return "everyone"; return `agent:${item.agent_id}`; } @@ -37,10 +39,19 @@ export function buildMentionItems( agents: MentionAgentOption[] = [], subagents: AgentSubagentSummary[] = [], files: WorkspaceMentionFile[] = [], - options: { filesFirst?: boolean } = {}, + options: { filesFirst?: boolean; everyoneLabel?: string } = {}, ): MentionPick[] { const q = query.trim().toLowerCase(); const people: MentionPick[] = []; + const everyoneLabel = options.everyoneLabel || "所有人"; + const everyoneHaystack = `${everyoneLabel} 所有人 everyone all`.toLowerCase(); + if (agents.length > 1 && (!q || everyoneHaystack.includes(q))) { + people.push({ + kind: "everyone", + label: everyoneLabel, + token: "所有人", + }); + } for (const c of connectors) { if ( q && @@ -130,11 +141,13 @@ export default function MentionPickerMenu({ const agentSection = t("mention.experts", "Experts"); const subagentSection = t("mention.subagents", "Subagents"); const fileSection = t("mention.files", "Workspace files"); + const groupSection = t("mention.group", "Group"); const sectionFor = (item: MentionPick) => { if (item.kind === "connector") return connSection; if (item.kind === "subagent") return subagentSection; if (item.kind === "file") return fileSection; + if (item.kind === "everyone") return groupSection; return agentSection; }; @@ -183,6 +196,8 @@ export default function MentionPickerMenu({ let icon; if (item.kind === "connector") { icon = ; + } else if (item.kind === "everyone") { + icon = @; } else if (item.kind === "file") { icon = ; } else if (item.kind === "subagent") { diff --git a/dashboard/src/pages/Chat/hooks/useSlashMentionInput.ts b/dashboard/src/pages/Chat/hooks/useSlashMentionInput.ts index 26719885..9ed60a70 100644 --- a/dashboard/src/pages/Chat/hooks/useSlashMentionInput.ts +++ b/dashboard/src/pages/Chat/hooks/useSlashMentionInput.ts @@ -110,7 +110,10 @@ export function useSlashMentionInput({ mentionAgents, availableSubagents, mentionFiles, - { filesFirst: isPathLikeMentionQuery(mentionQuery) }, + { + filesFirst: isPathLikeMentionQuery(mentionQuery), + everyoneLabel: locale.startsWith("zh") ? "所有人" : "everyone", + }, ), [ mentionQuery, @@ -118,6 +121,7 @@ export function useSlashMentionInput({ mentionAgents, availableSubagents, mentionFiles, + locale, ], ); @@ -283,6 +287,17 @@ export function useSlashMentionInput({ focusAt(next.cursor); return; } + if (pick.kind === "everyone") { + const next = replaceMentionQuery( + text, + mentionAtIndex, + mentionQuery, + pick.token, + ); + setText(next.text); + focusAt(next.cursor); + return; + } if (pick.kind === "agent" || pick.kind === "subagent") { const tokenName = pick.kind === "subagent" ? pick.slug : pick.label; const next = replaceMentionQuery( diff --git a/dashboard/src/pages/Chat/utils/expertMention.test.ts b/dashboard/src/pages/Chat/utils/expertMention.test.ts index 8ccd7efd..2aaf75a0 100644 --- a/dashboard/src/pages/Chat/utils/expertMention.test.ts +++ b/dashboard/src/pages/Chat/utils/expertMention.test.ts @@ -70,6 +70,17 @@ describe("mentionedExpertIds / ensureExpertMentions", () => { expect(mentionedExpertIds("请 @分析师 看一下", experts)).toEqual(["a1"]); }); + it("expands @所有人 and @everyone to every expert", () => { + expect(mentionedExpertIds("请 @所有人 看一下", experts)).toEqual([ + "a1", + "a2", + ]); + expect(mentionedExpertIds("@everyone 同步一下", experts)).toEqual([ + "a1", + "a2", + ]); + }); + it("injects missing mentions when reclaiming a queued turn", () => { expect(ensureExpertMentions("帮我看看", ["a2"], experts)).toBe( "帮我看看 @研究员 ", diff --git a/dashboard/src/pages/Chat/utils/expertMention.ts b/dashboard/src/pages/Chat/utils/expertMention.ts index 582634a4..c6764291 100644 --- a/dashboard/src/pages/Chat/utils/expertMention.ts +++ b/dashboard/src/pages/Chat/utils/expertMention.ts @@ -27,10 +27,19 @@ export function textHasExpertMention(text: string, name: string): boolean { return expertMentionPattern(name).test(text); } +export const EVERYONE_MENTION_NAMES = ["所有人", "everyone"] as const; + +export function textHasEveryoneMention(text: string): boolean { + return EVERYONE_MENTION_NAMES.some((name) => textHasExpertMention(text, name)); +} + export function mentionedExpertIds( text: string, experts: Array<{ agent_id: string; name: string }>, ): string[] { + if (textHasEveryoneMention(text)) { + return experts.map((expert) => expert.agent_id); + } return experts .filter((expert) => textHasExpertMention(text, expert.name)) .map((expert) => expert.agent_id); diff --git a/dashboard/src/pages/Control/CronJobs/index.tsx b/dashboard/src/pages/Control/CronJobs/index.tsx index 9836ff24..d5172c84 100644 --- a/dashboard/src/pages/Control/CronJobs/index.tsx +++ b/dashboard/src/pages/Control/CronJobs/index.tsx @@ -3,7 +3,7 @@ import { Button, Card, Empty, Form, Segmented, Spin, Tooltip } from "antd"; import { LayoutGrid, List, RefreshCw } from "lucide-react"; import type { CronJobSpecOutput } from "../../../api/types"; import { useTranslation } from "react-i18next"; -import { useNavigate } from "react-router-dom"; +import { useNavigate, useSearchParams } from "react-router-dom"; import { setPendingPrefillText } from "../../Chat/hooks/chatStore"; import { createColumns, @@ -84,6 +84,7 @@ function CronJobsPage() { const { isMobile, viewMode, setViewMode, showCardView } = useCardTableView("table"); const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); const { activeAgentId, activeAgent } = useAgent(); const canManageJobs = activeAgent?.is_owner === true; const taskExamples = useTaskExamples(activeAgentId); @@ -147,6 +148,14 @@ function CronJobsPage() { setDrawerOpen(true); }; + useEffect(() => { + if (searchParams.get("new") !== "1") return; + handleCreate(); + const next = new URLSearchParams(searchParams); + next.delete("new"); + setSearchParams(next, { replace: true }); + }, [searchParams, setSearchParams]); + const handleSuggestionClick = (text: string) => { setPendingPrefillText(text); navigate("/chat", { state: { prefillInput: text } }); diff --git a/dashboard/src/pages/Control/RemoteDesktop/index.module.less b/dashboard/src/pages/Control/RemoteDesktop/index.module.less index 227f2395..ed9c1b83 100644 --- a/dashboard/src/pages/Control/RemoteDesktop/index.module.less +++ b/dashboard/src/pages/Control/RemoteDesktop/index.module.less @@ -1,3 +1,15 @@ +.embedded { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.embeddedTabs { + flex-shrink: 0; + padding: 0 0 10px; +} + .panels { flex: 1; min-height: 0; diff --git a/dashboard/src/pages/Control/RemoteDesktop/index.tsx b/dashboard/src/pages/Control/RemoteDesktop/index.tsx index 175e75aa..13f53e0a 100644 --- a/dashboard/src/pages/Control/RemoteDesktop/index.tsx +++ b/dashboard/src/pages/Control/RemoteDesktop/index.tsx @@ -1,5 +1,6 @@ import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { Segmented } from "antd"; import { Monitor, Smartphone } from "lucide-react"; import PageShell from "../../../layouts/PageShell"; import { usePathTabs } from "../../../hooks/usePathTabs"; @@ -22,7 +23,11 @@ const TAB_ICONS = { phone: Smartphone, } as const; -export default function RemoteDesktopPage() { +export default function RemoteDesktopPage({ + embedded = false, +}: { + embedded?: boolean; +}) { const { t } = useTranslation(); const user = useCurrentUser(); const { mobileEnabled } = useServerCapabilities(); @@ -71,6 +76,45 @@ export default function RemoteDesktopPage() { `remoteDesktopHub.tabs.${activeTab}`, )}`; + const panels = ( +
+ {isMounted("desktop") && ( +
+ +
+ )} + {isMounted("phone") && ( +
+ +
+ )} +
+ ); + + if (embedded) { + return ( +
+
+ pathTabs.onChange(String(value))} + options={pathTabs.options} + /> +
+ {panels} +
+ ); + } + return ( -
- {isMounted("desktop") && ( -
- -
- )} - {isMounted("phone") && ( -
- -
- )} -
+ {panels}
); } diff --git a/dashboard/src/pages/Control/Terminal/index.tsx b/dashboard/src/pages/Control/Terminal/index.tsx index 46c239ef..7f7a28fa 100644 --- a/dashboard/src/pages/Control/Terminal/index.tsx +++ b/dashboard/src/pages/Control/Terminal/index.tsx @@ -168,9 +168,12 @@ const TerminalTab = memo(function TerminalTab({ onStateChange: (state) => { onConnStateChange(sessionId, state); }, - onExit: (code) => { + onExit: (code, message) => { + const reason = message?.trim() ? ` ${message.trim()}` : ""; termRefStable.current.current?.write( - `\r\n\x1b[90m[${t("terminal.processExited")} (${code})]\x1b[0m\r\n`, + `\r\n\x1b[90m[${t( + "terminal.processExited", + )} (${code})]${reason}\x1b[0m\r\n`, ); }, }; diff --git a/dashboard/src/pages/Control/Terminal/useTerminal.ts b/dashboard/src/pages/Control/Terminal/useTerminal.ts index aabf2568..f628c6b5 100644 --- a/dashboard/src/pages/Control/Terminal/useTerminal.ts +++ b/dashboard/src/pages/Control/Terminal/useTerminal.ts @@ -22,7 +22,7 @@ export interface TerminalCallbacks { onOutput: (data: string) => void; /** Scrollback replay from the server (on re-attach). Reset + write. */ onHistory?: (data: string) => void; - onExit?: (code: number) => void; + onExit?: (code: number, message?: string) => void; onStateChange?: (state: TerminalConnState) => void; } @@ -311,7 +311,7 @@ function openWs(session: TerminalSession) { session.exited = true; clearReconnectTimer(session); setConnState(session, "error"); - fanOut(session, (cbs) => cbs.onExit?.(-1)); + fanOut(session, (cbs) => cbs.onExit?.(-1, msg.message)); } } catch { // Non-JSON — ignore. @@ -493,7 +493,9 @@ function reconnect(id: string) { if (!session || !session.agentId) return; session.exited = false; session.reconnectAttempts = 0; + session.scrollback = ""; clearReconnectTimer(session); + fanOut(session, (cbs) => cbs.onHistory?.("")); openWs(session); } diff --git a/dashboard/src/pages/Control/TokenUsage/index.tsx b/dashboard/src/pages/Control/TokenUsage/index.tsx index 8181798f..34492ea1 100644 --- a/dashboard/src/pages/Control/TokenUsage/index.tsx +++ b/dashboard/src/pages/Control/TokenUsage/index.tsx @@ -39,6 +39,7 @@ import { useTranslation } from "react-i18next"; import PageShell from "../../../layouts/PageShell"; import { useIsMobile } from "../../../hooks/useIsMobile"; import { UsageStats, type UsageStatItem } from "./UsageStats"; +import { splitLocalCloudTokens } from "../../../utils/localModelUsage"; import { useUserRole } from "../../../hooks/useUserRole"; import { request, requestBlob } from "../../../api/request"; import { useAgent } from "../../../context/AgentContext"; @@ -581,18 +582,38 @@ function SummaryView({ const expertPie = useMemo(() => toPieData(byExpert), [byExpert]); const modelPie = useMemo(() => toPieData(byModel), [byModel]); const dayBars = useMemo(() => [...byDay].reverse().slice(-14), [byDay]); + const localCloud = useMemo(() => splitLocalCloudTokens(byModel), [byModel]); + const localCloudData = useMemo( + () => [ + { name: t("tokenUsage.localTokens"), value: localCloud.local }, + { name: t("tokenUsage.cloudTokens"), value: localCloud.cloud }, + ], + [localCloud.cloud, localCloud.local, t], + ); return (
+
+ {isMounted("browser") && ( +
+ +
+ )} + {isMounted("terminal") && ( +
+ +
+ )} +
+ ); + + if (embedded) { + return ( +
+
+ pathTabs.onChange(String(value))} + options={pathTabs.options} + /> +
+ {panels} +
+ ); + } + return ( -
- {isMounted("browser") && ( -
- -
- )} - {isMounted("terminal") && ( -
- -
- )} -
+ {panels}
); } diff --git a/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx new file mode 100644 index 00000000..04949764 --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/CloudMountPanel.tsx @@ -0,0 +1,106 @@ +import { useEffect, useState } from "react"; +import { Button, Input, Space, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import { knowledgeBasesApi } from "../../api/modules/knowledgeBases"; +import { message } from "../../utils/antdMessage"; + +interface CloudMountPanelProps { + kbId: string; +} + +export function CloudMountPanel({ kbId }: CloudMountPanelProps) { + const { t } = useTranslation(); + const [url, setUrl] = useState(""); + const [provider, setProvider] = useState("ima"); + const [mounted, setMounted] = useState(false); + const [busy, setBusy] = useState(false); + + const load = async () => { + const next = await knowledgeBasesApi.getMount(kbId); + if (next.kind === "cloud") { + setMounted(next.mounted); + setUrl(next.cloud_url || ""); + setProvider(next.cloud_provider || "ima"); + } + }; + + useEffect(() => { + void load().catch(() => undefined); + }, [kbId]); + + const mount = async () => { + if (!url.trim()) { + message.error(t("knowledgeBases.cloudUrlRequired")); + return; + } + setBusy(true); + try { + await knowledgeBasesApi.setMount(kbId, "", "", { + kind: "cloud", + cloud_url: url.trim(), + cloud_provider: provider.trim() || "ima", + }); + message.success(t("knowledgeBases.cloudMountSaved")); + await load(); + } catch (err) { + message.error( + err instanceof Error + ? err.message + : t("knowledgeBases.cloudMountFailed"), + ); + } finally { + setBusy(false); + } + }; + + const distill = async () => { + const folder = window.prompt(t("knowledgeBases.cloudDistillDest")); + if (!folder) return; + setBusy(true); + try { + const result = await knowledgeBasesApi.distillMount(kbId, folder); + message.success( + t("knowledgeBases.cloudDistillDone", { path: result.distill_path }), + ); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("knowledgeBases.distillFailed"), + ); + } finally { + setBusy(false); + } + }; + + return ( +
+ + {t("knowledgeBases.cloudMountTitle")} + + + {t("knowledgeBases.cloudMountHint")} + + + setProvider(event.target.value)} + placeholder={t("knowledgeBases.cloudProvider")} + style={{ width: 120 }} + /> + setUrl(event.target.value)} + placeholder={t("knowledgeBases.cloudUrl")} + style={{ minWidth: 280 }} + /> + + {mounted && ( + + )} + +
+ ); +} diff --git a/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx new file mode 100644 index 00000000..50cef9ae --- /dev/null +++ b/dashboard/src/pages/KnowledgeBases/LocalMountPanel.tsx @@ -0,0 +1,115 @@ +import { useEffect, useState } from "react"; +import { Button, Input, List, Space, Tag, Typography } from "antd"; +import { useTranslation } from "react-i18next"; +import { knowledgeBasesApi } from "../../api/modules/knowledgeBases"; +import { message } from "../../utils/antdMessage"; + +interface LocalMountPanelProps { + kbId: string; +} + +export function LocalMountPanel({ kbId }: LocalMountPanelProps) { + const { t } = useTranslation(); + const [source, setSource] = useState(""); + const [dest, setDest] = useState(""); + const [entries, setEntries] = useState< + Array<{ path: string; name: string; is_dir: boolean; size: number }> + >([]); + const [mounted, setMounted] = useState(false); + const [busy, setBusy] = useState(false); + + const load = async () => { + const next = await knowledgeBasesApi.getMount(kbId); + setMounted(next.mounted); + setSource(next.source_path || ""); + setDest(next.distill_path || ""); + setEntries(next.entries || []); + }; + + useEffect(() => { + void load().catch(() => undefined); + }, [kbId]); + + const mount = async () => { + if (!source.trim()) { + message.error(t("knowledgeBases.mountSourceRequired")); + return; + } + setBusy(true); + try { + await knowledgeBasesApi.setMount(kbId, source.trim(), dest.trim()); + message.success(t("knowledgeBases.mountSaved")); + await load(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("knowledgeBases.mountFailed"), + ); + } finally { + setBusy(false); + } + }; + + const distill = async () => { + const folder = window.prompt(t("knowledgeBases.distillDest")); + if (!folder) return; + setBusy(true); + try { + const result = await knowledgeBasesApi.distillMount(kbId, folder); + message.success( + t("knowledgeBases.distillDone", { + count: result.copied, + path: result.distill_path, + }), + ); + await load(); + } catch (err) { + message.error( + err instanceof Error ? err.message : t("knowledgeBases.distillFailed"), + ); + } finally { + setBusy(false); + } + }; + + return ( +
+ {t("knowledgeBases.mountTitle")} + + {t("knowledgeBases.mountHint")} + + + setSource(event.target.value)} + placeholder={t("knowledgeBases.mountSource")} + style={{ minWidth: 280 }} + /> + + {mounted && ( + + )} + + {mounted && ( + ( + + {item.name}{" "} + + {item.is_dir + ? t("knowledgeBases.mountDir") + : t("knowledgeBases.mountFile")} + + + )} + /> + )} +
+ ); +} diff --git a/dashboard/src/pages/KnowledgeBases/index.tsx b/dashboard/src/pages/KnowledgeBases/index.tsx index 470b6d0d..eaa89e5e 100644 --- a/dashboard/src/pages/KnowledgeBases/index.tsx +++ b/dashboard/src/pages/KnowledgeBases/index.tsx @@ -117,6 +117,8 @@ import { isEditableKnowledgeDocument, isKnowledgeMarkdownDocument, } from "../../utils/knowledgeDocPreview"; +import { CloudMountPanel } from "./CloudMountPanel"; +import { LocalMountPanel } from "./LocalMountPanel"; import TextDocumentEditorModal, { type TextDocumentFormat, } from "./TextDocumentEditorModal"; @@ -2162,6 +2164,8 @@ export default function KnowledgeBasesPage() { {selected.description || t("knowledgeBases.noDescription")} + +
([]); const [previewFullscreen, setPreviewFullscreen] = useState(false); const [previewPending, setPreviewPending] = useState(false); + const [moduleToggles, setModuleToggles] = useState>( + {}, + ); + const [sourceDest, setSourceDest] = useState(""); + const [downloading, setDownloading] = useState(false); + const autoStartRef = useRef(false); + const iframeRef = useRef(null); const applyOverview = useCallback( async (quiet = false) => { @@ -52,6 +59,7 @@ export default function OrganizationPage() { try { const next = await orgModuleApi.overview(); setOverview(next); + if (next.module_toggles) setModuleToggles(next.module_toggles); return next; } catch (err) { if (!quiet) { @@ -166,6 +174,50 @@ export default function OrganizationPage() { } }); + useEffect(() => { + if (autoStartRef.current) return; + if (!overview?.start_available || overview.sidecar_reachable) return; + autoStartRef.current = true; + void startSidecar(); + }, [overview?.start_available, overview?.sidecar_reachable]); + + const toggleModule = async (key: string, enabled: boolean) => { + const next = { ...moduleToggles, [key]: enabled }; + setModuleToggles(next); + try { + const saved = await orgModuleApi.setModules({ [key]: enabled }); + setModuleToggles(saved.updates); + } catch (err) { + setModuleToggles(moduleToggles); + message.error( + err instanceof Error ? err.message : t("organization.saveFailed"), + ); + } + }; + + const downloadSource = async () => { + const dest = sourceDest.trim(); + if (!dest) { + message.error(t("organization.downloadSourceDest")); + return; + } + setDownloading(true); + try { + const result = await orgModuleApi.downloadSource(dest); + message.success( + t("organization.downloadSourceDone", { path: result.path }), + ); + } catch (err) { + message.error( + err instanceof Error + ? err.message + : t("organization.downloadSourceFailed"), + ); + } finally { + setDownloading(false); + } + }; + const assemble = () => runAction("assemble", async () => { const result: OrgAssembleResult = await orgModuleApi.assemble(); @@ -203,17 +255,78 @@ export default function OrganizationPage() { ? formatServerIsoDateTime(overview.last_sync, timeZone) : t("organization.neverSynced"); const catalog = overview?.catalog ?? []; - const previewUrl = overview?.sidecar_url - ? `${overview.sidecar_url.replace(/\/$/, "")}/` - : ""; + const disabledKeys = useMemo( + () => + catalog + .filter((row) => moduleToggles[row.key] === false) + .map((row) => row.key), + [catalog, moduleToggles], + ); + const previewUrl = useMemo(() => { + if (!overview?.sidecar_url) return ""; + const base = `${overview.sidecar_url.replace(/\/$/, "")}/`; + if (!disabledKeys.length) return base; + return `${base}?freeos_disabled=${encodeURIComponent( + disabledKeys.join(","), + )}`; + }, [overview?.sidecar_url, disabledKeys]); const showFrame = Boolean(sidecarUp && previewUrl); + const pushTogglesToPreview = useCallback(() => { + const frame = iframeRef.current?.contentWindow; + if (!frame) return; + frame.postMessage( + { type: "freeos:module-toggles", disabled: disabledKeys }, + "*", + ); + }, [disabledKeys]); + + useEffect(() => { + if (!showFrame) return; + pushTogglesToPreview(); + }, [showFrame, pushTogglesToPreview]); + return ( + + {(sidecarUp || overview?.enabled) && ( + + )} @@ -505,6 +618,9 @@ export default function OrganizationPage() {

{t("organization.catalogTitle")}

+

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

{catalog.map((row) => (
@@ -513,16 +629,53 @@ export default function OrganizationPage() { {row.locked ? t("organization.locked") - : t("organization.optional")} + : moduleToggles[row.key] === false + ? t("organization.catalogDisabled") + : t("organization.catalogEnabled")}

{isZh ? row.description_zh : row.description}

+ {!row.locked && ( + + void toggleModule(row.key, checked) + } + /> + )}
))}
+ + {(sidecarUp || overview?.enabled) && ( +
+

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

+

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

+ + setSourceDest(event.target.value)} + placeholder={t("organization.downloadSourceDest")} + style={{ minWidth: 260 }} + /> + + +
+ )}