Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` 在桌面壳里进第一个智能体。组织仍是侧栏里的另一间房间,首次会话不自动走进去。

### 文档

Expand Down
115 changes: 114 additions & 1 deletion dashboard/src/components/AuthGuard.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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();
Expand Down Expand Up @@ -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(
<MemoryRouter initialEntries={["/projects?desktop=1"]}>
<Routes>
<Route
path="/projects"
element={
<AuthGuard>
<div>conversation list</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
</Routes>
</MemoryRouter>,
);

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(
<MemoryRouter initialEntries={["/organization?desktop=1"]}>
<Routes>
<Route
path="/organization"
element={
<AuthGuard>
<div>org room</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
</Routes>
</MemoryRouter>,
);

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(
<MemoryRouter initialEntries={["/chat/main?desktop=1"]}>
<Routes>
<Route
path="/chat/:agentId"
element={
<AuthGuard>
<div>first agent</div>
</AuthGuard>
}
/>
<Route path="/login" element={<div>login wall</div>} />
<Route path="/setup" element={<div>model setup</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);

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,
Expand Down
30 changes: 24 additions & 6 deletions dashboard/src/components/AuthGuard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
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";
import { applyUserLocale } from "../utils/locale";
import {
DESKTOP_MODEL_SETUP_PATH,
desktopPostSessionPath,
isDesktopLaunchDumpPath,
needsDesktopModelOnboarding,
} from "../utils/desktopOnboarding";
import { isDesktopShell } from "../utils/desktopShell";
Expand All @@ -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);
Expand All @@ -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);
};

Expand Down Expand Up @@ -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(
Expand Down
46 changes: 46 additions & 0 deletions dashboard/src/layouts/ChatIndexRedirect.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={["/chat?desktop=1"]}>
<Routes>
<Route path="/chat" element={<ChatIndexRedirect />} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);

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(
<MemoryRouter initialEntries={["/chat"]}>
<Routes>
<Route path="/chat" element={<ChatIndexRedirect />} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
</Routes>
</MemoryRouter>,
);

expect(await screen.findByText("conversation list")).toBeInTheDocument();
expect(screen.queryByText("first agent")).toBeNull();
});
});
14 changes: 12 additions & 2 deletions dashboard/src/layouts/ChatIndexRedirect.tsx
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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();
Expand All @@ -32,5 +34,13 @@ export default function ChatIndexRedirect() {
);
}
}
if (isDesktopShell(location.search)) {
return (
<Navigate
to={`${DESKTOP_FIRST_CHAT_PATH}${location.search}${location.hash}`}
replace
/>
);
}
return <Navigate to={CONVERSATION_LIST_PATH} replace />;
}
13 changes: 12 additions & 1 deletion dashboard/src/routes/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
Expand Down Expand Up @@ -66,6 +68,15 @@ function RedirectPreserveSearch({ to }: { to: string }) {
return <Navigate to={`${to}${location.search}${location.hash}`} replace />;
}

/** 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 <Navigate to={`${to}${location.search}${location.hash}`} replace />;
}

function TasksToProjects() {
const location = useLocation();
const params = new URLSearchParams(location.search);
Expand Down Expand Up @@ -476,6 +487,6 @@ export const routeConfigs: RouteConfig[] = [

// Misc
{ path: "/pwa-debug", element: <PwaDebugPage /> },
{ path: "/", element: <RedirectPreserveSearch to="/projects" /> },
{ path: "/", element: <RootRedirect /> },
{ path: "*", element: <NotFoundPage /> },
];
39 changes: 39 additions & 0 deletions dashboard/src/routes/rootRedirect.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<MemoryRouter initialEntries={[entry]}>
<Routes>
<Route path="/" element={root.element} />
<Route path="/chat/:agentId" element={<div>first agent</div>} />
<Route path="/projects" element={<div>conversation list</div>} />
<Route path="/organization" element={<div>org room</div>} />
</Routes>
</MemoryRouter>,
);
}

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();
});
});
20 changes: 20 additions & 0 deletions dashboard/src/utils/desktopOnboarding.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DESKTOP_RETURNING_HOME_PATH,
desktopAfterModelSetupPath,
desktopPostSessionPath,
isDesktopLaunchDumpPath,
isDesktopModelOnboardingDone,
markDesktopModelOnboardingDone,
needsDesktopModelOnboarding,
Expand Down Expand Up @@ -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);
});
});
Loading
Loading