From 497cf1d19b443e4e9dabe91766e926f3d2118388 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 04:23:16 +0000 Subject: [PATCH 1/3] Unify conversation home and align FreeOS copy for non-IT users. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sidebar 对话 now opens the workspace conversation list (same store as 项目/任务/对话区). Desktop local guests can reach Models and Knowledge. Organization import/push shows a landed receipt with 是/否 instead of true/false. Naming: 专家 vs 员工 vs 专项助手. Co-authored-by: XYAI Labs --- CHANGELOG.md | 7 +++ dashboard/src/layouts/ChatIndexRedirect.tsx | 40 +++++++++++++ dashboard/src/layouts/MainLayout/index.tsx | 2 +- dashboard/src/layouts/Sidebar.tsx | 10 +--- .../src/layouts/conversationHome.test.ts | 31 ++++++++++ dashboard/src/layouts/conversationHome.ts | 41 +++++++++++++ dashboard/src/layouts/sidebarNav.test.ts | 20 ++++++- dashboard/src/layouts/sidebarNav.tsx | 8 ++- dashboard/src/locales/en.json | 30 ++++++---- dashboard/src/locales/zh.json | 30 ++++++---- dashboard/src/pages/Organization/index.tsx | 57 +++++++++++++++++-- dashboard/src/pages/Projects/index.tsx | 2 +- .../Models/components/LocalHardwarePanel.tsx | 50 +++++++++++++++- dashboard/src/routes/controlAdminPath.test.ts | 4 ++ dashboard/src/routes/index.tsx | 15 +++-- dashboard/src/utils/desktopOnboarding.test.ts | 4 +- dashboard/src/utils/desktopOnboarding.ts | 4 +- .../utils/permissions.desktopGuest.test.ts | 49 ++++++++++++++++ dashboard/src/utils/permissions.ts | 24 ++++++++ 19 files changed, 375 insertions(+), 53 deletions(-) create mode 100644 dashboard/src/layouts/ChatIndexRedirect.tsx create mode 100644 dashboard/src/layouts/conversationHome.test.ts create mode 100644 dashboard/src/layouts/conversationHome.ts create mode 100644 dashboard/src/utils/permissions.desktopGuest.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 875d294a..cc878663 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,13 @@ ## [Unreleased] +### 变更 + +- 对话与工作区共用一份列表:侧栏「对话」进入工作区「对话」标签;点开一条才进入 `/chat/:专家/:会话`。工作区侧栏默认打开「项目」标签。 +- 用户可见用词对齐:员工 = 已入编部门;同事 = 同一组织里一起聊的人;专家 / 智能助手 = 尚未入编的帮手。个性化「子智能体」改为「专项助手」。群聊选择「同事或智能助手」。 +- 桌面未注册的 local guest 也能打开「模型」和「知识库」(第一台电脑免登录)。 +- 组织页导入/推送后显示落地回执,循环时间线用「是/否」而不是 true/false。 + ### 新增 - 模型页本机大模型:Ollama 已安装但未运行时显示「启动 Ollama」(Windows 走已知安装路径的 `Ollama.exe` / `ollama app` / `ollama serve`);缺少可自动处理的依赖时提供「一键下载安装」,否则说明需手动安装;「搜索本机模型」后台扫描 `.gguf` / `.ggml`(以及明确的本地 LLM safetensors 目录),结果进入本机列表并可注册到 Ollama 供对话切换。 diff --git a/dashboard/src/layouts/ChatIndexRedirect.tsx b/dashboard/src/layouts/ChatIndexRedirect.tsx new file mode 100644 index 00000000..b5d8bb04 --- /dev/null +++ b/dashboard/src/layouts/ChatIndexRedirect.tsx @@ -0,0 +1,40 @@ +import { Navigate, useLocation } from "react-router-dom"; +import { useAgent } from "../context/AgentContext"; +import { CONVERSATION_LIST_PATH, chatCanvasPath } from "./conversationHome"; + +type ChatIntentState = { + newChat?: boolean; + prefillInput?: string; + attachKnowledgeBaseId?: string; +}; + +function hasChatIntent(state: unknown): boolean { + if (!state || typeof state !== "object") return false; + const next = state as ChatIntentState; + return Boolean( + next.newChat || next.prefillInput || next.attachKnowledgeBaseId, + ); +} + +/** + * Bare ``/chat`` is not a second conversation home. + * Intent (new chat / prefill / attach KB) opens the canvas; otherwise the + * shared workspace 对话 list. + */ +export default function ChatIndexRedirect() { + const location = useLocation(); + const { activeAgentId, agents } = useAgent(); + if (hasChatIntent(location.state)) { + const agentId = activeAgentId || agents[0]?.agent_id; + if (agentId) { + return ( + + ); + } + } + return ; +} diff --git a/dashboard/src/layouts/MainLayout/index.tsx b/dashboard/src/layouts/MainLayout/index.tsx index 854afecf..16a7e8ac 100644 --- a/dashboard/src/layouts/MainLayout/index.tsx +++ b/dashboard/src/layouts/MainLayout/index.tsx @@ -55,7 +55,7 @@ function ChatWithKey() { export default function MainLayout() { const location = useLocation(); const currentPath = location.pathname; - const selectedKey = resolveSelectedKey(currentPath); + const selectedKey = resolveSelectedKey(currentPath, location.search); const isMobile = useIsMobile(); const { layoutMode } = useLayoutMode(); useDashboardPushToast(); diff --git a/dashboard/src/layouts/Sidebar.tsx b/dashboard/src/layouts/Sidebar.tsx index 1b9cb7cc..5d043b27 100644 --- a/dashboard/src/layouts/Sidebar.tsx +++ b/dashboard/src/layouts/Sidebar.tsx @@ -31,6 +31,7 @@ import { type NavItem, type NavSection, } from "./sidebarNav"; +import { CONVERSATION_LIST_PATH } from "./conversationHome"; import styles from "./Sidebar.module.less"; import { typeSize } from "../utils/mobileTypeScale"; import { DESKTOP_DRAG_REGION_CLASS } from "../utils/desktopChrome"; @@ -467,13 +468,6 @@ export default function Sidebar({ }, [isMinimal, selectMinimalPane]); const handleNavigate = (path: string) => { - // When navigating to /chat, preserve the current chatId in the URL so the - // Chat component is not remounted (key stays the same) and the user stays - // on their most recent conversation instead of seeing a blank welcome screen. - if (path === "/chat" && window.location.pathname.startsWith("/chat/")) { - if (isMobile) onToggle(); - return; - } navigate(path); if (isMobile) onToggle(); }; @@ -486,7 +480,7 @@ export default function Sidebar({ window.dispatchEvent(new Event(EXPAND_CHAT_RAIL_EVENT)); setChatSidebarOpen(true); if (!window.location.pathname.startsWith("/chat")) { - navigate("/chat"); + navigate(CONVERSATION_LIST_PATH); } if (isMobile) onToggle(); }, [ diff --git a/dashboard/src/layouts/conversationHome.test.ts b/dashboard/src/layouts/conversationHome.test.ts new file mode 100644 index 00000000..f3fce33a --- /dev/null +++ b/dashboard/src/layouts/conversationHome.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "vitest"; +import { + CONVERSATION_LIST_PATH, + WORKSPACE_PROJECTS_PATH, + chatCanvasPath, + resolveWorkspaceNavKey, + workspaceViewFromSearch, +} from "./conversationHome"; + +describe("conversationHome", () => { + it("treats the workspace conversations tab as the chat list", () => { + expect(workspaceViewFromSearch("")).toBe("conversations"); + expect(workspaceViewFromSearch("?view=projects")).toBe("projects"); + expect(workspaceViewFromSearch("view=tasks")).toBe("tasks"); + expect(resolveWorkspaceNavKey("/projects")).toBe("chat"); + expect(resolveWorkspaceNavKey("/projects", "?view=projects")).toBe( + "projects", + ); + expect(resolveWorkspaceNavKey("/projects", "?view=tasks")).toBe("projects"); + expect(resolveWorkspaceNavKey("/chat/agent-1/thr_1")).toBe("chat"); + expect(resolveWorkspaceNavKey("/tasks")).toBe("projects"); + expect(resolveWorkspaceNavKey("/experts")).toBeNull(); + }); + + it("opens a canvas only when an expert is known", () => { + expect(chatCanvasPath()).toBe(CONVERSATION_LIST_PATH); + expect(chatCanvasPath("exp-1")).toBe("/chat/exp-1"); + expect(chatCanvasPath("exp-1", "thr_1")).toBe("/chat/exp-1/thr_1"); + expect(WORKSPACE_PROJECTS_PATH).toBe("/projects?view=projects"); + }); +}); diff --git a/dashboard/src/layouts/conversationHome.ts b/dashboard/src/layouts/conversationHome.ts new file mode 100644 index 00000000..b6f99503 --- /dev/null +++ b/dashboard/src/layouts/conversationHome.ts @@ -0,0 +1,41 @@ +/** Shared conversation list lives under the workspace 对话 tab. */ +export const CONVERSATION_LIST_PATH = "/projects"; +export const WORKSPACE_PROJECTS_PATH = "/projects?view=projects"; +export const WORKSPACE_TASKS_PATH = "/projects?view=tasks"; + +export type WorkspaceView = "conversations" | "projects" | "tasks"; + +export function workspaceViewFromSearch(search = ""): WorkspaceView { + const raw = search.startsWith("?") ? search.slice(1) : search; + const view = new URLSearchParams(raw).get("view"); + if (view === "projects" || view === "tasks") return view; + return "conversations"; +} + +/** Sidebar key for the unified conversation / workspace hub. */ +export function resolveWorkspaceNavKey( + pathname: string, + search = "", +): "chat" | "projects" | null { + if (pathname === "/tasks" || pathname.startsWith("/tasks/")) { + return "projects"; + } + if (pathname === "/projects" || pathname.startsWith("/projects/")) { + return workspaceViewFromSearch(search) === "conversations" + ? "chat" + : "projects"; + } + if (pathname === "/chat" || pathname.startsWith("/chat/")) { + return "chat"; + } + return null; +} + +export function chatCanvasPath( + agentId?: string | null, + threadId?: string | null, +): string { + if (agentId && threadId) return `/chat/${agentId}/${threadId}`; + if (agentId) return `/chat/${agentId}`; + return CONVERSATION_LIST_PATH; +} diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 4424399c..63265c74 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -39,7 +39,7 @@ describe("sidebarNav", () => { ]); }); - it("hides models and knowledge when the user lacks those permissions", () => { + it("hides models and knowledge when a non-desktop user lacks those permissions", () => { const guest = { id: 2, username: "guest", @@ -58,6 +58,24 @@ describe("sidebarNav", () => { expect(keys).toContain("experts"); }); + it("shows models and knowledge for the first-run desktop guest", () => { + const desktopGuest = { + id: 3, + username: "local", + role: "user", + permissions: [], + is_local: true, + } as OctopUser; + const items = buildNavSections(desktopGuest).flatMap((s) => s.items); + const keys = items.map((i) => i.key); + expect(keys).toContain("models"); + expect(keys).toContain("knowledge-bases"); + expect(items.find((i) => i.key === "chat")?.path).toBe("/projects"); + expect(items.find((i) => i.key === "projects")?.path).toBe( + "/projects?view=projects", + ); + }); + it("labels organization without the OS suffix", () => { expect(zh.nav.organization).toBe("组织"); expect(en.nav.organization).toBe("Organization"); diff --git a/dashboard/src/layouts/sidebarNav.tsx b/dashboard/src/layouts/sidebarNav.tsx index f940d33b..b8c981c2 100644 --- a/dashboard/src/layouts/sidebarNav.tsx +++ b/dashboard/src/layouts/sidebarNav.tsx @@ -11,6 +11,10 @@ import { } from "lucide-react"; import type { OctopUser } from "../api/modules/auth"; import { navAllowed } from "../utils/permissions"; +import { + CONVERSATION_LIST_PATH, + WORKSPACE_PROJECTS_PATH, +} from "./conversationHome"; export const EXPANDED_WIDTH = 220; export const COLLAPSED_WIDTH = 56; @@ -54,7 +58,7 @@ export function buildNavSections( const items: NavItem[] = [ { key: "chat", - path: "/chat", + path: CONVERSATION_LIST_PATH, icon: , labelKey: "nav.conversations", }, @@ -95,7 +99,7 @@ export function buildNavSections( }); items.push({ key: "projects", - path: "/projects", + path: WORKSPACE_PROJECTS_PATH, icon: , labelKey: "nav.workspace", }); diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index abe98683..f00da4e8 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -107,7 +107,12 @@ "catalogEnabled": "Shown", "catalogDisabled": "Hidden", "catalogToggleHint": "When off, the module disappears from the openXYOS preview sidebar.", - "autoStartingSidecar": "Starting the openXYOS sidecar…" + "autoStartingSidecar": "Starting the openXYOS sidecar…", + "glossary": "Employees: already assigned to a department. Colleagues: peers in the same organization (use this word in group chat). Experts / smart helpers: not yet placed in a department — manage them on Experts.", + "receiptTitle": "What just happened", + "assembleReceipt": "Imported {{count}} employee(s) assigned to a department. In group chat call them colleagues, not experts.", + "assembleNext": "Next: open Conversations and invite colleagues to a group. Unassigned helpers stay on Experts.", + "packReceipt": "Packed and tried to push to openXYOS. Details below." }, "systemSettings": { "subtitle": "Workbench, remote desktop, and host administration in one place." @@ -130,8 +135,8 @@ "createGroup": "New group", "groupTitle": "Group name", "groupTitlePlaceholder": "Optional, e.g. Weekly sync", - "groupMembers": "Choose experts", - "groupMembersRequired": "Select at least two experts", + "groupMembers": "Choose colleagues or smart helpers", + "groupMembersRequired": "Select at least two colleagues or smart helpers", "groupCreated": "Group chat created", "groupCreateFailed": "Could not create the group chat", "kindDirect": "Direct", @@ -828,7 +833,7 @@ } }, "experts": { - "subtitle": "Spin up a pre-configured agent (with identity / soul / heartbeat baked in) from a scenario template. Each expert ships with its own system prompt — once created, just bind a provider and start it.", + "subtitle": "Experts (also called smart helpers) are not yet assigned to a department. Departmental staff are employees on Organization; in group chat call same-org peers colleagues.", "loadFailed": "Failed to load experts", "loadDetailFailed": "Failed to load expert details", "share": { @@ -1378,14 +1383,14 @@ "skillPicker": "Select skills", "skillPickerSearch": "Search skills", "skillPickerEmpty": "No skills available", - "expertPicker": "Select experts", - "expertPickerSearch": "Search experts or agents", - "expertPickerEmpty": "No other experts available", + "expertPicker": "Choose colleagues or smart helpers", + "expertPickerSearch": "Search colleagues or smart helpers", + "expertPickerEmpty": "No one to chat with yet. Add a smart helper on Experts, or import employees on Organization — then call them colleagues in the group.", "expertPickerManage": "Manage experts", "expertPickerCheck": "Select {{name}} for group chat", - "expertPickerGroupHint": "Check multiple experts or agents, then enter a group chat", + "expertPickerGroupHint": "Check colleagues or smart helpers, then enter a group chat and @ them", "expertPickerEnterGroupCount": "Enter group chat ({{count}})", - "expertPickerGroupOpened": "Opened group chat", + "expertPickerGroupOpened": "Opened group chat. Type @ to mention a colleague or smart helper", "subagentPicker": "Select subagents", "subagentPickerSearch": "Search subagents", "subagentPickerEmpty": "No installed subagents", @@ -3244,7 +3249,10 @@ "onnxNotReady": "not ready", "onnxCacheDir": "Cache: {{dir}}", "onnxLocalCached": "Cached models", - "onnxQuickDownload": "Quick download from catalog…" + "onnxQuickDownload": "Quick download from catalog…", + "useInChat": "Switch in chat", + "localInstallRegistered": "Installed {{name}} and registered it. Open Conversations to switch.", + "localInstallNeedProvider": "Downloaded. Enable the Ollama provider on this page, then switch in chat." }, "advancedSettings": { "description": "Manage runtime configuration and environment variables.", @@ -5399,7 +5407,7 @@ "pageShell": { "experts": { "title": "Experts", - "subtitle": "Create and manage your AI experts, or pick a scenario template to get started quickly." + "subtitle": "Experts / smart helpers are not yet in a department. Employees live on Organization; in group chat call same-org peers colleagues." }, "tasks": { "title": "Automation", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index d17f1523..c8d589fc 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -107,7 +107,12 @@ "catalogEnabled": "已显示", "catalogDisabled": "已隐藏", "catalogToggleHint": "关闭后,该模块会从右侧 openXYOS 预览的侧栏消失。", - "autoStartingSidecar": "正在自动启动 openXYOS 边车…" + "autoStartingSidecar": "正在自动启动 openXYOS 边车…", + "glossary": "员工:已经编入某个部门。同事:同一组织里一起干活的人(群聊里这样叫)。专家 / 智能助手:还没编入部门的帮手,在「专家」页管理。", + "receiptTitle": "刚才的结果", + "assembleReceipt": "已导入 {{count}} 名员工(已入编部门)。群聊里请称他们为同事,不要叫专家。", + "assembleNext": "下一步:打开「对话」建群,把同事拉进来。还没入编的帮手在「专家」页。", + "packReceipt": "已打包并尝试推送到 openXYOS。下面是落地说明。" }, "systemSettings": { "subtitle": "工作台、远程桌面与主机管理集中在这里。" @@ -130,8 +135,8 @@ "createGroup": "建群", "groupTitle": "群聊名称", "groupTitlePlaceholder": "可选,例如「产品周会」", - "groupMembers": "选择专家", - "groupMembersRequired": "请至少选择两位专家", + "groupMembers": "选择同事或智能助手", + "groupMembersRequired": "请至少选择两位(同事或智能助手)", "groupCreated": "群聊已创建", "groupCreateFailed": "无法创建群聊", "kindDirect": "单聊", @@ -828,7 +833,7 @@ } }, "experts": { - "subtitle": "基于已配置的场景模板,一键创建预置身份/灵魂/心跳的智能体。每个专家自带 system prompt,新建后再为它绑定 provider 并启动即可。", + "subtitle": "专家(也叫智能助手)是还没编入部门的帮手。已入编的叫员工,在「组织」;同一组织里一起聊时叫同事。", "loadFailed": "加载专家失败", "loadDetailFailed": "加载详情失败", "share": { @@ -1378,14 +1383,14 @@ "skillPicker": "选择技能", "skillPickerSearch": "搜索技能", "skillPickerEmpty": "暂无可用技能", - "expertPicker": "选择专家", - "expertPickerSearch": "搜索专家或智能体", - "expertPickerEmpty": "暂无其他专家", + "expertPicker": "选择同事或智能助手", + "expertPickerSearch": "搜索同事或智能助手", + "expertPickerEmpty": "还没有可一起聊的人。到「专家」添加智能助手,或到「组织」导入员工后,群聊里称他们为同事。", "expertPickerManage": "管理专家", "expertPickerCheck": "选择 {{name}} 加入群聊", - "expertPickerGroupHint": "勾选多位专家或智能体,随时进入群聊", + "expertPickerGroupHint": "勾选同事或智能助手,进入群聊后可以 @ 他们", "expertPickerEnterGroupCount": "进入群聊({{count}})", - "expertPickerGroupOpened": "已进入群聊", + "expertPickerGroupOpened": "已进入群聊,输入 @ 可点名同事或智能助手", "subagentPicker": "选择子智能体", "subagentPickerSearch": "搜索子智能体", "subagentPickerEmpty": "暂无已安装的子智能体", @@ -3241,7 +3246,10 @@ "onnxNotReady": "未就绪", "onnxCacheDir": "缓存目录:{{dir}}", "onnxLocalCached": "已缓存模型", - "onnxQuickDownload": "从目录快速下载…" + "onnxQuickDownload": "从目录快速下载…", + "useInChat": "去对话切换", + "localInstallRegistered": "已安装 {{name}} 并登记到模型列表。打开「对话」即可切换。", + "localInstallNeedProvider": "模型已下载。请先在本页启用 Ollama 提供商,再到对话里切换。" }, "advancedSettings": { "description": "管理运行配置和环境变量等高级选项。", @@ -5538,7 +5546,7 @@ "pageShell": { "experts": { "title": "专家", - "subtitle": "创建与管理你的 AI 专家,也可从模板库挑选场景一键新建。" + "subtitle": "专家 / 智能助手:还没编入部门。员工在「组织」;群聊里对同一组织的人请称同事。" }, "tasks": { "title": "自动化", diff --git a/dashboard/src/pages/Organization/index.tsx b/dashboard/src/pages/Organization/index.tsx index bc85210f..5ce1a03f 100644 --- a/dashboard/src/pages/Organization/index.tsx +++ b/dashboard/src/pages/Organization/index.tsx @@ -35,6 +35,11 @@ import styles from "./Organization.module.less"; type ActionKey = "assemble" | "pack" | "loop" | "sidecar" | "produce" | null; +type LastReceipt = { + kind: "assemble" | "pack" | "loop"; + lines: string[]; +}; + function metric(value: number | undefined): string { return typeof value === "number" ? String(value) : "—"; } @@ -49,6 +54,7 @@ export default function OrganizationPage() { const [busy, setBusy] = useState(null); const [loopProof, setLoopProof] = useState(null); const [lastActionNotes, setLastActionNotes] = useState([]); + const [lastReceipt, setLastReceipt] = useState(null); const [previewFullscreen, setPreviewFullscreen] = useState(false); const [previewPending, setPreviewPending] = useState(false); const [moduleToggles, setModuleToggles] = useState>( @@ -272,10 +278,24 @@ export default function OrganizationPage() { await saveSourceTo(dest); }; + const flagLabel = (value: boolean | undefined) => + value ? t("organization.yes") : t("organization.no"); + const assemble = () => runAction("assemble", async () => { const result: OrgAssembleResult = await orgModuleApi.assemble(); + const names = result.spawned + .map((row) => row.name || row.slug) + .filter(Boolean); setLastActionNotes(result.notes); + setLastReceipt({ + kind: "assemble", + lines: [ + t("organization.assembleReceipt", { count: result.spawned.length }), + ...names.slice(0, 6), + t("organization.assembleNext"), + ], + }); message.success( t("organization.assembleDone", { count: result.spawned.length }), ); @@ -297,13 +317,18 @@ export default function OrganizationPage() { runAction("pack", async () => { const result: OrgPackResult = await orgModuleApi.pack(); if (result.applied.landed) setLanded(result.applied.landed); + const remote = result.applied.remote_applied + ? t("organization.packRemoteYes") + : t("organization.packRemoteNo"); setLastActionNotes([ ...result.pack.notes, ...result.applied.notes, - result.applied.remote_applied - ? t("organization.packRemoteYes") - : t("organization.packRemoteNo"), + remote, ]); + setLastReceipt({ + kind: "pack", + lines: [t("organization.packReceipt"), remote], + }); message.success(t("organization.packDone")); }); @@ -313,14 +338,20 @@ export default function OrganizationPage() { setLoopProof(proof); if (proof.landed) setLanded(proof.landed); setLastActionNotes(proof.notes); + setLastReceipt({ + kind: "loop", + lines: [ + proof.ok ? t("organization.loopOk") : t("organization.loopPartial"), + `${t("organization.timelineRemote")}: ${flagLabel( + Boolean(proof.remote_applied), + )}`, + ], + }); message.success( proof.ok ? t("organization.loopOk") : t("organization.loopPartial"), ); }); - const flagLabel = (value: boolean | undefined) => - value ? t("organization.yes") : t("organization.no"); - const progressLabel = busy === "assemble" ? t("organization.progressAssemble") @@ -393,6 +424,7 @@ export default function OrganizationPage() {

