diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb7e782..468e0fb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ - 桌面 `POST /api/auth/local-session` 对已有 `~/.freeos`(多用户 / 组织映射行)或 WebView 非 `127.0.0.1` Host 返回 403,前端重试后掉进注册登录。本机会话在 loopback / `*.localhost` / Origin 为本机时签发 JWT 并选用已有工作室账号;SPA 在 `/` 跳到 `/projects` 丢掉 `?desktop=1` 之前记住桌面壳。403 修复后的路径是:可选模型配置(云 Key / 本机,可跳过)→ 第一个智能体 `/chat/main`,不经过登录墙,也不停在工作台列表。`/setup` 在 guest 已创建后不再打回登录页。 - Windows 安装 / 覆盖安装 / 同版本重装会刷新 `~/.freeos/portable`:Setup 先结束仍在运行的 FreeOS,写入 `$INSTDIR\FREEOS_INSTALL_STAMP`,并清除已解压树里的 `FREEOS_STAMP`。下次启动按安装戳 + 包内戳重新解压 `packages/` 与内嵌 Dashboard,#88 及后续宿主/界面修复不必再手工热补。用户数据库与设置仍留在 `~/.freeos`。 +- 桌面回访(已有 JWT / `has_providers=true`)不再停在 Octop `/projects` 工作台,也不把宿主原生组织页当首屏。窗口打开 `/chat/main?desktop=1`;SPA `/` 与裸 `/chat` 在桌面壳里进第一个智能体。组织仍是侧栏里的另一间房间,首次会话不自动走进去。 ### 文档 diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index 71ffd740..2b340aee 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -26,7 +26,7 @@ vi.mock("../context/AuthPromptContext", () => ({ })); import AuthGuard from "./AuthGuard"; -import { getAuthToken } from "../api/request"; +import { getAuthToken, setAuthToken } from "../api/request"; function renderGuard() { return render( @@ -49,6 +49,15 @@ function renderGuard() { ); } +const guestUser = { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, +}; + describe("AuthGuard local session", () => { beforeEach(() => { localStorage.clear(); @@ -230,6 +239,110 @@ describe("AuthGuard local session", () => { expect(screen.queryByText("conversation list")).toBeNull(); }); + it("moves a returning desktop token off /projects even when has_providers", async () => { + setAuthToken("existing-token"); + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: true, + desktop: true, + }); + me.mockResolvedValue(guestUser); + + render( + + + +
conversation list
+ + } + /> + login wall} /> + model setup} /> + first agent} /> +
+
, + ); + + expect(await screen.findByText("first agent")).toBeInTheDocument(); + expect(screen.queryByText("conversation list")).toBeNull(); + expect(screen.queryByText("model setup")).toBeNull(); + expect(screen.queryByText("login wall")).toBeNull(); + expect(localSession).not.toHaveBeenCalled(); + }); + + it("does not keep the organization room as the desktop first screen", async () => { + setAuthToken("existing-token"); + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: true, + desktop: true, + }); + me.mockResolvedValue(guestUser); + organizationIdentityStatus.mockResolvedValue({ + integrated: true, + authority: "dual", + studio: "freeos", + room: "organization", + }); + + render( + + + +
org room
+ + } + /> + login wall} /> + model setup} /> + first agent} /> +
+
, + ); + + expect(await screen.findByText("first agent")).toBeInTheDocument(); + expect(screen.queryByText("org room")).toBeNull(); + expect(screen.queryByText("login wall")).toBeNull(); + }); + + it("keeps a returning desktop user on /chat/main", async () => { + setAuthToken("existing-token"); + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: true, + desktop: true, + }); + me.mockResolvedValue(guestUser); + + render( + + + +
first agent
+ + } + /> + login wall} /> + model setup} /> + conversation list} /> +
+
, + ); + + expect(await screen.findByText("first agent")).toBeInTheDocument(); + expect(screen.queryByText("conversation list")).toBeNull(); + expect(screen.queryByText("model setup")).toBeNull(); + }); + it("does not keep the /projects dump as home on first desktop launch", async () => { getAuthStatus.mockResolvedValue({ setup_required: true, diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index f99640eb..34c320ed 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -1,5 +1,5 @@ -import { useEffect, useState } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; +import { useEffect, useRef, useState } from "react"; +import { useLocation, useNavigate, useSearchParams } from "react-router-dom"; import { Spin } from "antd"; import { clearAuthToken, getAuthToken, setAuthToken } from "../api/request"; import { authApi, type OctopUser } from "../api/modules/auth"; @@ -7,6 +7,7 @@ import { applyUserLocale } from "../utils/locale"; import { DESKTOP_MODEL_SETUP_PATH, desktopPostSessionPath, + isDesktopLaunchDumpPath, needsDesktopModelOnboarding, } from "../utils/desktopOnboarding"; import { isDesktopShell } from "../utils/desktopShell"; @@ -24,6 +25,9 @@ interface AuthGuardProps { */ export default function AuthGuard({ children }: AuthGuardProps) { const navigate = useNavigate(); + const location = useLocation(); + const locationRef = useRef(location); + locationRef.current = location; const [params] = useSearchParams(); const desktopQuery = params.toString(); const [checking, setChecking] = useState(true); @@ -44,10 +48,20 @@ export default function AuthGuard({ children }: AuthGuardProps) { }; const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => { - // Open → optional model (skippable) → first agent. Never the login wall. + // Open → optional model (skippable) → first agent. Never login, org, or + // the Octop `/projects` dump — even when has_providers skips the model step. const next = desktopPostSessionPath(hasProviders); - if (!cancelled) navigate(next, { replace: true }); - if (next === DESKTOP_MODEL_SETUP_PATH) return; + const { pathname, search } = locationRef.current; + const dump = isDesktopLaunchDumpPath(pathname, search); + if ( + next === DESKTOP_MODEL_SETUP_PATH || + dump || + needsDesktopModelOnboarding(hasProviders) + ) { + const suffix = `${search}${locationRef.current.hash}`; + if (!cancelled) navigate(`${next}${suffix}`, { replace: true }); + if (next === DESKTOP_MODEL_SETUP_PATH) return; + } await adopt(me); }; @@ -140,7 +154,11 @@ export default function AuthGuard({ children }: AuthGuardProps) { try { const me = await authApi.me(); - await adopt(me); + if (desktop) { + await enterAfterSession(me, hasProviders); + } else { + await adopt(me); + } } catch { if ( await tryLocalSession( diff --git a/dashboard/src/layouts/ChatIndexRedirect.test.tsx b/dashboard/src/layouts/ChatIndexRedirect.test.tsx new file mode 100644 index 00000000..b0b21105 --- /dev/null +++ b/dashboard/src/layouts/ChatIndexRedirect.test.tsx @@ -0,0 +1,46 @@ +import { describe, expect, it, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; + +vi.mock("../context/AgentContext", () => ({ + useAgent: () => ({ activeAgentId: null, agents: [] }), +})); + +import ChatIndexRedirect from "./ChatIndexRedirect"; + +describe("ChatIndexRedirect", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it("opens the first desktop agent instead of the /projects dump", async () => { + render( + + + } /> + first agent} /> + conversation list} /> + + , + ); + + expect(await screen.findByText("first agent")).toBeInTheDocument(); + expect(screen.queryByText("conversation list")).toBeNull(); + }); + + it("keeps the web console conversation list for bare /chat", async () => { + render( + + + } /> + first agent} /> + conversation list} /> + + , + ); + + expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(screen.queryByText("first agent")).toBeNull(); + }); +}); diff --git a/dashboard/src/layouts/ChatIndexRedirect.tsx b/dashboard/src/layouts/ChatIndexRedirect.tsx index c8bccdc4..245c9721 100644 --- a/dashboard/src/layouts/ChatIndexRedirect.tsx +++ b/dashboard/src/layouts/ChatIndexRedirect.tsx @@ -1,5 +1,7 @@ import { Navigate, useLocation } from "react-router-dom"; import { useAgent } from "../context/AgentContext"; +import { DESKTOP_FIRST_CHAT_PATH } from "../utils/desktopOnboarding"; +import { isDesktopShell } from "../utils/desktopShell"; import { CONVERSATION_LIST_PATH, chatCanvasPath } from "./conversationHome"; type ChatIntentState = { @@ -18,8 +20,8 @@ function hasChatIntent(state: unknown): boolean { /** * Bare ``/chat`` is not a second conversation home. - * Intent (new chat / prefill / attach KB) opens the canvas; otherwise the - * shared workspace 对话 list. + * Desktop opens the first agent; the web console uses the shared 对话 list. + * Intent (new chat / prefill / attach KB) still opens a canvas. */ export default function ChatIndexRedirect() { const location = useLocation(); @@ -32,5 +34,13 @@ export default function ChatIndexRedirect() { ); } } + if (isDesktopShell(location.search)) { + return ( + + ); + } return ; } diff --git a/dashboard/src/routes/index.tsx b/dashboard/src/routes/index.tsx index ecfd5a37..03f22934 100644 --- a/dashboard/src/routes/index.tsx +++ b/dashboard/src/routes/index.tsx @@ -4,6 +4,8 @@ import { SYSTEM_SETTINGS_TO_PERSONALIZATION } from "../pages/Agent/Personalizati import { isSystemSettingsNavPath } from "../pages/SystemSettings/tabs"; import ChatIndexRedirect from "../layouts/ChatIndexRedirect"; import { resolveWorkspaceNavKey } from "../layouts/conversationHome"; +import { DESKTOP_FIRST_CHAT_PATH } from "../utils/desktopOnboarding"; +import { isDesktopShell } from "../utils/desktopShell"; // Lazy-loaded pages — Common const ExpertsPage = lazy(() => import("../pages/Experts")); @@ -66,6 +68,15 @@ function RedirectPreserveSearch({ to }: { to: string }) { return ; } +/** Desktop first paint is studio chat; the web console still uses `/projects`. */ +function RootRedirect() { + const location = useLocation(); + const to = isDesktopShell(location.search) + ? DESKTOP_FIRST_CHAT_PATH + : "/projects"; + return ; +} + function TasksToProjects() { const location = useLocation(); const params = new URLSearchParams(location.search); @@ -476,6 +487,6 @@ export const routeConfigs: RouteConfig[] = [ // Misc { path: "/pwa-debug", element: }, - { path: "/", element: }, + { path: "/", element: }, { path: "*", element: }, ]; diff --git a/dashboard/src/routes/rootRedirect.test.tsx b/dashboard/src/routes/rootRedirect.test.tsx new file mode 100644 index 00000000..3c44931b --- /dev/null +++ b/dashboard/src/routes/rootRedirect.test.tsx @@ -0,0 +1,39 @@ +import { describe, expect, it, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { MemoryRouter, Route, Routes } from "react-router-dom"; +import { routeConfigs } from "./index"; + +function renderRoot(entry: string) { + const root = routeConfigs.find((rc) => rc.path === "/"); + if (!root) throw new Error("missing / route"); + return render( + + + + first agent} /> + conversation list} /> + org room} /> + + , + ); +} + +describe("RootRedirect", () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + }); + + it("sends desktop / to /chat/main, not /projects or organization", async () => { + renderRoot("/?desktop=1"); + expect(await screen.findByText("first agent")).toBeInTheDocument(); + expect(screen.queryByText("conversation list")).toBeNull(); + expect(screen.queryByText("org room")).toBeNull(); + }); + + it("keeps the web console / → /projects dump", async () => { + renderRoot("/"); + expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(screen.queryByText("first agent")).toBeNull(); + }); +}); diff --git a/dashboard/src/utils/desktopOnboarding.test.ts b/dashboard/src/utils/desktopOnboarding.test.ts index 66368f43..1a862df3 100644 --- a/dashboard/src/utils/desktopOnboarding.test.ts +++ b/dashboard/src/utils/desktopOnboarding.test.ts @@ -5,6 +5,7 @@ import { DESKTOP_RETURNING_HOME_PATH, desktopAfterModelSetupPath, desktopPostSessionPath, + isDesktopLaunchDumpPath, isDesktopModelOnboardingDone, markDesktopModelOnboardingDone, needsDesktopModelOnboarding, @@ -37,4 +38,23 @@ describe("desktopOnboarding", () => { expect(isDesktopModelOnboardingDone()).toBe(true); expect(desktopPostSessionPath()).toBe(DESKTOP_RETURNING_HOME_PATH); }); + + it("treats Octop home and org-on-launch as dumps, not studio chat", () => { + expect(isDesktopLaunchDumpPath("/")).toBe(true); + expect(isDesktopLaunchDumpPath("/chat")).toBe(true); + expect(isDesktopLaunchDumpPath("/projects")).toBe(true); + expect(isDesktopLaunchDumpPath("/projects", "?desktop=1")).toBe(true); + expect(isDesktopLaunchDumpPath("/projects", "?view=projects")).toBe(false); + expect(isDesktopLaunchDumpPath("/projects", "?view=tasks")).toBe(false); + expect(isDesktopLaunchDumpPath("/organization")).toBe(false); + expect(isDesktopLaunchDumpPath("/organization", "?desktop=1")).toBe(true); + expect( + isDesktopLaunchDumpPath("/organization/workspace", "?desktop=1"), + ).toBe(true); + expect(isDesktopLaunchDumpPath(DESKTOP_FIRST_CHAT_PATH)).toBe(false); + expect(isDesktopLaunchDumpPath(DESKTOP_FIRST_CHAT_PATH, "?desktop=1")).toBe( + false, + ); + expect(isDesktopLaunchDumpPath("/experts")).toBe(false); + }); }); diff --git a/dashboard/src/utils/desktopOnboarding.ts b/dashboard/src/utils/desktopOnboarding.ts index 0ada630c..4dd421a6 100644 --- a/dashboard/src/utils/desktopOnboarding.ts +++ b/dashboard/src/utils/desktopOnboarding.ts @@ -11,6 +11,34 @@ export const DESKTOP_RETURNING_HOME_PATH = DESKTOP_FIRST_CHAT_PATH; export const DESKTOP_MODEL_SETUP_PATH = "/setup"; +/** + * Launch landings that are not studio chat: Octop `/projects` dump, bare + * `/chat`, SPA `/`, or the organization room when `?desktop=1` is still on + * the URL (host first paint). Sidebar can still open those rooms later. + */ +export function isDesktopLaunchDumpPath( + pathname: string, + search = "", +): boolean { + const params = new URLSearchParams( + search.startsWith("?") ? search.slice(1) : search, + ); + const launchQuery = params.get("desktop") === "1"; + if (pathname === "/" || pathname === "") return true; + if (pathname === "/chat") return true; + if (pathname === "/projects" || pathname === "/projects/") { + const view = params.get("view"); + return view !== "projects" && view !== "tasks"; + } + if ( + launchQuery && + (pathname === "/organization" || pathname.startsWith("/organization/")) + ) { + return true; + } + return false; +} + /** True after the desktop first-run model step was saved or skipped. */ export function isDesktopModelOnboardingDone(): boolean { if (typeof window === "undefined") return false; diff --git a/desktop/README.md b/desktop/README.md index a4cec135..2e16a22d 100644 --- a/desktop/README.md +++ b/desktop/README.md @@ -124,10 +124,11 @@ On first open the shell: 2. Does **not** extract or start `openxyos-runtime` unless `FREEOS_ORG_SIDECAR=1`. 3. Starts the FreeOS host with `FREEOS_HOME` and `FREEOS_ORG_ENABLE=1`. It does not set `OPENXYOS_BASE_URL` / `FREEOS_ORG_SIDECAR_URL` by default. -4. Opens the desktop window on the host UI with a local guest session (no - login wall). Chat and **Organization** are both available in-host — no - Node process on 3780 is required. Register or sign in later when a save - needs an account. +4. Opens the desktop window on `/chat/main` with a local guest session (no + login wall). First session is optional model setup (skippable) → first + agent chat. **Organization** stays a later sidebar room — it is not the + first screen. No Node process on 3780 is required. Register or sign in + later when a save needs an account. ## Build green zip diff --git a/desktop/src/process.go b/desktop/src/process.go index 286795d2..a95bc228 100644 --- a/desktop/src/process.go +++ b/desktop/src/process.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "net/url" "os" "os/exec" "path/filepath" @@ -90,12 +91,19 @@ func dashboardURL(port int) string { // withDesktopQuery marks the SPA as the Wails shell so it can skip PWA // service-worker caching and treat first open as a local desktop session. +// Origin-only URLs open the first agent (`/chat/main`), not `/` → `/projects` +// and not the organization room. func withDesktopQuery(base string) string { if strings.Contains(base, "desktop=1") { return base } + trimmed := strings.TrimRight(base, "/") if strings.Contains(base, "?") { return base + "&desktop=1" } - return strings.TrimRight(base, "/") + "/?desktop=1" + parsed, err := url.Parse(trimmed) + if err == nil && (parsed.Path == "" || parsed.Path == "/") { + return trimmed + "/chat/main?desktop=1" + } + return trimmed + "?desktop=1" } diff --git a/desktop/src/settings_home_test.go b/desktop/src/settings_home_test.go index 8372fcc5..ee990b67 100644 --- a/desktop/src/settings_home_test.go +++ b/desktop/src/settings_home_test.go @@ -134,12 +134,18 @@ func TestHostLaunchEnvSidecarOptIn(t *testing.T) { } func TestWithDesktopQueryMarksSpa(t *testing.T) { - if got := withDesktopQuery("http://127.0.0.1:8088/"); got != "http://127.0.0.1:8088/?desktop=1" { + if got := withDesktopQuery("http://127.0.0.1:8088/"); got != "http://127.0.0.1:8088/chat/main?desktop=1" { t.Fatalf("got %q", got) } + if got := withDesktopQuery("http://127.0.0.1:8088"); got != "http://127.0.0.1:8088/chat/main?desktop=1" { + t.Fatalf("origin: %q", got) + } if got := withDesktopQuery("http://127.0.0.1:8088/?desktop=1"); got != "http://127.0.0.1:8088/?desktop=1" { t.Fatalf("idempotent: %q", got) } + if got := withDesktopQuery("http://127.0.0.1:8088/setup"); got != "http://127.0.0.1:8088/setup?desktop=1" { + t.Fatalf("existing path: %q", got) + } } func TestSidecarReadyRequiresNodeAndServer(t *testing.T) { diff --git a/docs/user-guide.md b/docs/user-guide.md index 17d89c6e..960404d8 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -318,6 +318,8 @@ octop models ollama-list # 列出本机 Ollama 模型(需服务已启动) 打开 **http://127.0.0.1:8088**。桌面或本机首次启动会先发一张工作室访客通行证,不必先填登录墙;需要保存、导出时再注册。远程安装则使用向导创建的账号登录。 +桌面首启与再次打开:可选模型(云密钥或本机 Ollama,可跳过)→ 第一个智能体 `/chat/main`。不要停在 Octop `/projects` 工作台,也不要把组织页当第一屏。 + 组织能力像工作室里的另一间房间:侧栏「组织」走进去试用、布置流程。那间房间有自己的登记本,不会把房间里的管理员钥匙悄悄换成工作室大门的管理员钥匙。两种用法同在一个 FreeOS 里,各自留白。 > ⚠️ **安全提醒**:Docker 首次初始化若未设置 `OCTOP_DEFAULT_PASSWORD`,会自动生成随机管理员密码(写入 `/data/.octop/credential.txt`,可用 `docker exec <容器> cat /data/.octop/credential.txt` 查看)。无论哪种方式,都请尽快在 **个人设置 → 修改密码** 中更换,避免服务暴露到公网时被未授权访问。