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` 查看)。无论哪种方式,都请尽快在 **个人设置 → 修改密码** 中更换,避免服务暴露到公网时被未授权访问。