diff --git a/CHANGELOG.md b/CHANGELOG.md index 33dac748..234c83a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,10 @@ - 桌面安装包与配置模板不再允许嵌入云厂商 API Key(含 DeepSeek)。首次运行不会预填真实密钥:优先本机 Ollama,云调用在密钥为空时直接拒绝并提示用户自行填写(即使环境里有 `DEEPSEEK_API_KEY` / `OPENAI_API_KEY` / `LLM_API_KEY` 也不写入 `providers`)。打包排除 `.env`、`octop.db`、`.freeos`,打 zip 前扫描 staging。生产/air-gap sidecar 不再回退 `LLM_API_KEY`。已发布的 **0.0.1–0.0.4** 安装包须下架(已从 Release 删除),并轮换可能泄露的 DeepSeek 密钥。 +### 变更 + +- 桌面首次启动不再要求注册/登录。首屏是可选模型配置(云密钥或本机 Ollama,可跳过);跳过或保存后进入默认智能体对话,而不是停在工作台列表。已有提供商或会话的用户不会被再次拦住。账号仍可稍后在头像菜单里领取,供保存/导出/组织房间使用。首次运行与安全条目一致:不预填云密钥,本机 Ollama 优先。 + ### 文档 - 增加阿拉伯语(`README.ar.md`)与葡萄牙语(`README.pt.md`)项目简介,并在各语言 README 的语言切换链接中列出。 diff --git a/dashboard/src/components/AuthGuard.test.tsx b/dashboard/src/components/AuthGuard.test.tsx index 2ae5a88c..fca54a28 100644 --- a/dashboard/src/components/AuthGuard.test.tsx +++ b/dashboard/src/components/AuthGuard.test.tsx @@ -140,6 +140,92 @@ describe("AuthGuard local session", () => { expect(getAuthToken()).toBe("guest-token"); }); + it("sends a provisioned desktop guest to model setup", async () => { + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: false, + desktop: true, + }); + localSession.mockResolvedValue({ + access_token: "guest-token", + token_type: "Bearer", + expires_in: 3600, + user: { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, + }, + token: "guest-token", + }); + + render( + + + +
usable app
+ + } + /> + login wall} /> + model setup} /> +
+
, + ); + + expect(await screen.findByText("model setup")).toBeInTheDocument(); + expect(screen.queryByText("login wall")).toBeNull(); + expect(screen.queryByText("usable app")).toBeNull(); + }); + + it("does not loop returning desktop users who already have a provider", async () => { + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: true, + desktop: true, + }); + localSession.mockResolvedValue({ + access_token: "guest-token", + token_type: "Bearer", + expires_in: 3600, + user: { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, + }, + token: "guest-token", + }); + + render( + + + +
usable app
+ + } + /> + login wall} /> + model setup} /> +
+
, + ); + + expect(await screen.findByText("usable app")).toBeInTheDocument(); + expect(screen.queryByText("model setup")).toBeNull(); + expect(screen.queryByText("login wall")).toBeNull(); + }); + it("opens the studio door even when the organization room is available", async () => { organizationIdentityStatus.mockResolvedValue({ integrated: true, diff --git a/dashboard/src/components/AuthGuard.tsx b/dashboard/src/components/AuthGuard.tsx index 9e18a4e1..490c578a 100644 --- a/dashboard/src/components/AuthGuard.tsx +++ b/dashboard/src/components/AuthGuard.tsx @@ -4,7 +4,7 @@ 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 { desktopPostSessionPath } from "../utils/desktopOnboarding"; +import { needsDesktopModelOnboarding } from "../utils/desktopOnboarding"; import { isDesktopShell } from "../utils/desktopShell"; import { CurrentUserProvider } from "../hooks/useCurrentUser"; import { AuthPromptProvider } from "../context/AuthPromptContext"; @@ -28,7 +28,7 @@ export default function AuthGuard({ children }: AuthGuardProps) { useEffect(() => { let cancelled = false; - const desktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : ""); + const shellDesktop = isDesktopShell(desktopQuery ? `?${desktopQuery}` : ""); const adopt = async (me: OctopUser) => { await applyUserLocale(me.locale); @@ -39,19 +39,26 @@ export default function AuthGuard({ children }: AuthGuardProps) { } }; - const enterAfterSession = async (me: OctopUser, hasProviders: boolean) => { - if (desktop && desktopPostSessionPath(hasProviders) === "/setup") { + const enterAfterSession = async ( + me: OctopUser, + hasProviders: boolean, + desktop: boolean, + ) => { + if (desktop && needsDesktopModelOnboarding(hasProviders)) { if (!cancelled) navigate("/setup", { replace: true }); return; } await adopt(me); }; - const adoptLocal = async (hasProviders = false): Promise => { + const adoptLocal = async ( + hasProviders = false, + desktop = shellDesktop, + ): Promise => { try { const res = await authApi.localSession(); setAuthToken(res.access_token); - await enterAfterSession(res.user, hasProviders); + await enterAfterSession(res.user, hasProviders, desktop); return true; } catch { return false; @@ -62,9 +69,10 @@ export default function AuthGuard({ children }: AuthGuardProps) { attempts: number, delayMs: number, hasProviders = false, + desktop = shellDesktop, ) => { for (let attempt = 0; attempt < attempts; attempt += 1) { - if (await adoptLocal(hasProviders)) return true; + if (await adoptLocal(hasProviders, desktop)) return true; if (attempt < attempts - 1) { await new Promise((resolve) => { window.setTimeout(resolve, delayMs); @@ -74,9 +82,12 @@ export default function AuthGuard({ children }: AuthGuardProps) { return false; }; - const holdForDesktop = async (hasProviders = false) => { + const holdForDesktop = async ( + hasProviders = false, + desktop = shellDesktop, + ) => { while (!cancelled) { - if (await adoptLocal(hasProviders)) return; + if (await adoptLocal(hasProviders, desktop)) return; await new Promise((resolve) => { window.setTimeout(resolve, 400); }); @@ -86,18 +97,21 @@ export default function AuthGuard({ children }: AuthGuardProps) { const check = async () => { try { const status = await authApi.getAuthStatus(); + const desktop = shellDesktop || status.desktop === true; + const hasProviders = status.has_providers === true; if (status.setup_required) { if ( await tryLocalSession( desktop ? 20 : 4, desktop ? 250 : 150, - status.has_providers === true, + hasProviders, + desktop, ) ) return; if (desktop) { - await holdForDesktop(status.has_providers === true); + await holdForDesktop(hasProviders, desktop); return; } clearAuthToken(); @@ -105,12 +119,8 @@ export default function AuthGuard({ children }: AuthGuardProps) { return; } - if ( - desktop && - desktopPostSessionPath(status.has_providers === true) === "/setup" - ) { - if (await tryLocalSession(20, 250, status.has_providers === true)) - return; + if (desktop && needsDesktopModelOnboarding(hasProviders)) { + if (await tryLocalSession(20, 250, hasProviders, desktop)) return; if (!cancelled) navigate("/setup", { replace: true }); return; } @@ -121,12 +131,13 @@ export default function AuthGuard({ children }: AuthGuardProps) { await tryLocalSession( desktop ? 20 : 4, desktop ? 250 : 150, - status.has_providers === true, + hasProviders, + desktop, ) ) return; if (desktop) { - await holdForDesktop(); + await holdForDesktop(hasProviders, desktop); return; } if (!cancelled) { @@ -140,10 +151,17 @@ export default function AuthGuard({ children }: AuthGuardProps) { const me = await authApi.me(); await adopt(me); } catch { - if (await tryLocalSession(desktop ? 20 : 4, desktop ? 250 : 150)) + if ( + await tryLocalSession( + desktop ? 20 : 4, + desktop ? 250 : 150, + hasProviders, + desktop, + ) + ) return; if (desktop) { - await holdForDesktop(); + await holdForDesktop(hasProviders, desktop); return; } if (!cancelled) { @@ -152,7 +170,7 @@ export default function AuthGuard({ children }: AuthGuardProps) { } } } catch { - if (desktop) { + if (shellDesktop) { await holdForDesktop(); return; } diff --git a/dashboard/src/locales/en.json b/dashboard/src/locales/en.json index b4eb82e2..cbf02c66 100644 --- a/dashboard/src/locales/en.json +++ b/dashboard/src/locales/en.json @@ -4669,7 +4669,8 @@ }, "wizard": { "title": "Initial setup", - "desktopTitle": "Configure a model", + "desktopTitle": "Set up a model", + "desktopSubtitle": "Optional — skip anytime and start chatting", "checking": "Checking setup status…", "back": "Back", "sessionExpired": "Wizard session expired. Please verify the bootstrap password again.", @@ -4741,8 +4742,15 @@ }, "model": { "intro": "Start with a local runtime such as Ollama, or a local OpenAI-compatible URL. Cloud providers are optional. You can skip and finish later on Models.", + "desktopIntro": "Choose a local runtime such as Ollama, or paste a cloud API key. You can skip this and chat with the first assistant right away.", "skip": "Skip", "skipToWorkspace": "Skip and enter workspace", + "skipToChat": "Skip and start chatting", + "continueToChat": "Save and start chatting", + "nextHint": "Next: open a conversation with your first assistant.", + "ollamaDetected": "Ollama is running on this computer. You can continue with local models.", + "ollamaInstalled": "Ollama is installed but not running. Start it, continue anyway, or skip and chat first.", + "ollamaMissing": "No local runtime detected yet. Add a cloud API key, or skip and set this up later in Models.", "presetTab": "Preset provider", "customTab": "Custom provider", "showMorePresets": "Show more providers ({{count}})", diff --git a/dashboard/src/locales/zh.json b/dashboard/src/locales/zh.json index 8eef6833..4b0542e0 100644 --- a/dashboard/src/locales/zh.json +++ b/dashboard/src/locales/zh.json @@ -4808,6 +4808,7 @@ "wizard": { "title": "初始化设置", "desktopTitle": "配置模型", + "desktopSubtitle": "可选步骤,随时可跳过并开始对话", "checking": "正在检查初始化状态…", "back": "上一步", "sessionExpired": "向导会话已过期,请重新验证启动密码。", @@ -4880,8 +4881,15 @@ }, "model": { "intro": "先选本机运行时(如 Ollama)或本机 OpenAI 兼容地址。云厂商是可选项。也可以跳过,稍后在「模型」页完成。", + "desktopIntro": "选择本机运行时(如 Ollama),或填写云厂商 API Key。也可以跳过,直接与第一位助手开始对话。", "skip": "跳过", "skipToWorkspace": "跳过,进入工作台", + "skipToChat": "跳过,开始对话", + "continueToChat": "保存并开始对话", + "nextHint": "下一步:进入与第一位助手的对话。", + "ollamaDetected": "已检测到本机 Ollama 正在运行,可直接用本地模型继续。", + "ollamaInstalled": "已安装 Ollama 但尚未运行。可以先启动、直接继续,或跳过先去对话。", + "ollamaMissing": "尚未检测到本机运行时。可以填写云厂商 API Key,或跳过,稍后在「模型」页再设置。", "presetTab": "预置提供商", "customTab": "自定义提供商", "showMorePresets": "显示更多提供商({{count}} 个)", diff --git a/dashboard/src/pages/Login/index.test.tsx b/dashboard/src/pages/Login/index.test.tsx index 84c3f07c..8bb31906 100644 --- a/dashboard/src/pages/Login/index.test.tsx +++ b/dashboard/src/pages/Login/index.test.tsx @@ -33,6 +33,7 @@ function renderLogin() { } /> usable app} /> setup wizard} /> + conversation list} /> , ); @@ -69,7 +70,6 @@ describe("LoginPage local session", () => { expect(screen.queryByText("login form")).toBeNull(); expect(getAuthToken()).toBe("guest-token"); await waitFor(() => expect(localSession).toHaveBeenCalledOnce()); - expect(getAuthStatus).not.toHaveBeenCalled(); }); it("shows the form when a local session is not available", async () => { @@ -105,6 +105,7 @@ describe("LoginPage local session", () => { } /> usable app} /> model setup} /> + conversation list} /> , ); @@ -114,6 +115,43 @@ describe("LoginPage local session", () => { expect(getAuthToken()).toBe("guest-token"); }); + it("opens returning desktop users on the conversation list", async () => { + localSession.mockResolvedValue({ + access_token: "guest-token", + token_type: "Bearer", + expires_in: 3600, + user: { + id: 1, + username: "local", + role: "admin", + display_name: "FreeOS", + locale: "zh", + is_local: true, + }, + token: "guest-token", + }); + getAuthStatus.mockResolvedValue({ + setup_required: false, + has_providers: true, + desktop: true, + }); + + render( + + + } /> + usable app} /> + model setup} /> + conversation list} /> + + , + ); + + expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(screen.queryByText("login form")).toBeNull(); + expect(screen.queryByText("model setup")).toBeNull(); + }); + it("does not render the login form inside the desktop shell", async () => { localSession.mockRejectedValue(new Error("interactive login required")); getAuthStatus.mockResolvedValue({ setup_required: false }); @@ -125,6 +163,7 @@ describe("LoginPage local session", () => { } /> usable app} /> setup wizard} /> + conversation list} /> , ); diff --git a/dashboard/src/pages/Login/index.tsx b/dashboard/src/pages/Login/index.tsx index f863050f..af31e9eb 100644 --- a/dashboard/src/pages/Login/index.tsx +++ b/dashboard/src/pages/Login/index.tsx @@ -46,12 +46,23 @@ export default function LoginPage() { setAuthToken(session.access_token); await applyUserLocale(session.user.locale); if (!cancelled) { - navigate(desktop ? desktopPostSessionPath() : "/chat", { - replace: true, - }); + let hasProviders = false; + let desktopFlow = desktop; + try { + const status = await authApi.getAuthStatus(); + hasProviders = status?.has_providers === true; + desktopFlow = desktopFlow || status?.desktop === true; + } catch { + /* first-run still goes to model setup when the probe fails */ + } + navigate( + desktopFlow ? desktopPostSessionPath(hasProviders) : "/chat", + { replace: true }, + ); } return; } catch { + if (cancelled) return; if (attempt < attempts - 1) { await new Promise((resolve) => { window.setTimeout(resolve, delayMs); @@ -66,10 +77,18 @@ export default function LoginPage() { setAuthToken(session.access_token); await applyUserLocale(session.user.locale); if (!cancelled) { - navigate(desktopPostSessionPath(), { replace: true }); + let hasProviders = false; + try { + const status = await authApi.getAuthStatus(); + hasProviders = status?.has_providers === true; + } catch { + /* stay on first-run setup */ + } + navigate(desktopPostSessionPath(hasProviders), { replace: true }); } return; } catch { + if (cancelled) return; await new Promise((resolve) => { window.setTimeout(resolve, 400); }); diff --git a/dashboard/src/pages/Setup/index.test.tsx b/dashboard/src/pages/Setup/index.test.tsx index 4cf90ba9..1468cbd4 100644 --- a/dashboard/src/pages/Setup/index.test.tsx +++ b/dashboard/src/pages/Setup/index.test.tsx @@ -78,7 +78,9 @@ function renderSetup() { } /> + first chat} /> workspace} /> + conversation list} /> login wall} /> , @@ -94,7 +96,7 @@ describe("SetupPage desktop first-run", () => { isDesktopShell.mockReset(); }); - it("shows only the model step and can skip into the workspace", async () => { + it("shows only the model step and can skip into the first chat", async () => { isDesktopShell.mockReturnValue(true); getAuthStatus.mockResolvedValue({ setup_required: true, @@ -115,14 +117,30 @@ describe("SetupPage desktop first-run", () => { expect(screen.queryByText("admin step")).toBeNull(); expect(screen.getByText("no back")).toBeInTheDocument(); expect( - screen.getByRole("button", { name: "wizard.model.skipToWorkspace" }), + screen.getByRole("button", { name: "wizard.model.skipToChat" }), ).toBeInTheDocument(); const user = userEvent.setup(); await user.click( - screen.getByRole("button", { name: "wizard.model.skipToWorkspace" }), + screen.getByRole("button", { name: "wizard.model.skipToChat" }), ); - expect(await screen.findByText("workspace")).toBeInTheDocument(); + expect(await screen.findByText("first chat")).toBeInTheDocument(); + expect(isDesktopModelOnboardingDone()).toBe(true); + }); + + it("does not loop returning desktop users who already have a provider", async () => { + isDesktopShell.mockReturnValue(true); + getAuthStatus.mockResolvedValue({ + setup_required: false, + wizard_password_required: false, + desktop: true, + has_providers: true, + }); + + renderSetup(); + + expect(await screen.findByText("conversation list")).toBeInTheDocument(); + expect(screen.queryByText("model step")).toBeNull(); expect(isDesktopModelOnboardingDone()).toBe(true); }); diff --git a/dashboard/src/pages/Setup/index.tsx b/dashboard/src/pages/Setup/index.tsx index bd28ee10..e9b2d4ad 100644 --- a/dashboard/src/pages/Setup/index.tsx +++ b/dashboard/src/pages/Setup/index.tsx @@ -12,8 +12,9 @@ import { setAuthToken } from "../../api/request"; import { preferencesApi } from "../../api/modules/preferences"; import BrandMark from "../../components/BrandMark"; import { + desktopAfterModelSetupPath, desktopPostSessionPath, - markDesktopModelOnboardingDone, + needsDesktopModelOnboarding, } from "../../utils/desktopOnboarding"; import { isDesktopShell } from "../../utils/desktopShell"; import DatabaseStep from "./steps/DatabaseStep"; @@ -72,9 +73,8 @@ export default function SetupPage() { }, []); const enterWorkspace = useCallback(() => { - markDesktopModelOnboardingDone(); wizardSession.clearAll(); - navigate("/chat", { replace: true }); + navigate(desktopAfterModelSetupPath(), { replace: true }); }, [navigate]); useEffect(() => { @@ -86,11 +86,11 @@ export default function SetupPage() { const desktop = isDesktopShell() || status.desktop === true; if (desktop) { - if ( - desktopPostSessionPath(status.has_providers === true) === "/chat" - ) { + if (!needsDesktopModelOnboarding(status.has_providers === true)) { wizardSession.clearAll(); - navigate("/chat", { replace: true }); + navigate(desktopPostSessionPath(status.has_providers === true), { + replace: true, + }); return; } try { @@ -290,7 +290,9 @@ export default function SetupPage() {
{" "} - {desktopFlow ? t("wizard.desktopTitle") : t("wizard.title")} + {desktopFlow + ? t("wizard.desktopSubtitle") + : t("wizard.title")}
@@ -324,7 +326,10 @@ export default function SetupPage() { {desktopFlow ? ( undefined} onSkip={enterWorkspace} onContinue={(draft) => { diff --git a/dashboard/src/pages/Setup/setup.module.less b/dashboard/src/pages/Setup/setup.module.less index 0a9659ca..a62012dd 100644 --- a/dashboard/src/pages/Setup/setup.module.less +++ b/dashboard/src/pages/Setup/setup.module.less @@ -172,6 +172,14 @@ padding: 24px 28px 0; } +.localDetectHint, +.nextHint { + display: block; + font-size: 12px; + line-height: 1.5; + margin-top: 6px; +} + .modelStepMode { flex-shrink: 0; padding: 16px 28px 0; diff --git a/dashboard/src/pages/Setup/steps/ModelStep.tsx b/dashboard/src/pages/Setup/steps/ModelStep.tsx index 04e780e4..e40633d1 100644 --- a/dashboard/src/pages/Setup/steps/ModelStep.tsx +++ b/dashboard/src/pages/Setup/steps/ModelStep.tsx @@ -20,6 +20,10 @@ import { message } from "@/utils/antdMessage"; import { Plus, Trash2, Zap } from "lucide-react"; import { useTranslation } from "react-i18next"; import { request } from "../../../api/request"; +import { + localModelsApi, + type LocalProbe, +} from "../../../api/modules/localModels"; import { wizardApi, wizardSession, @@ -102,6 +106,9 @@ interface Props { onContinue: (draft: ProviderDraft) => void; hideBack?: boolean; skipLabel?: string; + continueLabel?: string; + intro?: string; + detectLocal?: boolean; } type SetupMode = "preset" | "custom"; @@ -128,6 +135,9 @@ export default function ModelStep({ onContinue, hideBack = false, skipLabel, + continueLabel, + intro, + detectLocal = false, }: Props) { const { t } = useTranslation(); const [presetForm] = Form.useForm(); @@ -138,6 +148,7 @@ export default function ModelStep({ const [mode, setMode] = useState("preset"); const [selectedPresetId, setSelectedPresetId] = useState(""); const [showAllPresets, setShowAllPresets] = useState(false); + const [localProbe, setLocalProbe] = useState(null); const [customModels, setCustomModels] = useState([]); const [addingCustomModel, setAddingCustomModel] = useState(false); const [addingPresetModel, setAddingPresetModel] = useState(false); @@ -195,6 +206,22 @@ export default function ModelStep({ }; }, [presetForm]); + useEffect(() => { + if (!detectLocal) return; + let cancelled = false; + void localModelsApi + .probe() + .then((probe) => { + if (!cancelled) setLocalProbe(probe); + }) + .catch(() => { + /* probe is best-effort; cloud key + skip still work */ + }); + return () => { + cancelled = true; + }; + }, [detectLocal]); + useEffect(() => { if (!loadingPresets && presets.length === 0) { setMode("custom"); @@ -237,6 +264,42 @@ export default function ModelStep({ const canContinueWithoutTest = (mode === "preset" && isOllama) || (mode === "custom" && customIsLocal); + const localDetectHint = (() => { + if (!detectLocal || !localProbe) return null; + const hardware = localProbe.hardware; + if (hardware.ollama_reachable) return t("wizard.model.ollamaDetected"); + if (hardware.ollama_installed || hardware.ollama_binary) { + return t("wizard.model.ollamaInstalled"); + } + return t("wizard.model.ollamaMissing"); + })(); + + const renderStepIntro = (fallback: string) => ( + <> + + {intro ?? fallback} + + {localDetectHint ? ( + + {localDetectHint} + + ) : null} + {detectLocal ? ( + + {t("wizard.model.nextHint")} + + ) : null} + {canContinueWithoutTest && ( + + {t("wizard.model.localOptionalTest")} + + )} + + ); + const applyPreset = (p: ProviderPreset) => { resetTest(); setSelectedPresetId(p.id); @@ -573,9 +636,10 @@ export default function ModelStep({ disabled={!testPassed && !canContinueWithoutTest} onClick={() => void handleContinue()} > - {testPassed || !canContinueWithoutTest - ? t("wizard.model.continue") - : t("wizard.model.continueLocal")} + {continueLabel ?? + (testPassed || !canContinueWithoutTest + ? t("wizard.model.continue") + : t("wizard.model.continueLocal"))} @@ -1231,6 +1295,11 @@ export default function ModelStep({ {t("models.noProvidersHint")} + {detectLocal ? ( + + {t("wizard.model.nextHint")} + + ) : null}
@@ -1266,17 +1335,7 @@ export default function ModelStep({ > {t("wizard.stepModel")}
- - {t("wizard.model.intro")} - - {canContinueWithoutTest && ( - - {t("wizard.model.localOptionalTest")} - - )} + {renderStepIntro(t("wizard.model.intro"))}
diff --git a/dashboard/src/utils/desktopOnboarding.test.ts b/dashboard/src/utils/desktopOnboarding.test.ts index bea51d8e..cfcfdc24 100644 --- a/dashboard/src/utils/desktopOnboarding.test.ts +++ b/dashboard/src/utils/desktopOnboarding.test.ts @@ -1,8 +1,13 @@ import { afterEach, describe, expect, it } from "vitest"; import { + DESKTOP_FIRST_CHAT_PATH, + DESKTOP_MODEL_SETUP_PATH, + DESKTOP_RETURNING_HOME_PATH, + desktopAfterModelSetupPath, desktopPostSessionPath, isDesktopModelOnboardingDone, markDesktopModelOnboardingDone, + needsDesktopModelOnboarding, } from "./desktopOnboarding"; describe("desktopOnboarding", () => { @@ -12,14 +17,23 @@ describe("desktopOnboarding", () => { it("starts unfinished and remembers skip or save", () => { expect(isDesktopModelOnboardingDone()).toBe(false); - expect(desktopPostSessionPath()).toBe("/setup"); + expect(needsDesktopModelOnboarding()).toBe(true); + expect(desktopPostSessionPath()).toBe(DESKTOP_MODEL_SETUP_PATH); markDesktopModelOnboardingDone(); expect(isDesktopModelOnboardingDone()).toBe(true); - expect(desktopPostSessionPath()).toBe("/projects"); + expect(needsDesktopModelOnboarding()).toBe(false); + expect(desktopPostSessionPath()).toBe(DESKTOP_RETURNING_HOME_PATH); }); it("treats an existing provider as already finished", () => { - expect(desktopPostSessionPath(true)).toBe("/projects"); + expect(needsDesktopModelOnboarding(true)).toBe(false); + expect(desktopPostSessionPath(true)).toBe(DESKTOP_RETURNING_HOME_PATH); expect(isDesktopModelOnboardingDone()).toBe(true); }); + + it("opens the default first-agent chat after skip or save", () => { + expect(desktopAfterModelSetupPath()).toBe(DESKTOP_FIRST_CHAT_PATH); + expect(isDesktopModelOnboardingDone()).toBe(true); + expect(desktopPostSessionPath()).toBe(DESKTOP_RETURNING_HOME_PATH); + }); }); diff --git a/dashboard/src/utils/desktopOnboarding.ts b/dashboard/src/utils/desktopOnboarding.ts index af1cbbe4..37d44bc6 100644 --- a/dashboard/src/utils/desktopOnboarding.ts +++ b/dashboard/src/utils/desktopOnboarding.ts @@ -1,5 +1,16 @@ const ONBOARDING_KEY = "freeos:model-onboarding-done"; +/** Pinned default agent created for the desktop / loopback guest session. */ +export const DESKTOP_FIRST_AGENT_ID = "main"; + +/** Canvas for the first-run assistant — not the shared conversation list. */ +export const DESKTOP_FIRST_CHAT_PATH = `/chat/${DESKTOP_FIRST_AGENT_ID}`; + +/** Returning desktop users land on the shared workspace conversation list. */ +export const DESKTOP_RETURNING_HOME_PATH = "/projects"; + +export const DESKTOP_MODEL_SETUP_PATH = "/setup"; + /** True after the desktop first-run model step was saved or skipped. */ export function isDesktopModelOnboardingDone(): boolean { if (typeof window === "undefined") return false; @@ -20,11 +31,25 @@ export function markDesktopModelOnboardingDone(): void { } } -/** Path after a desktop local session: model setup once, then the shared conversation list. */ +/** First launch with no providers and no skip/save yet. */ +export function needsDesktopModelOnboarding(hasProviders = false): boolean { + return !hasProviders && !isDesktopModelOnboardingDone(); +} + +/** + * Path after a desktop local session is adopted. + * First launch → model setup; later launches → conversation list (not a loop). + */ export function desktopPostSessionPath(hasProviders = false): string { - if (hasProviders || isDesktopModelOnboardingDone()) { - if (hasProviders) markDesktopModelOnboardingDone(); - return "/projects"; + if (needsDesktopModelOnboarding(hasProviders)) { + return DESKTOP_MODEL_SETUP_PATH; } - return "/setup"; + if (hasProviders) markDesktopModelOnboardingDone(); + return DESKTOP_RETURNING_HOME_PATH; +} + +/** After skip or a successful model save: chat with the default first agent. */ +export function desktopAfterModelSetupPath(): string { + markDesktopModelOnboardingDone(); + return DESKTOP_FIRST_CHAT_PATH; }