{t("organization.heroTitle")}

{t("organization.heroStory")}

+

{t("organization.glossary")}

{overview?.enabled ? t("organization.on") : t("organization.off")} @@ -657,6 +689,19 @@ export default function OrganizationPage() {
+ {lastReceipt ? ( +
+

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

+
    + {lastReceipt.lines.map((line) => ( +
  1. {line}
  2. + ))} +
+
+ ) : null} +

{t("organization.timelineTitle")} diff --git a/dashboard/src/pages/Projects/index.tsx b/dashboard/src/pages/Projects/index.tsx index 0a30722c..168522dd 100644 --- a/dashboard/src/pages/Projects/index.tsx +++ b/dashboard/src/pages/Projects/index.tsx @@ -306,7 +306,7 @@ export default function ProjectsPage() { ]; return ( - +

{zoneTabs.map((tab) => ( diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index b6bfafed..9f3affa7 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -13,6 +13,7 @@ import { Typography, } from "antd"; import { useTranslation } from "react-i18next"; +import { useNavigate } from "react-router-dom"; import { localModelsApi, type LocalInstalledModel, @@ -25,7 +26,30 @@ import { canPickDesktopFolder, pickDesktopFolder, } from "../../../../utils/desktopFolder"; +import { providerApi } from "../../../../api/modules/provider"; import { message } from "../../../../utils/antdMessage"; +import { CONVERSATION_LIST_PATH } from "../../../../layouts/conversationHome"; +import { isOllamaProviderRow } from "../presetUtils"; + +async function registerOllamaModel(name: string): Promise { + const providers = await providerApi.listProviders(); + const ollama = providers.find( + (row) => + row.id === "ollama" || + isOllamaProviderRow({ + name: row.name, + base_url: row.current_base_url, + api_key: row.current_api_key, + }), + ); + if (!ollama) return false; + const already = [...(ollama.models ?? []), ...(ollama.extra_models ?? [])].some( + (model) => model.id === name || model.name === name, + ); + if (already) return true; + await providerApi.addModel(ollama.id, { id: name, name }); + return true; +} function mergeModels( ...groups: Array @@ -51,6 +75,7 @@ function canRegister(item: LocalInstalledModel): boolean { export function LocalHardwarePanel() { const { t } = useTranslation(); + const navigate = useNavigate(); const [probe, setProbe] = useState(null); const [loading, setLoading] = useState(false); const [installing, setInstalling] = useState(null); @@ -170,7 +195,20 @@ export function LocalHardwarePanel() { await refresh(); return; } - message.success(t("models.localInstallDone", { name })); + let registered = false; + try { + registered = await registerOllamaModel(name); + } catch { + registered = false; + } + message.success( + t( + registered + ? "models.localInstallRegistered" + : "models.localInstallNeedProvider", + { name }, + ), + ); await refresh(); } catch (err) { message.error( @@ -358,7 +396,6 @@ export function LocalHardwarePanel() { {t("models.localHardwareHint")} - {ollamaInstalled && !ollamaUp && ( )} + + {t("models.localInstalled")} diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 5102904a..096ad8bd 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -135,6 +135,10 @@ describe("unknown dashboard paths", () => { expect(resolveSelectedKey("/workbench/terminal")).toBe("personalization"); expect(resolveSelectedKey("/acp")).toBe("personalization"); expect(resolveSelectedKey("/models")).toBe("models"); + expect(resolveSelectedKey("/projects")).toBe("chat"); + expect(resolveSelectedKey("/projects", "?view=projects")).toBe("projects"); + expect(resolveSelectedKey("/projects", "?view=tasks")).toBe("projects"); + expect(resolveSelectedKey("/chat/exp-1/thr_1")).toBe("chat"); expect(resolveSelectedKey("/personalization/connectors")).toBe( "personalization", ); diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index bb6777cc..228dd35c 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -2,6 +2,8 @@ import { lazy } from "react"; import { Navigate, useLocation } from "react-router-dom"; import { SYSTEM_SETTINGS_TO_PERSONALIZATION } from "../pages/Agent/Personalization/tabs"; import { isSystemSettingsNavPath } from "../pages/SystemSettings/tabs"; +import ChatIndexRedirect from "../layouts/ChatIndexRedirect"; +import { resolveWorkspaceNavKey } from "../layouts/conversationHome"; // Lazy-loaded pages — Common const ExpertsPage = lazy(() => import("../pages/Experts")); @@ -175,9 +177,10 @@ export function isPersonalizationPath(pathname: string): boolean { ); } -export function resolveSelectedKey(pathname: string): string { +export function resolveSelectedKey(pathname: string, search = ""): string { + const workspaceKey = resolveWorkspaceNavKey(pathname, search); + if (workspaceKey) return workspaceKey; if (pathToKey[pathname]) return pathToKey[pathname]; - if (pathname.startsWith("/chat/")) return "chat"; if (pathname.startsWith("/personalization/")) return "personalization"; if (pathname === "/models" || pathname.startsWith("/models/")) return "models"; @@ -189,8 +192,8 @@ export function resolveSelectedKey(pathname: string): string { } export const routeConfigs: RouteConfig[] = [ - // Chat (handled via ChatWithKey wrapper in MainLayout) - { path: "/chat", element: null, useWrapper: true }, + // Chat canvas; bare /chat deep-links to the shared workspace 对话 list + { path: "/chat", element: }, { path: "/chat/:agentId", element: null, useWrapper: true }, { path: "/chat/:agentId/:threadId", element: null, useWrapper: true }, @@ -366,11 +369,11 @@ export const routeConfigs: RouteConfig[] = [ path: "/plugins", element: , }, - { path: "/sessions", element: }, + { path: "/sessions", element: }, { path: "/cron-jobs", element: }, // Misc { path: "/pwa-debug", element: }, - { path: "/", element: }, + { path: "/", element: }, { path: "*", element: }, ]; diff --git a/dashboard/src/utils/desktopOnboarding.test.ts b/dashboard/src/utils/desktopOnboarding.test.ts index 260255f3..bea51d8e 100644 --- a/dashboard/src/utils/desktopOnboarding.test.ts +++ b/dashboard/src/utils/desktopOnboarding.test.ts @@ -15,11 +15,11 @@ describe("desktopOnboarding", () => { expect(desktopPostSessionPath()).toBe("/setup"); markDesktopModelOnboardingDone(); expect(isDesktopModelOnboardingDone()).toBe(true); - expect(desktopPostSessionPath()).toBe("/chat"); + expect(desktopPostSessionPath()).toBe("/projects"); }); it("treats an existing provider as already finished", () => { - expect(desktopPostSessionPath(true)).toBe("/chat"); + expect(desktopPostSessionPath(true)).toBe("/projects"); expect(isDesktopModelOnboardingDone()).toBe(true); }); }); diff --git a/dashboard/src/utils/desktopOnboarding.ts b/dashboard/src/utils/desktopOnboarding.ts index 8d52dfe6..af1cbbe4 100644 --- a/dashboard/src/utils/desktopOnboarding.ts +++ b/dashboard/src/utils/desktopOnboarding.ts @@ -20,11 +20,11 @@ export function markDesktopModelOnboardingDone(): void { } } -/** Path after a desktop local session: model setup once, then the workspace. */ +/** Path after a desktop local session: model setup once, then the shared conversation list. */ export function desktopPostSessionPath(hasProviders = false): string { if (hasProviders || isDesktopModelOnboardingDone()) { if (hasProviders) markDesktopModelOnboardingDone(); - return "/chat"; + return "/projects"; } return "/setup"; } diff --git a/dashboard/src/utils/permissions.desktopGuest.test.ts b/dashboard/src/utils/permissions.desktopGuest.test.ts new file mode 100644 index 00000000..224d9302 --- /dev/null +++ b/dashboard/src/utils/permissions.desktopGuest.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { + canAccessPath, + isLocalDesktopGuest, + navAllowed, + userCan, +} from "./permissions"; + +const desktopGuest = { + role: "user", + username: "local", + is_local: true, + permissions: [] as string[], +}; + +const restricted = { + role: "user", + username: "guest", + permissions: [] as string[], +}; + +describe("desktop local guest permissions", () => { + it("recognizes the unclaimed local session", () => { + expect(isLocalDesktopGuest(desktopGuest)).toBe(true); + expect(isLocalDesktopGuest(restricted)).toBe(false); + expect(isLocalDesktopGuest({ role: "user", username: "local" })).toBe(true); + }); + + it("can open models and knowledge bases on first-run desktop", () => { + expect(navAllowed(desktopGuest, "models")).toBe(true); + expect(navAllowed(desktopGuest, "knowledge-bases")).toBe(true); + expect(canAccessPath(desktopGuest, "/models")).toBe(true); + expect(canAccessPath(desktopGuest, "/knowledge-bases")).toBe(true); + expect(userCan(desktopGuest, "ollama_models")).toBe(true); + expect(userCan(desktopGuest, "knowledge_bases")).toBe(true); + }); + + it("does not unlock admin pages for the desktop guest", () => { + expect(navAllowed(desktopGuest, "admin-users")).toBe(false); + expect(canAccessPath(desktopGuest, "/personalization/users")).toBe(false); + expect(userCan(desktopGuest, "users")).toBe(false); + }); + + it("still hides models for a regular user without permissions", () => { + expect(navAllowed(restricted, "models")).toBe(false); + expect(canAccessPath(restricted, "/models")).toBe(false); + expect(navAllowed(restricted, "knowledge-bases")).toBe(false); + }); +}); diff --git a/dashboard/src/utils/permissions.ts b/dashboard/src/utils/permissions.ts index 558dd0ea..9b3f6ee4 100644 --- a/dashboard/src/utils/permissions.ts +++ b/dashboard/src/utils/permissions.ts @@ -3,6 +3,9 @@ export type PermissionHolder = { role: "admin" | "user" | string; permissions?: string[] | null; + /** Unclaimed desktop / loopback guest from ``/auth/local-session``. */ + is_local?: boolean; + username?: string; }; /** Any-of module keys, or ``"admin"`` for role-only. */ @@ -28,6 +31,20 @@ export const PERM = { advancedPage: ["update", "envs", "tls", "observability", "backup"], } as const satisfies Record; +const DESKTOP_GUEST_KEYS = new Set([ + ...PERM.modelsPage, + ...PERM.knowledgeBasesPage, +]); + +/** First-run desktop guest should reach 模型 / 知识库 without a claimed account. */ +export function isLocalDesktopGuest( + user: PermissionHolder | null | undefined, +): boolean { + if (!user) return false; + if (user.is_local) return true; + return user.username === "local"; +} + /** Sidebar item key → permission keys. Shared with path guards. */ export const NAV_PERMISSIONS = { channels: PERM.channels, @@ -77,6 +94,7 @@ export function userCan( ): boolean { if (!user) return false; if (user.role === "admin") return true; + if (isLocalDesktopGuest(user) && DESKTOP_GUEST_KEYS.has(key)) return true; return (user.permissions ?? []).includes(key); } @@ -87,6 +105,12 @@ export function userCanAny( ): boolean { if (!user) return false; if (user.role === "admin") return true; + if ( + isLocalDesktopGuest(user) && + keys.some((k) => DESKTOP_GUEST_KEYS.has(k)) + ) { + return true; + } const held = new Set(user.permissions ?? []); return keys.some((k) => held.has(k)); } From ec0c647f9f7ce33256f6befb13a6b87937769bc8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 04:24:32 +0000 Subject: [PATCH 2/3] Apply Prettier to product-coherence dashboard files. Co-authored-by: XYAI Labs --- dashboard/src/layouts/ChatIndexRedirect.tsx | 6 +----- .../Settings/Models/components/LocalHardwarePanel.tsx | 7 ++++--- dashboard/src/routes/controlAdminPath.test.ts | 4 +++- 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/dashboard/src/layouts/ChatIndexRedirect.tsx b/dashboard/src/layouts/ChatIndexRedirect.tsx index b5d8bb04..c8bccdc4 100644 --- a/dashboard/src/layouts/ChatIndexRedirect.tsx +++ b/dashboard/src/layouts/ChatIndexRedirect.tsx @@ -28,11 +28,7 @@ export default function ChatIndexRedirect() { const agentId = activeAgentId || agents[0]?.agent_id; if (agentId) { return ( - + ); } } diff --git a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx index 9f3affa7..d7f02382 100644 --- a/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx +++ b/dashboard/src/pages/Settings/Models/components/LocalHardwarePanel.tsx @@ -43,9 +43,10 @@ async function registerOllamaModel(name: string): Promise { }), ); if (!ollama) return false; - const already = [...(ollama.models ?? []), ...(ollama.extra_models ?? [])].some( - (model) => model.id === name || model.name === name, - ); + const already = [ + ...(ollama.models ?? []), + ...(ollama.extra_models ?? []), + ].some((model) => model.id === name || model.name === name); if (already) return true; await providerApi.addModel(ollama.id, { id: name, name }); return true; diff --git a/dashboard/src/routes/controlAdminPath.test.ts b/dashboard/src/routes/controlAdminPath.test.ts index 096ad8bd..b8b36772 100644 --- a/dashboard/src/routes/controlAdminPath.test.ts +++ b/dashboard/src/routes/controlAdminPath.test.ts @@ -130,7 +130,9 @@ describe("unknown dashboard paths", () => { }); it("highlights personalization for former control and admin URLs", () => { - expect(resolveSelectedKey("/system-settings/users")).toBe("personalization"); + expect(resolveSelectedKey("/system-settings/users")).toBe( + "personalization", + ); expect(resolveSelectedKey("/admin/users")).toBe("personalization"); expect(resolveSelectedKey("/workbench/terminal")).toBe("personalization"); expect(resolveSelectedKey("/acp")).toBe("personalization"); From bd06e443c78e04ff1ce6729e66e9f70d6c64044b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 16 Sep 2026 04:39:57 +0000 Subject: [PATCH 3/3] =?UTF-8?q?Lock=20=E5=91=98=E5=B7=A5=20/=20=E5=90=8C?= =?UTF-8?q?=E4=BA=8B=20/=20=E4=B8=93=E5=AE=B6=C2=B7=E6=99=BA=E8=83=BD?= =?UTF-8?q?=E5=8A=A9=E6=89=8B=20glossary=20in=20user-facing=20copy.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Employees are departmental staff. Colleagues are same-org peers in chat and group UX. Experts / smart helpers are not yet assigned to a department. Do not call staff 专家 or unassigned helpers 员工. Co-authored-by: XYAI Labs --- CHANGELOG.md | 2 +- dashboard/src/layouts/sidebarNav.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cc878663..aac685e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ ### 变更 -- 对话与工作区共用一份列表:侧栏「对话」进入工作区「对话」标签;点开一条才进入 `/chat/:专家/:会话`。工作区侧栏默认打开「项目」标签。 +- 对话与工作区共用一份列表:侧栏「对话」进入工作区「对话」标签;点开一条才进入聊天画布。工作区侧栏默认打开「项目」标签。 - 用户可见用词对齐:员工 = 已入编部门;同事 = 同一组织里一起聊的人;专家 / 智能助手 = 尚未入编的帮手。个性化「子智能体」改为「专项助手」。群聊选择「同事或智能助手」。 - 桌面未注册的 local guest 也能打开「模型」和「知识库」(第一台电脑免登录)。 - 组织页导入/推送后显示落地回执,循环时间线用「是/否」而不是 true/false。 diff --git a/dashboard/src/layouts/sidebarNav.test.ts b/dashboard/src/layouts/sidebarNav.test.ts index 63265c74..0c6539db 100644 --- a/dashboard/src/layouts/sidebarNav.test.ts +++ b/dashboard/src/layouts/sidebarNav.test.ts @@ -80,4 +80,19 @@ describe("sidebarNav", () => { expect(zh.nav.organization).toBe("组织"); expect(en.nav.organization).toBe("Organization"); }); + + it("locks the employee / colleague / expert glossary", () => { + expect(zh.organization.glossary).toContain("员工:已经编入某个部门"); + expect(zh.organization.glossary).toContain("同事:同一组织"); + expect(zh.organization.glossary).toContain("专家 / 智能助手"); + expect(zh.organization.assembleBody).toContain("员工"); + expect(zh.organization.assembleBody).toContain("同事"); + expect(zh.organization.assembleBody).not.toContain("出现在专家列表"); + expect(zh.organization.produceTitle).toContain("专家"); + expect(zh.chat.expertPicker).toContain("同事或智能助手"); + expect(zh.projects.groupMembers).toContain("同事或智能助手"); + expect(en.organization.glossary).toContain("Employees: already assigned"); + expect(en.organization.glossary).toContain("Colleagues: peers"); + expect(en.chat.expertPicker).toContain("colleagues or smart helpers"); + }); });