From 307b2d3670a5c61f7e3eb28012965ea7f452cc43 Mon Sep 17 00:00:00 2001 From: coso Date: Wed, 25 Mar 2026 01:39:23 +0800 Subject: [PATCH] feat: finalize oem cloud access integration --- RELEASE_NOTES.md | 38 +- package.json | 2 +- public/oem-runtime-config.js | 32 + src-tauri/Cargo.toml | 4 +- src-tauri/tauri.conf.headless.json | 2 +- src-tauri/tauri.conf.json | 2 +- src/App.tsx | 4 + src/components/input-kit/ModelSelector.tsx | 17 +- .../user-center-session/index.test.tsx | 233 ++++ .../account/user-center-session/index.tsx | 550 +++++++++ .../agent/providers/index.test.tsx | 269 +++++ .../settings-v2/agent/providers/index.tsx | 681 +++++++++++ src/hooks/useApiKeyProvider.ts | 31 +- src/hooks/useDeepLink.ts | 47 +- src/hooks/useOemCloudAccess.test.tsx | 222 ++++ src/hooks/useOemCloudAccess.ts | 1036 +++++++++++++++++ src/hooks/useOemLimeHubProviderSync.test.tsx | 213 ++++ src/hooks/useOemLimeHubProviderSync.ts | 174 +++ src/hooks/useServiceSkillCatalogBootstrap.ts | 12 + src/lib/api/oemCloudControlPlane.test.ts | 241 ++++ src/lib/api/oemCloudControlPlane.ts | 1007 ++++++++++++++++ src/lib/api/oemCloudRuntime.test.ts | 98 ++ src/lib/api/oemCloudRuntime.ts | 365 ++++++ src/lib/api/serviceSkills.test.ts | 126 ++ src/lib/api/serviceSkills.ts | 223 +++- src/lib/oemCloudDesktopAuth.test.ts | 30 + src/lib/oemCloudDesktopAuth.ts | 203 ++++ src/lib/oemCloudSession.test.ts | 106 ++ src/lib/oemCloudSession.ts | 372 ++++++ src/lib/oemLimeHubProvider.test.ts | 55 + src/lib/oemLimeHubProvider.ts | 68 ++ src/lib/serviceSkillCatalogBootstrap.test.ts | 114 ++ src/lib/serviceSkillCatalogBootstrap.ts | 109 ++ 33 files changed, 6639 insertions(+), 47 deletions(-) create mode 100644 public/oem-runtime-config.js create mode 100644 src/components/settings-v2/account/user-center-session/index.test.tsx create mode 100644 src/components/settings-v2/account/user-center-session/index.tsx create mode 100644 src/components/settings-v2/agent/providers/index.test.tsx create mode 100644 src/components/settings-v2/agent/providers/index.tsx create mode 100644 src/hooks/useOemCloudAccess.test.tsx create mode 100644 src/hooks/useOemCloudAccess.ts create mode 100644 src/hooks/useOemLimeHubProviderSync.test.tsx create mode 100644 src/hooks/useOemLimeHubProviderSync.ts create mode 100644 src/hooks/useServiceSkillCatalogBootstrap.ts create mode 100644 src/lib/api/oemCloudControlPlane.test.ts create mode 100644 src/lib/api/oemCloudControlPlane.ts create mode 100644 src/lib/api/oemCloudRuntime.test.ts create mode 100644 src/lib/api/oemCloudRuntime.ts create mode 100644 src/lib/api/serviceSkills.test.ts create mode 100644 src/lib/oemCloudDesktopAuth.test.ts create mode 100644 src/lib/oemCloudDesktopAuth.ts create mode 100644 src/lib/oemCloudSession.test.ts create mode 100644 src/lib/oemCloudSession.ts create mode 100644 src/lib/oemLimeHubProvider.test.ts create mode 100644 src/lib/oemLimeHubProvider.ts create mode 100644 src/lib/serviceSkillCatalogBootstrap.test.ts create mode 100644 src/lib/serviceSkillCatalogBootstrap.ts diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 219bd29b0..f8c21591c 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,38 +1,38 @@ -## Lime v0.95.0 +## Lime v0.95.1 ### ✨ 主要更新 -- **Agent 线程可靠性面板上线**:会话线程新增 pending request、active incident、最近 outcome 与排队 turn 的统一读模型,前端可直接展示“卡在哪里、为什么卡、下一步怎么恢复” -- **恢复动作从诊断面板直达执行**:支持在线中断当前 turn、恢复线程、重放待处理请求、提升排队 turn,减少遇到等待确认、工具失败或队列阻塞时的手工排障成本 -- **Agent 工作区继续收口**:`AgentChatWorkspace` 大体量逻辑拆分到共享 contract、命令解析/执行层与多个面板组件,消息列表、决策面板、提及面板、队列面板与工作台布局的职责更清晰 -- **发布构建链路补齐稳定性修复**:补齐 Agent 工作区与线程可靠性面板的 TypeScript 合同边界,修正中断态诊断优先级,`npm run build` / `npm run lint` 与 Rust 测试链路恢复可用 -- **Codex slash command 基础接入**:新增 `/compact`、`/clear`、`/new`、`/review`、`/init`、`/diff`、`/status`、`/model`、`/help` 等命令目录与格式化/执行链路,为后续命令扩展提供统一入口 -- **项目热力图与治理图再生成能力补齐**:新增仓库热力图脚本、治理图谱脚本和操作文档,可直接生成热点模块与治理候选报告,方便后续收口 legacy/compat 路径 +- **云端账户闭环接入收口**:个人中心统一承接登录、会话刷新和退出,Providers 页改成只消费这份云端会话,不再额外维护独立登录表单 +- **Google 一键登录回流桌面端**:桌面 OAuth 回调支持拿到 session token 后立即补拉 bootstrap、写回本地会话,并把登录成功后的跳转路径带回客户端 +- **云端 Provider 最终态改为服务端驱动**:客户端不再猜测接入模式、配置模式、模型来源和开发者入口,而是直接消费 control-plane 返回的最终态字段 +- **模型目录与默认来源同步治理**:默认来源切到云端时,内部 compat `lime-hub` Provider 会自动同步网关地址、品牌名和服务端模型目录;切回本地时会清空云端模型目录,避免旧配置残留 +- **本地 / 第三方 Provider 面板重新分层**:设置页把“云端入口”和“本地 / 第三方开发者配置”拆开,云端消费态、套餐状态和模型目录单独展示,避免与 API Key 管理混在一起 +- **服务技能目录接入云端 bootstrap**:启动时与 OAuth 登录完成后都会同步服务技能目录,保障客户端在登录后即可直接拿到云端下发的技能目录 +- **OEM 运行时配置继续去硬编码**:默认桌面客户端标识改成中性 `desktop-client`,品牌、域名与登录入口继续由运行时配置文件注入,方便后续打包替换 ### ⚠️ 兼容性说明 -- Agent 线程可靠性依赖新的本地数据库表 `agent_turn_outcomes` 与 `agent_thread_incidents`;升级后首次打开相关会话会自动补齐表结构 -- 线程恢复与 pending request 诊断依赖新的 Aster 线程读模型;如果本地还在用 `setup:local-aster` 覆盖,请确认联调仓库已同步到 `aster-rust v0.21.0` -- release workflow 仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包 +- 现网包发布仍由 `v*` tag 触发,`RELEASE_NOTES.md` 会直接作为 GitHub Release 正文;只推 `main` 不会自动出包 +- 云端登录、模型目录和服务技能目录都依赖运行时注入的控制面 / Gateway 域名与租户配置;打包前请确认 `public/oem-runtime-config.js` 已替换为目标环境值 +- 云端默认来源与开发者 API Key 模式现在由服务端治理;如果后台关闭开发者入口,客户端仍会保留本地 / 第三方 Provider 管理,但不会再把云端入口误当作普通 API Key Provider 展示 ### 🔗 依赖同步 -- `aster-core` / `aster-models` 远程 tag 固定到 `v0.21.0` -- 应用版本同步提升到 `v0.95.0`,覆盖 `package.json`、Tauri 配置与 Rust workspace/package 入口 +- 应用版本同步提升到 `v0.95.1`,覆盖 `package.json`、Tauri 配置与 Rust workspace/package 入口 +- 云端运行时默认客户端标识调整为 `desktop-client`,供不同品牌包在打包阶段覆写 ### 🧪 测试 -- 发布前执行:`npm run build` -- 发布前执行:`npm run lint` -- 发布前执行:`cargo fmt --all --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`cargo test --manifest-path src-tauri/Cargo.toml` -- 发布前执行:`cargo clippy --manifest-path src-tauri/Cargo.toml` +- 发布前执行:`npm run verify:app-version` +- 发布前执行:`npm test -- src/hooks/useOemLimeHubProviderSync.test.tsx src/lib/oemCloudSession.test.ts src/hooks/useOemCloudAccess.test.tsx src/components/settings-v2/agent/providers/index.test.tsx src/components/settings-v2/account/user-center-session/index.test.tsx src/lib/api/oemCloudControlPlane.test.ts src/lib/api/oemCloudRuntime.test.ts` +- 发布前执行:`npm run governance:legacy-report` +- 发布前执行:`npm run test:contracts` ### 📝 文档 -- 补充 `docs/aiprompts/project-heatmap.md`,明确仓库热力图、治理图与 legacy-report 的配套使用方式 - 发布说明随 `RELEASE_NOTES.md` 更新,供 GitHub Release 工作流直接读取 +- 运行时配置示例随 `public/oem-runtime-config.js` 一并更新,避免桌面 OAuth 客户端标识继续写死旧品牌值 --- -**完整变更**: v0.94.0...v0.95.0 +**完整变更**: v0.95.0...v0.95.1 diff --git a/package.json b/package.json index 223f75b97..cd6205682 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "lime", "private": true, - "version": "0.95.0", + "version": "0.95.1", "type": "module", "engines": { "node": ">=22.0.0" diff --git a/public/oem-runtime-config.js b/public/oem-runtime-config.js new file mode 100644 index 000000000..5984ebf1f --- /dev/null +++ b/public/oem-runtime-config.js @@ -0,0 +1,32 @@ +window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + gatewayBaseUrl: "https://gateway-api.150404.xyz", + hubProviderName: "Lime Hub", + tenantId: "tenant-0001", + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + ...(window.__LIME_OEM_CLOUD__ ?? {}), +}; + +/* +Replace this file during packaging when you need brand-specific runtime values. + +Example: + +window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://limehub.example.com", + gatewayBaseUrl: "https://limehub.example.com/gateway-api", + hubProviderName: "Lime Hub", + tenantId: "tenant-demo", + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", +}; + +window.__LIME_SESSION_TOKEN__ = "session-token-from-login"; +*/ diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 537f136be..6629ad6d8 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -3,7 +3,7 @@ members = ["crates/*"] resolver = "2" [workspace.package] -version = "0.95.0" +version = "0.95.1" edition = "2021" authors = ["coso"] repository = "https://github.com/aiclientproxy/lime" @@ -192,7 +192,7 @@ version = "2.4" [package] name = "lime" -version = "0.95.0" +version = "0.95.1" description = "AI API Proxy Desktop App" authors = ["you"] edition = "2021" diff --git a/src-tauri/tauri.conf.headless.json b/src-tauri/tauri.conf.headless.json index b9c20e409..24fe85e76 100644 --- a/src-tauri/tauri.conf.headless.json +++ b/src-tauri/tauri.conf.headless.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.95.0", + "version": "0.95.1", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev:web-bridge", diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 64e6cecf8..555eb5098 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Lime", - "version": "0.95.0", + "version": "0.95.1", "identifier": "com.lime.app", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/App.tsx b/src/App.tsx index 4e10d1c55..f64398a41 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -25,7 +25,9 @@ import { useOnboardingState } from "./components/onboarding"; import { showRegistryLoadError } from "./lib/utils/connectError"; import { useDeepLink } from "./hooks/useDeepLink"; import { useRelayRegistry } from "./hooks/useRelayRegistry"; +import { useServiceSkillCatalogBootstrap } from "./hooks/useServiceSkillCatalogBootstrap"; import { useGlobalTrayModelSync } from "./hooks/useGlobalTrayModelSync"; +import { useOemLimeHubProviderSync } from "./hooks/useOemLimeHubProviderSync"; import { ComponentDebugProvider } from "./contexts/ComponentDebugContext"; import { SoundProvider } from "./contexts/SoundProvider"; import { ComponentDebugOverlay } from "./components/dev"; @@ -243,6 +245,8 @@ function AppContent() { projectName: string; } | null>(null); + useServiceSkillCatalogBootstrap(); + useOemLimeHubProviderSync(); useGlobalTrayModelSync({ currentPage, pageParams, diff --git a/src/components/input-kit/ModelSelector.tsx b/src/components/input-kit/ModelSelector.tsx index 9472d3c8a..39ef186aa 100644 --- a/src/components/input-kit/ModelSelector.tsx +++ b/src/components/input-kit/ModelSelector.tsx @@ -22,6 +22,8 @@ import { filterModelsByTheme } from "@/components/agent/chat/utils/modelThemePol import { getProviderModelCompatibilityIssue } from "@/components/agent/chat/utils/providerModelCompatibility"; import { getProviderLabel } from "@/lib/constants/providerMappings"; import { ModelCapabilityBadges } from "@/components/model/ModelCapabilityBadges"; +import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; +import { resolveOemLimeHubProviderName } from "@/lib/oemLimeHubProvider"; const compactTriggerClassName = "h-8 min-w-[104px] max-w-[168px] justify-start gap-1.5 rounded-full border-slate-200/80 bg-white/92 px-2.5 text-slate-600 shadow-none transition-colors hover:border-slate-300 hover:bg-white hover:text-slate-800"; @@ -244,14 +246,19 @@ export const ModelSelector: React.FC = ({ setOpen(false); }, [disabled, open]); - const selectedProviderLabel = - selectedProvider?.label || getProviderLabel(providerType || "lime-hub"); + const defaultHubProviderLabel = resolveOemLimeHubProviderName( + resolveOemCloudRuntimeContext(), + ); const compactProviderType = selectedProvider?.key || providerType || "lime-hub"; + const fallbackProviderLabel = + compactProviderType.toLowerCase() === "lime-hub" + ? defaultHubProviderLabel + : getProviderLabel(compactProviderType); + const selectedProviderLabel = + selectedProvider?.label || fallbackProviderLabel; const compactProviderLabel = - selectedProvider?.label || - getProviderLabel(providerType || "lime-hub") || - "Lime Hub"; + selectedProvider?.label || fallbackProviderLabel; const compactModelLabel = model || "切换模型"; const normalizedTheme = (activeTheme || "").toLowerCase(); const activeThemeLabel = diff --git a/src/components/settings-v2/account/user-center-session/index.test.tsx b/src/components/settings-v2/account/user-center-session/index.test.tsx new file mode 100644 index 000000000..956e5e182 --- /dev/null +++ b/src/components/settings-v2/account/user-center-session/index.test.tsx @@ -0,0 +1,233 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseOemCloudAccess, mockFormatOemCloudDateTime } = vi.hoisted(() => ({ + mockUseOemCloudAccess: vi.fn(), + mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), +})); + +vi.mock("@/hooks/useOemCloudAccess", () => ({ + useOemCloudAccess: () => mockUseOemCloudAccess(), + formatOemCloudDateTime: (value?: string) => + mockFormatOemCloudDateTime(value), +})); + +import { UserCenterSessionSettings } from "."; + +interface MountedPage { + container: HTMLDivElement; + root: Root; +} + +const mounted: MountedPage[] = []; + +function createAccessState(overrides: Record = {}) { + return { + runtime: { + baseUrl: "https://user.150404.xyz", + controlPlaneBaseUrl: "https://user.150404.xyz/api", + sceneBaseUrl: "https://user.150404.xyz/scene-api", + gatewayBaseUrl: "https://user.150404.xyz/gateway-api", + tenantId: "tenant-0001", + sessionToken: null, + hubProviderName: "Lime Hub", + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }, + configuredTarget: { + baseUrl: "https://user.150404.xyz", + tenantId: "tenant-0001", + }, + hubProviderName: "Lime Hub", + loginMode: "password", + setLoginMode: vi.fn(), + passwordForm: { + identifier: "", + password: "", + }, + setPasswordForm: vi.fn(), + emailCodeForm: { + identifier: "", + code: "", + displayName: "", + username: "", + }, + setEmailCodeForm: vi.fn(), + codeDelivery: null, + session: null, + bootstrap: null, + initializing: false, + refreshing: false, + sendingCode: false, + loggingIn: false, + loggingOut: false, + openingGoogleLogin: false, + errorMessage: null, + infoMessage: null, + defaultProviderSummary: null, + handleRefresh: vi.fn(), + handleSendEmailCode: vi.fn(), + handleEmailCodeLogin: vi.fn(), + handlePasswordLogin: vi.fn(), + handleGoogleLogin: vi.fn(), + handleLogout: vi.fn(), + openUserCenter: vi.fn(), + ...overrides, + }; +} + +function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const page = { container, root }; + mounted.push(page); + return page; +} + +function findButton(container: HTMLElement, text: string) { + const button = Array.from(container.querySelectorAll("button")).find((item) => + item.textContent?.includes(text), + ); + + if (!button) { + throw new Error(`未找到按钮: ${text}`); + } + return button as HTMLButtonElement; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseOemCloudAccess.mockReturnValue(createAccessState()); +}); + +afterEach(() => { + vi.clearAllMocks(); + + while (mounted.length > 0) { + const current = mounted.pop(); + if (!current) { + break; + } + + act(() => { + current.root.unmount(); + }); + current.container.remove(); + } +}); + +describe("UserCenterSessionSettings", () => { + it("未登录时应展示个人中心登录面板", () => { + const { container } = renderPage(); + const text = container.textContent ?? ""; + + expect(text).toContain("个人中心会话"); + expect(text).toContain("使用 Google 一键登录"); + expect(text).toContain("登录后自动完成"); + }); + + it("点击 Google 一键登录时应调用 hook 的 handleGoogleLogin", async () => { + const handleGoogleLogin = vi.fn(); + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + handleGoogleLogin, + }), + ); + + const { container } = renderPage(); + + await act(async () => { + findButton(container, "使用 Google 一键登录").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(handleGoogleLogin).toHaveBeenCalledTimes(1); + }); + + it("展开备用登录方式后点击验证码模式切换时应调用 hook 的 setLoginMode", async () => { + const setLoginMode = vi.fn(); + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + setLoginMode, + }), + ); + + const { container } = renderPage(); + + await act(async () => { + findButton(container, "使用邮箱验证码 / 账号密码").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + await act(async () => { + findButton(container, "邮箱验证码").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(setLoginMode).toHaveBeenCalledWith("email_code"); + }); + + it("已登录时应展示会话摘要并允许退出", async () => { + const handleLogout = vi.fn(); + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + session: { + tenant: { id: "tenant-0001" }, + user: { + id: "user-001", + email: "operator@example.com", + displayName: "Demo Operator", + }, + session: { + id: "session-001", + expiresAt: "2026-03-25T08:00:00.000Z", + }, + }, + bootstrap: { + serviceSkillCatalog: { + items: [{ id: "skill-001" }, { id: "skill-002" }], + }, + sceneCatalog: [{ id: "scene-001" }], + gateway: { + basePath: "/gateway-api", + }, + }, + defaultProviderSummary: "Lime Hub 主服务 · gpt-5.2-pro", + handleLogout, + }), + ); + + const { container } = renderPage(); + const text = container.textContent ?? ""; + + expect(text).toContain("Demo Operator"); + expect(text).toContain("fmt:2026-03-25T08:00:00.000Z"); + expect(text).toContain("2 项"); + expect(text).toContain("Lime Hub 主服务 · gpt-5.2-pro"); + + await act(async () => { + findButton(container, "退出登录").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(handleLogout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings-v2/account/user-center-session/index.tsx b/src/components/settings-v2/account/user-center-session/index.tsx new file mode 100644 index 000000000..b30a01ac5 --- /dev/null +++ b/src/components/settings-v2/account/user-center-session/index.tsx @@ -0,0 +1,550 @@ +import { + AlertCircle, + ChevronDown, + ChevronUp, + CheckCircle2, + Cloud, + ExternalLink, + LoaderCircle, + LogIn, + LogOut, + RefreshCw, +} from "lucide-react"; +import { useState } from "react"; +import { ProviderIcon } from "@/icons/providers"; +import { + formatOemCloudDateTime, + useOemCloudAccess, +} from "@/hooks/useOemCloudAccess"; +import { cn } from "@/lib/utils"; + +const SURFACE_CLASS_NAME = + "rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; + +function SessionValueCard(props: { + label: string; + value: string; + hint: string; +}) { + return ( +
+

+ {props.label} +

+

+ {props.value} +

+

{props.hint}

+
+ ); +} + +function NoticeBar(props: { + tone: "error" | "success"; + message: string; +}) { + return ( +
+ {props.tone === "success" ? ( + + ) : ( + + )} + {props.message} +
+ ); +} + +function resolveServiceSkillCount(payload: unknown): number { + if (!payload || typeof payload !== "object") { + return 0; + } + + const items = (payload as { items?: unknown[] }).items; + return Array.isArray(items) ? items.length : 0; +} + +export function UserCenterSessionSettings() { + const [showAlternativeMethods, setShowAlternativeMethods] = useState(false); + const { + runtime, + configuredTarget, + hubProviderName, + loginMode, + setLoginMode, + passwordForm, + setPasswordForm, + emailCodeForm, + setEmailCodeForm, + codeDelivery, + session, + bootstrap, + initializing, + refreshing, + sendingCode, + loggingIn, + loggingOut, + openingGoogleLogin, + errorMessage, + infoMessage, + defaultProviderSummary, + handleRefresh, + handleSendEmailCode, + handleEmailCodeLogin, + handlePasswordLogin, + handleGoogleLogin, + handleLogout, + openUserCenter, + } = useOemCloudAccess(); + + return ( +
+
+
+
+ +
+
+
+ + USER CENTER + +
+

+ 个人中心会话 +

+

+ 用户登录、退出和云端会话同步统一放在这里。登录成功后,Providers 页里的 {hubProviderName} 云服务入口会直接复用这份会话。 +

+
+
+ +
+ + + +
+
+ + {errorMessage ? ( + + ) : null} + {infoMessage ? ( + + ) : null} + + {!runtime ? ( +
+ 当前没有可用的运行时配置。请先在 + + public/oem-runtime-config.js + + 中配置域名、网关地址和租户信息,然后再进行登录。 +
+ ) : initializing ? ( +
+
+ + 正在恢复个人中心会话... +
+
+ ) : session ? ( +
+
+
+
+
+
+ +
+
+

+ {session.user.displayName || session.user.email || "已登录"} +

+

+ {session.user.email || session.user.username || session.user.id} +

+
+
+ +
+ + + + + +
+
+ +
+ + + +
+
+
+ +
+
+

+ 会话说明 +

+

+ Providers 页里的 {hubProviderName} 云服务入口不再单独维护登录表单,而是直接读取这里的会话。你在这里刷新、退出之后,上游云端目录和下游面板都会一起同步。 +

+ {codeDelivery ? ( +

+ 最近一次验证码已发送到 {codeDelivery.maskedEmail}。 +

+ ) : null} +
+
+
+ ) : ( +
+
+
+
+

+ 使用 Google 一键登录 +

+

+ Google 是默认登录方式。授权完成后桌面端会自动完成登录,并同步默认云端来源、服务技能目录与 Scene 入口。 +

+
+ + + +
+ + +
+
+ +
+
+

+ 登录后自动完成 +

+
    +
  • 同步当前租户下的默认云端来源与模型目录。
  • +
  • 把个人中心会话复用到 Providers 页,不再重复登录。
  • +
  • 同步服务技能目录与 Scene 入口,形成完整闭环。
  • +
+
+
+
+ + {showAlternativeMethods ? ( +
+
+
+

+ 备用登录方式 +

+

+ 如果当前租户没有启用 Google,或需要兼容已有账号体系,可以改用邮箱验证码或账号密码登录。 +

+
+ +
+ + +
+
+ + {loginMode === "password" ? ( +
+
+ + + setPasswordForm((current) => ({ + ...current, + identifier: event.target.value, + })) + } + placeholder="例如:operator@example.com" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + data-testid="oem-cloud-password-identifier" + /> +
+ +
+ + + setPasswordForm((current) => ({ + ...current, + password: event.target.value, + })) + } + placeholder="输入用户中心密码" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + data-testid="oem-cloud-password-secret" + /> +
+ + +
+ ) : ( +
+
+ + + setEmailCodeForm((current) => ({ + ...current, + identifier: event.target.value, + })) + } + placeholder="例如:operator@example.com" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + data-testid="oem-cloud-code-identifier" + /> +
+ +
+
+ + + setEmailCodeForm((current) => ({ + ...current, + code: event.target.value, + })) + } + placeholder="输入 6 位验证码" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + data-testid="oem-cloud-code-value" + /> +
+ +
+ +
+
+ + + setEmailCodeForm((current) => ({ + ...current, + displayName: event.target.value, + })) + } + placeholder="选填" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + /> +
+ +
+ + + setEmailCodeForm((current) => ({ + ...current, + username: event.target.value, + })) + } + placeholder="选填" + className="w-full rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition placeholder:text-slate-400 focus:border-slate-300" + /> +
+
+ + +
+ )} +
+ ) : null} +
+ )} +
+
+
+ ); +} + +export default UserCenterSessionSettings; diff --git a/src/components/settings-v2/agent/providers/index.test.tsx b/src/components/settings-v2/agent/providers/index.test.tsx new file mode 100644 index 000000000..e27f256b4 --- /dev/null +++ b/src/components/settings-v2/agent/providers/index.test.tsx @@ -0,0 +1,269 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const { mockUseOemCloudAccess, mockFormatOemCloudDateTime } = vi.hoisted(() => ({ + mockUseOemCloudAccess: vi.fn(), + mockFormatOemCloudDateTime: vi.fn((value?: string) => `fmt:${value ?? ""}`), +})); + +vi.mock("@/components/provider-pool", () => ({ + ProviderPoolPage: () =>
凭证池占位
, +})); + +vi.mock("@/hooks/useOemCloudAccess", () => ({ + useOemCloudAccess: () => mockUseOemCloudAccess(), + formatOemCloudDateTime: (value?: string) => + mockFormatOemCloudDateTime(value), + formatOemCloudAccessModeLabel: (value?: string) => value || "未知", + formatOemCloudConfigModeLabel: (value?: string) => value || "未知", + formatOemCloudModelsSourceLabel: (value?: string) => value || "未知", + formatOemCloudOfferStateLabel: (value?: string) => value || "未知", +})); + +import { CloudProviderSettings } from "."; + +interface MountedPage { + container: HTMLDivElement; + root: Root; +} + +const mounted: MountedPage[] = []; + +function createOffer(overrides: Record = {}) { + return { + providerKey: "lime-hub-main", + displayName: "Lime Hub 主服务", + source: "oem_cloud", + state: "available_ready", + description: "统一下发的云端目录", + visible: true, + loggedIn: true, + accountStatus: "logged_in", + subscriptionStatus: "active", + quotaStatus: "ok", + canInvoke: true, + defaultModel: "gpt-5.2-pro", + effectiveAccessMode: "session", + apiKeyModeEnabled: false, + tenantOverrideApplied: false, + configMode: "managed", + modelsSource: "hub_catalog", + developerAccessVisible: false, + availableModelCount: 2, + fallbackToLocalAllowed: true, + currentPlan: "Pro", + creditsSummary: "余额充足", + tags: [], + ...overrides, + }; +} + +function createAccessState(overrides: Record = {}) { + const defaultOffer = createOffer(); + + return { + runtime: { + baseUrl: "https://user.150404.xyz", + controlPlaneBaseUrl: "https://user.150404.xyz/api", + sceneBaseUrl: "https://user.150404.xyz/scene-api", + gatewayBaseUrl: "https://user.150404.xyz/gateway-api", + tenantId: "tenant-0001", + sessionToken: null, + hubProviderName: "Lime Hub", + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }, + configuredTarget: { + baseUrl: "https://user.150404.xyz", + tenantId: "tenant-0001", + }, + hubProviderName: "Lime Hub", + session: null, + offers: [], + preference: null, + selectedOffer: null, + selectedModels: [], + defaultCloudOffer: null, + activeCloudOffer: null, + initializing: false, + refreshing: false, + loadingDetail: false, + savingDefault: "", + errorMessage: null, + infoMessage: null, + defaultProviderSummary: null, + defaultProviderSourceLabel: "未设定", + activeAccessModeLabel: "登录会话", + activeConfigModeLabel: "托管模式", + activeModelsSourceLabel: "云端目录", + activeDeveloperAccessEnabled: false, + activeDeveloperAccessLabel: "已关闭", + handleRefresh: vi.fn(), + openOfferDetail: vi.fn(), + handleSetDefault: vi.fn(), + openUserCenter: vi.fn(), + ...overrides, + }; +} + +function renderPage(props: { onOpenProfile?: () => void } = {}) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render(); + }); + + const page = { container, root }; + mounted.push(page); + return page; +} + +function findButton(container: HTMLElement, text: string) { + const button = Array.from(container.querySelectorAll("button")).find((item) => + item.textContent?.includes(text), + ); + + if (!button) { + throw new Error(`未找到按钮: ${text}`); + } + return button as HTMLButtonElement; +} + +beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + mockUseOemCloudAccess.mockReturnValue(createAccessState()); +}); + +afterEach(() => { + vi.clearAllMocks(); + + while (mounted.length > 0) { + const current = mounted.pop(); + if (!current) { + break; + } + + act(() => { + current.root.unmount(); + }); + current.container.remove(); + } +}); + +describe("CloudProviderSettings", () => { + it("未配置运行时信息时应展示配置提示并保留本地 Provider 面板", () => { + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + runtime: null, + configuredTarget: null, + }), + ); + + const { container } = renderPage(); + const text = container.textContent ?? ""; + + expect(text).toContain("云端接入"); + expect(text).toContain("public/oem-runtime-config.js"); + expect(text).toContain("本地 / 其它开发者 Provider"); + expect(text).toContain("凭证池占位"); + }); + + it("未登录时应提示前往个人中心登录", async () => { + const onOpenProfile = vi.fn(); + const { container } = renderPage({ onOpenProfile }); + + expect(container.textContent ?? "").toContain("去个人中心登录"); + + await act(async () => { + findButton(container, "去个人中心登录").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(onOpenProfile).toHaveBeenCalledTimes(1); + }); + + it("已登录时应展示云端来源目录与模型详情", async () => { + const handleRefresh = vi.fn(); + const handleSetDefault = vi.fn(); + const selectedOffer = { + ...createOffer(), + access: { + offerId: "offer-001", + accessMode: "session", + hubTokenEnabled: false, + }, + }; + + mockUseOemCloudAccess.mockReturnValue( + createAccessState({ + session: { + tenant: { id: "tenant-0001" }, + user: { + id: "user-001", + email: "operator@example.com", + displayName: "Demo Operator", + }, + session: { + id: "session-001", + expiresAt: "2026-03-25T08:00:00.000Z", + }, + }, + offers: [createOffer()], + preference: { + providerSource: "oem_cloud", + providerKey: "lime-hub-main", + }, + selectedOffer, + selectedModels: [ + { + id: "model-001", + offerId: "offer-001", + modelId: "gpt-5.2-pro", + displayName: "GPT-5.2 Pro", + recommended: true, + }, + ], + defaultCloudOffer: createOffer(), + activeCloudOffer: createOffer(), + defaultProviderSummary: "Lime Hub 主服务 · gpt-5.2-pro", + defaultProviderSourceLabel: "云端服务", + handleRefresh, + handleSetDefault, + }), + ); + + const { container } = renderPage(); + const text = container.textContent ?? ""; + + expect(text).toContain("Demo Operator"); + expect(text).toContain("Lime Hub 主服务"); + expect(text).toContain("GPT-5.2 Pro"); + expect(text).toContain("fmt:2026-03-25T08:00:00.000Z"); + + await act(async () => { + findButton(container, "刷新云端状态").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + await act(async () => { + findButton(container, "已是默认来源").dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + expect(handleRefresh).toHaveBeenCalledTimes(1); + expect(handleSetDefault).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/settings-v2/agent/providers/index.tsx b/src/components/settings-v2/agent/providers/index.tsx new file mode 100644 index 000000000..751483f1a --- /dev/null +++ b/src/components/settings-v2/agent/providers/index.tsx @@ -0,0 +1,681 @@ +import { useEffect, useMemo } from "react"; +import { + AlertCircle, + CheckCircle2, + Cloud, + Database, + ExternalLink, + KeyRound, + Layers3, + LoaderCircle, + LogIn, + RefreshCw, + ShieldCheck, +} from "lucide-react"; +import { ProviderPoolPage } from "@/components/provider-pool"; +import { + formatOemCloudAccessModeLabel, + formatOemCloudConfigModeLabel, + formatOemCloudDateTime, + formatOemCloudModelsSourceLabel, + formatOemCloudOfferStateLabel, + useOemCloudAccess, +} from "@/hooks/useOemCloudAccess"; +import { cn } from "@/lib/utils"; + +const SURFACE_CLASS_NAME = + "rounded-[26px] border border-slate-200/80 bg-white p-5 shadow-sm shadow-slate-950/5"; + +function SessionValueCard(props: { + label: string; + value: string; + hint: string; + icon?: JSX.Element; +}) { + return ( +
+
+ {props.icon ? {props.icon} : null} + {props.label} +
+

+ {props.value} +

+

{props.hint}

+
+ ); +} + +function NoticeBar(props: { + tone: "error" | "success"; + message: string; +}) { + return ( +
+ {props.tone === "success" ? ( + + ) : ( + + )} + {props.message} +
+ ); +} + +function InfoPill(props: { label: string; tone?: "slate" | "emerald" | "amber" }) { + return ( + + {props.label} + + ); +} + +function resolveOfferTone( + state: string, +): "slate" | "emerald" | "amber" { + switch (state) { + case "available_ready": + return "emerald"; + case "available_quota_low": + case "available_subscribe_required": + return "amber"; + default: + return "slate"; + } +} + +export interface CloudProviderSettingsProps { + onOpenProfile?: () => void; +} + +export function CloudProviderSettings(props: CloudProviderSettingsProps) { + const { + runtime, + configuredTarget, + hubProviderName, + session, + offers, + preference, + selectedOffer, + selectedModels, + defaultCloudOffer, + activeCloudOffer, + initializing, + refreshing, + loadingDetail, + savingDefault, + errorMessage, + infoMessage, + defaultProviderSummary, + defaultProviderSourceLabel, + activeAccessModeLabel, + activeConfigModeLabel, + activeModelsSourceLabel, + activeDeveloperAccessEnabled, + activeDeveloperAccessLabel, + handleRefresh, + openOfferDetail, + handleSetDefault, + openUserCenter, + } = useOemCloudAccess(); + + useEffect(() => { + if (!session || selectedOffer || loadingDetail || offers.length === 0) { + return; + } + + const initialOffer = defaultCloudOffer ?? offers[0]; + if (initialOffer) { + void openOfferDetail(initialOffer.providerKey); + } + }, [ + defaultCloudOffer, + loadingDetail, + offers, + openOfferDetail, + selectedOffer, + session, + ]); + + const selectedOfferKey = selectedOffer?.providerKey ?? defaultCloudOffer?.providerKey; + + const localProviderHint = useMemo(() => { + if (!activeCloudOffer) { + return "这里管理你自带的 API Key、第三方平台和本地模型。云端入口已经单独收口,不再和本地 Provider 混在一起。"; + } + + if (activeDeveloperAccessEnabled) { + return "当前云端服务已开放开发者入口。你可以继续在这里管理自带 API Key 或第三方 Provider,作为云端服务之外的补充能力。"; + } + + return "当前云端服务未开放开发者 API Key 模式,这不会影响你在这里管理其它第三方 Provider 或本地模型。"; + }, [activeCloudOffer, activeDeveloperAccessEnabled]); + + return ( +
+
+
+
+ +
+
+
+ + CLOUD ACCESS + +
+

+ {hubProviderName} 云端接入 +

+

+ 这里展示云端会话、默认来源、套餐状态和模型目录;下方的 Provider 面板只负责本地 / 第三方开发者配置,避免把云端消费态和 API Key 管理混在一起。 +

+
+
+ +
+ } + /> + } + /> + } + /> + } + /> + } + /> + } + /> +
+
+ + {errorMessage ? : null} + {infoMessage ? : null} + + {!runtime ? ( +
+ 当前没有可用的运行时配置。请在 + + public/oem-runtime-config.js + + 中配置域名、网关地址和租户信息,再继续接入云端服务。 +
+ ) : initializing ? ( +
+
+ + 正在恢复个人中心会话... +
+
+ ) : session ? ( +
+
+
+
+
+
+
+ +
+
+

+ {session.user.displayName || session.user.email || "已登录"} +

+

+ {session.user.email || session.user.username || session.user.id} +

+
+
+ +
+ + + +
+
+ +
+ + + +
+
+
+ +
+
+

+ 当前治理结果 +

+

+ 默认来源:{defaultProviderSummary || "未设定"}。当前接入模式为 + {activeAccessModeLabel},配置模式为 {activeConfigModeLabel}, + 模型目录来自 {activeModelsSourceLabel}。 +

+

+ 开发者入口{activeDeveloperAccessEnabled ? "已开放" : "未开放"}。 + 当后台关闭 API Key 模式时,这里不会再把云端服务伪装成本地 API Key Provider。 +

+
+
+
+ +
+
+

+ 云端服务目录 +

+

+ 每个卡片都来自服务端最终态。是否允许 API Key 模式、当前模型来源、租户覆盖是否生效,都以服务端返回为准。 +

+
+ + {offers.length === 0 ? ( +
+

+ 当前租户还没有可用的云端服务来源。请先在后台发布可见 Offer。 +

+
+ ) : ( +
+
+ {offers.map((offer) => { + const isDefaultCloudOffer = + preference?.providerSource === "oem_cloud" && + preference.providerKey === offer.providerKey; + const isFocused = selectedOfferKey === offer.providerKey; + const stateTone = resolveOfferTone(offer.state); + + return ( +
+
+
+
+
+

+ {offer.displayName} +

+ {isDefaultCloudOffer ? ( + + ) : null} + {offer.tenantOverrideApplied ? ( + + ) : null} +
+

+ {offer.description || "当前来源暂无额外说明。"} +

+
+ +
+ +
+ + + + +
+ +
+ + +
+ +
+ + +
+
+
+ ); + })} +
+ +
+ {selectedOffer ? ( +
+
+
+

+ {selectedOffer.displayName} +

+ +
+

+ 当前实际接入方式为 + {formatOemCloudAccessModeLabel( + selectedOffer.access.accessMode, + )} + ,共下发 {selectedModels.length} 个模型目录项。 +

+
+ +
+ + + +
+ + {selectedOffer.loginHint ? ( +

+ 登录提示:{selectedOffer.loginHint} +

+ ) : null} + {selectedOffer.subscribeHint ? ( +

+ 套餐提示:{selectedOffer.subscribeHint} +

+ ) : null} + {selectedOffer.unavailableHint ? ( +

+ 不可用提示:{selectedOffer.unavailableHint} +

+ ) : null} + +
+
+ 模型目录 +
+ {loadingDetail ? ( +
+ + 正在加载模型目录... +
+ ) : selectedModels.length > 0 ? ( +
+ {selectedModels.map((model) => ( +
+
+
+

+ {model.displayName} +

+

+ {model.modelId} +

+
+ {model.recommended ? ( + + ) : null} +
+ {model.description ? ( +

+ {model.description} +

+ ) : null} +
+ ))} +
+ ) : ( +
+ 当前来源还没有下发模型目录。 +
+ )} +
+
+ ) : ( +
+ +

+ 选择一个云端来源查看详情 +

+

+ 这里会展示模型目录、实际接入方式和后台治理结果。 +

+
+ )} +
+
+ )} +
+
+ ) : ( +
+
+
+
+

+ 先在个人中心登录 +

+

+ 当前还没有可用的个人中心会话。登录后,云端默认来源、模型目录和服务技能目录会自动同步到本地。 +

+
+
+ + +
+
+
+ +
+
+

+ 接入说明 +

+

+ 云端服务默认通过登录会话消费。是否开放开发者 API Key 模式、是否允许租户覆盖,都由后台统一治理并在这里展示最终态。 +

+
+
+
+ )} +
+
+ +
+
+
+
+

+ 本地 / 其它开发者 Provider +

+

+ {localProviderHint} +

+
+ + +
+
+ + +
+
+ ); +} + +export default CloudProviderSettings; diff --git a/src/hooks/useApiKeyProvider.ts b/src/hooks/useApiKeyProvider.ts index 6bbce09c8..111f46a1a 100644 --- a/src/hooks/useApiKeyProvider.ts +++ b/src/hooks/useApiKeyProvider.ts @@ -24,6 +24,9 @@ import { subscribeProviderDataChanged, } from "@/lib/providerDataEvents"; import { isDebugFlagEnabled } from "@/lib/perfDebug"; +import { + isOemManagedHubProvider, +} from "@/lib/oemLimeHubProvider"; // ============================================================================ // Hook 返回类型 @@ -510,7 +513,9 @@ export function useApiKeyProvider( providerDebugLog("[useApiKeyProvider] selectedProvider: 没有选中的 Provider"); return null; } - const found = providers.find((p) => p.id === selectedProviderId); + const found = providers.find( + (p) => p.id === selectedProviderId && !isOemManagedHubProvider(p), + ); if (found) { providerDebugLog( `[useApiKeyProvider] selectedProvider: ${found.name}, api_keys: ${found.api_keys?.length || 0}`, @@ -523,11 +528,31 @@ export function useApiKeyProvider( return found ?? null; }, [providers, selectedProviderId]); + useEffect(() => { + if (!selectedProviderId) { + return; + } + + const selectedProvider = providers.find((p) => p.id === selectedProviderId); + if (!selectedProvider || !isOemManagedHubProvider(selectedProvider)) { + return; + } + + const nextVisibleProvider = + providers.find((provider) => !isOemManagedHubProvider(provider)) ?? null; + const nextProviderId = nextVisibleProvider?.id ?? null; + setSelectedProviderId(nextProviderId); + void saveSelectedProvider(nextProviderId); + }, [providers, saveSelectedProvider, selectedProviderId]); + /** 按搜索过滤后的 Provider 列表 */ const filteredProviders = useMemo(() => { - if (!searchQuery.trim()) return providers; + const visibleProviders = providers.filter( + (provider) => !isOemManagedHubProvider(provider), + ); + if (!searchQuery.trim()) return visibleProviders; const query = searchQuery.toLowerCase(); - return providers.filter( + return visibleProviders.filter( (p) => p.name.toLowerCase().includes(query) || p.id.toLowerCase().includes(query), diff --git a/src/hooks/useDeepLink.ts b/src/hooks/useDeepLink.ts index 6ae95e6fb..4133ba177 100644 --- a/src/hooks/useDeepLink.ts +++ b/src/hooks/useDeepLink.ts @@ -9,10 +9,17 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { safeInvoke, safeListen } from "@/lib/dev-bridge"; import type { UnlistenFn } from "@tauri-apps/api/event"; +import { + completeOemCloudDesktopOAuthLogin, + parseOemCloudDesktopOAuthCallbackUrl, +} from "@/lib/oemCloudDesktopAuth"; +import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; +import { resolveOemLimeHubProviderName } from "@/lib/oemLimeHubProvider"; import { showDeepLinkError, showApiKeySaveError, } from "@/lib/utils/connectError"; +import { toast } from "sonner"; import { useConnectCallback } from "./useConnectCallback"; /** @@ -268,6 +275,40 @@ export function useDeepLink(): UseDeepLinkReturn { setIsDialogOpen(true); }, []); + const handleOauthCallbackUrl = useCallback(async (url: string) => { + const payload = parseOemCloudDesktopOAuthCallbackUrl(url); + if (!payload) { + return false; + } + + if (payload.error) { + toast.error("Google 登录未完成", { + description: payload.error, + }); + return true; + } + + try { + await completeOemCloudDesktopOAuthLogin(payload); + const providerName = resolveOemLimeHubProviderName( + resolveOemCloudRuntimeContext(), + ); + toast.success("Google 登录成功", { + description: `个人中心会话、${providerName} 云端目录与服务技能目录已同步。`, + }); + } catch (error) { + const message = + error instanceof Error && error.message.trim() + ? error.message.trim() + : "同步桌面端登录结果失败"; + toast.error("Google 登录失败", { + description: message, + }); + } + + return true; + }, []); + /** * 确认添加 API Key * _Requirements: 5.4, 5.3 (统计回调)_ @@ -385,6 +426,10 @@ export function useDeepLink(): UseDeepLinkReturn { console.log("[useDeepLink] 收到 Deep Link URL:", urls); for (const url of urls) { + if (await handleOauthCallbackUrl(url)) { + continue; + } + if (!url.startsWith("lime://connect")) { continue; } @@ -462,7 +507,7 @@ export function useDeepLink(): UseDeepLinkReturn { } console.log("[useDeepLink] 已取消 Deep Link 监听器"); }; - }, [handleDeepLinkEvent, handleDeepLinkError]); + }, [handleDeepLinkEvent, handleDeepLinkError, handleOauthCallbackUrl]); return { connectPayload, diff --git a/src/hooks/useOemCloudAccess.test.tsx b/src/hooks/useOemCloudAccess.test.tsx new file mode 100644 index 000000000..9a6dbd074 --- /dev/null +++ b/src/hooks/useOemCloudAccess.test.tsx @@ -0,0 +1,222 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { setStoredOemCloudSessionState } from "@/lib/oemCloudSession"; + +const controlPlaneMocks = vi.hoisted(() => ({ + getClientBootstrap: vi.fn(), + listClientProviderOffers: vi.fn(), + getClientProviderPreference: vi.fn(), + getClientProviderOffer: vi.fn(), + listClientProviderOfferModels: vi.fn(), + updateClientProviderPreference: vi.fn(), + createClientDesktopAuthSession: vi.fn(), + pollClientDesktopAuthSession: vi.fn(), + loginClientByPassword: vi.fn(), + logoutClient: vi.fn(), + sendClientAuthEmailCode: vi.fn(), + verifyClientAuthEmailCode: vi.fn(), +})); + +vi.mock("@/lib/api/oemCloudControlPlane", () => { + class MockOemCloudControlPlaneError extends Error { + status: number; + + constructor(message: string, status = 500) { + super(message); + this.status = status; + } + } + + return { + OemCloudControlPlaneError: MockOemCloudControlPlaneError, + getClientBootstrap: controlPlaneMocks.getClientBootstrap, + listClientProviderOffers: controlPlaneMocks.listClientProviderOffers, + getClientProviderPreference: controlPlaneMocks.getClientProviderPreference, + getClientProviderOffer: controlPlaneMocks.getClientProviderOffer, + listClientProviderOfferModels: + controlPlaneMocks.listClientProviderOfferModels, + updateClientProviderPreference: + controlPlaneMocks.updateClientProviderPreference, + createClientDesktopAuthSession: + controlPlaneMocks.createClientDesktopAuthSession, + pollClientDesktopAuthSession: + controlPlaneMocks.pollClientDesktopAuthSession, + loginClientByPassword: controlPlaneMocks.loginClientByPassword, + logoutClient: controlPlaneMocks.logoutClient, + sendClientAuthEmailCode: controlPlaneMocks.sendClientAuthEmailCode, + verifyClientAuthEmailCode: controlPlaneMocks.verifyClientAuthEmailCode, + }; +}); + +vi.mock("@/lib/oemCloudDesktopAuth", () => ({ + OEM_CLOUD_OAUTH_COMPLETED_EVENT: "lime:oem-cloud-oauth-completed", + completeOemCloudDesktopOAuthLogin: vi.fn(), +})); + +vi.mock("@/lib/serviceSkillCatalogBootstrap", () => ({ + syncServiceSkillCatalogFromBootstrapPayload: vi.fn(), +})); + +import { useOemCloudAccess } from "./useOemCloudAccess"; + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((nextResolve, nextReject) => { + resolve = nextResolve; + reject = nextReject; + }); + return { + promise, + resolve, + reject, + }; +} + +let latestState: ReturnType | null = null; + +function HookHarness() { + latestState = useOemCloudAccess(); + return ( +
+ {latestState.initializing + ? "initializing" + : latestState.session?.session.id || "anonymous"} +
+ ); +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useOemCloudAccess", () => { + let mountedHarness: MountedHarness | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + latestState = null; + window.localStorage.clear(); + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + tenantId: "tenant-0001", + }; + + controlPlaneMocks.listClientProviderOffers.mockResolvedValue([]); + controlPlaneMocks.getClientProviderPreference.mockResolvedValue(null); + controlPlaneMocks.getClientProviderOffer.mockResolvedValue(null); + controlPlaneMocks.listClientProviderOfferModels.mockResolvedValue([]); + controlPlaneMocks.updateClientProviderPreference.mockResolvedValue(null); + controlPlaneMocks.createClientDesktopAuthSession.mockResolvedValue(null); + controlPlaneMocks.pollClientDesktopAuthSession.mockResolvedValue(null); + controlPlaneMocks.loginClientByPassword.mockResolvedValue(null); + controlPlaneMocks.logoutClient.mockResolvedValue(undefined); + controlPlaneMocks.sendClientAuthEmailCode.mockResolvedValue(null); + controlPlaneMocks.verifyClientAuthEmailCode.mockResolvedValue(null); + }); + + afterEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + + if (mountedHarness) { + act(() => { + mountedHarness?.root.unmount(); + }); + mountedHarness.container.remove(); + mountedHarness = null; + } + }); + + it("恢复本地会话时不应因重复 effect 触发而卡在初始化中", async () => { + const bootstrapPayload = { + session: { + tenant: { + id: "tenant-0001", + name: "JustAI Demo", + }, + user: { + id: "user-001", + email: "operator@example.com", + displayName: "Demo Operator", + }, + session: { + id: "session-001", + tenantId: "tenant-0001", + userId: "user-001", + expiresAt: "2026-03-25T08:00:00.000Z", + }, + }, + providerOffersSummary: [], + providerPreference: null, + serviceSkillCatalog: { + items: [], + }, + sceneCatalog: [], + gateway: { + basePath: "/gateway-api", + }, + }; + const bootstrapDeferred = createDeferred(); + controlPlaneMocks.getClientBootstrap.mockImplementation( + () => bootstrapDeferred.promise, + ); + + setStoredOemCloudSessionState({ + token: "session-token-restore", + tenant: { + id: "tenant-0001", + }, + user: { + id: "user-001", + }, + session: { + id: "session-001", + }, + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedHarness = { container, root }; + + act(() => { + root.render(); + }); + + await flushEffects(); + + expect(controlPlaneMocks.getClientBootstrap).toHaveBeenCalledTimes(1); + expect(latestState?.initializing).toBe(true); + + await act(async () => { + bootstrapDeferred.resolve(bootstrapPayload); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(latestState?.initializing).toBe(false); + expect(latestState?.session?.session.id).toBe("session-001"); + expect(latestState?.session?.token).toBe("session-token-restore"); + }); +}); diff --git a/src/hooks/useOemCloudAccess.ts b/src/hooks/useOemCloudAccess.ts new file mode 100644 index 000000000..aefdec844 --- /dev/null +++ b/src/hooks/useOemCloudAccess.ts @@ -0,0 +1,1036 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + clearServiceSkillCatalogCache, +} from "@/lib/api/serviceSkills"; +import { + type ClientPasswordLoginPayload, + type CreateClientDesktopAuthSessionPayload, + type OemCloudBootstrapResponse, + type OemCloudCurrentSession, + type OemCloudDesktopAuthSessionStartResponse, + type OemCloudDesktopAuthSessionStatus, + type OemCloudPartnerHubAccessMode, + type OemCloudPartnerHubConfigMode, + type OemCloudPartnerHubModelsSource, + type OemCloudProviderModelItem, + type OemCloudProviderOfferDetail, + type OemCloudProviderOfferState, + type OemCloudProviderOfferSummary, + type OemCloudProviderPreference, + type SendAuthEmailCodeResponse, + type VerifyClientAuthEmailCodePayload, + OemCloudControlPlaneError, + createClientDesktopAuthSession, + getClientBootstrap, + getClientProviderOffer, + getClientProviderPreference, + listClientProviderOfferModels, + listClientProviderOffers, + loginClientByPassword, + logoutClient, + pollClientDesktopAuthSession, + sendClientAuthEmailCode, + updateClientProviderPreference, + verifyClientAuthEmailCode, +} from "@/lib/api/oemCloudControlPlane"; +import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; +import { + completeOemCloudDesktopOAuthLogin, + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + type OemCloudDesktopOAuthCompletedDetail, +} from "@/lib/oemCloudDesktopAuth"; +import { + applyStoredOemCloudSessionToWindow, + clearOemCloudBootstrapSnapshot, + clearStoredOemCloudSessionState, + getOemCloudBootstrapSnapshot, + getStoredOemCloudSessionState, + setOemCloudBootstrapSnapshot, + setStoredOemCloudSessionState, +} from "@/lib/oemCloudSession"; +import { syncServiceSkillCatalogFromBootstrapPayload } from "@/lib/serviceSkillCatalogBootstrap"; +import { resolveOemLimeHubProviderName } from "@/lib/oemLimeHubProvider"; + +export type OemCloudLoginMode = "password" | "email_code"; + +const DESKTOP_AUTH_LEGACY_CLIENT_IDS: Record = { + "limehub-desktop": ["lobehub-desktop"], +}; + +async function openExternalUrl(url: string): Promise { + try { + const { open } = await import("@tauri-apps/plugin-shell"); + await open(url); + return; + } catch { + if (typeof window === "undefined") { + throw new Error("当前环境不支持打开外部浏览器"); + } + } + + window.open(url, "_blank", "noopener,noreferrer"); +} + +function buildUserCenterUrl(baseUrl: string, path = "") { + const targetPath = path.trim(); + if (!targetPath) { + return baseUrl; + } + + if (/^https?:\/\//i.test(targetPath)) { + return targetPath; + } + + return `${baseUrl}${targetPath.startsWith("/") ? targetPath : `/${targetPath}`}`; +} + +function buildErrorMessage(error: unknown, fallback: string) { + if (error instanceof Error && error.message.trim()) { + return error.message.trim(); + } + + const message = String(error ?? "").trim(); + if (!message || message === "[object Object]") { + return fallback; + } + return message; +} + +function isAuthExpired(error: unknown) { + return ( + error instanceof OemCloudControlPlaneError && + (error.status === 401 || error.status === 403) + ); +} + +function sleep(ms: number) { + return new Promise((resolve) => { + window.setTimeout(resolve, ms); + }); +} + +function isDesktopClientNotFound(error: unknown) { + return ( + error instanceof OemCloudControlPlaneError && + error.status === 404 && + /desktop client not found/i.test(error.message) + ); +} + +function resolveDesktopClientIdCandidates(clientId: string) { + const primaryClientId = clientId.trim(); + const fallbackClientIds = DESKTOP_AUTH_LEGACY_CLIENT_IDS[primaryClientId] ?? []; + return [primaryClientId, ...fallbackClientIds]; +} + +async function createGoogleDesktopAuthSession( + runtime: ReturnType, +): Promise { + if (!runtime) { + throw new Error("缺少 OEM 云端配置,请先配置域名与租户。"); + } + + const payload: CreateClientDesktopAuthSessionPayload = { + clientId: runtime.desktopClientId, + provider: "google", + desktopRedirectUri: runtime.desktopOauthRedirectUrl, + }; + + const clientIdCandidates = resolveDesktopClientIdCandidates( + runtime.desktopClientId, + ); + + let lastError: unknown = null; + for (const clientId of clientIdCandidates) { + try { + return await createClientDesktopAuthSession(runtime.tenantId, { + ...payload, + clientId, + }); + } catch (error) { + lastError = error; + if ( + clientId !== clientIdCandidates[clientIdCandidates.length - 1] && + isDesktopClientNotFound(error) + ) { + continue; + } + throw error; + } + } + + throw lastError ?? new Error("创建 Google 桌面授权会话失败"); +} + +function buildGoogleDesktopAuthTerminalMessage( + status: OemCloudDesktopAuthSessionStatus, +) { + switch (status) { + case "denied": + return "Google 授权已被拒绝,请重新发起登录。"; + case "cancelled": + return "Google 授权已取消,请重新发起登录。"; + case "expired": + return "Google 授权已过期,请重新发起登录。"; + case "consumed": + return "当前登录结果已被消费,请重新发起 Google 登录。"; + default: + return `Google 授权返回了未识别状态:${status}`; + } +} + +async function waitForGoogleOauthCompletion( + isCompleted: () => boolean, + timeoutMs: number, +) { + const deadline = Date.now() + Math.max(0, timeoutMs); + while (!isCompleted() && Date.now() < deadline) { + await sleep(150); + } + + return isCompleted(); +} + +async function pollGoogleDesktopAuthSession( + runtime: NonNullable>, + authSession: OemCloudDesktopAuthSessionStartResponse, + isCompleted: () => boolean, +) { + let pollIntervalSeconds = Math.max(1, authSession.pollIntervalSeconds); + + while (!isCompleted()) { + const status = await pollClientDesktopAuthSession(authSession.deviceCode); + if (isCompleted()) { + return; + } + + pollIntervalSeconds = Math.max(1, status.pollIntervalSeconds); + + switch (status.status) { + case "pending_login": + case "pending_consent": { + await sleep(pollIntervalSeconds * 1000); + continue; + } + case "approved": { + if (!status.sessionToken) { + throw new Error("Google 授权已完成,但服务端未返回会话 Token。"); + } + + await completeOemCloudDesktopOAuthLogin({ + tenantId: status.tenantId || authSession.tenantId, + token: status.sessionToken, + nextPath: runtime.desktopOauthNextPath, + error: null, + }); + return; + } + case "consumed": { + if ( + await waitForGoogleOauthCompletion( + isCompleted, + Math.min(2000, pollIntervalSeconds * 1000), + ) + ) { + return; + } + throw new Error(buildGoogleDesktopAuthTerminalMessage(status.status)); + } + case "denied": + case "cancelled": + case "expired": + default: + throw new Error(buildGoogleDesktopAuthTerminalMessage(status.status)); + } + } +} + +export function formatOemCloudDateTime(value?: string) { + if (!value) { + return "未知"; + } + + const parsed = Date.parse(value); + if (Number.isNaN(parsed)) { + return value; + } + + return new Intl.DateTimeFormat("zh-CN", { + dateStyle: "medium", + timeStyle: "short", + }).format(parsed); +} + +export function formatOemCloudAccessModeLabel( + value?: OemCloudPartnerHubAccessMode, +) { + switch (value) { + case "session": + return "登录会话"; + case "hub_token": + return "平台令牌"; + case "api_key": + return "API Key"; + default: + return "未知"; + } +} + +export function formatOemCloudConfigModeLabel( + value?: OemCloudPartnerHubConfigMode, +) { + switch (value) { + case "managed": + return "托管模式"; + case "hybrid": + return "混合模式"; + case "developer": + return "开发者模式"; + default: + return "未知"; + } +} + +export function formatOemCloudModelsSourceLabel( + value?: OemCloudPartnerHubModelsSource, +) { + switch (value) { + case "hub_catalog": + return "云端目录"; + case "manual": + return "手动目录"; + default: + return "未知"; + } +} + +export function formatOemCloudOfferStateLabel( + value?: OemCloudProviderOfferState, +) { + switch (value) { + case "available_logged_out": + return "待登录"; + case "available_subscribe_required": + return "需开通套餐"; + case "available_ready": + return "可直接使用"; + case "available_quota_low": + return "额度偏低"; + case "blocked": + return "已受限"; + case "unavailable": + return "不可用"; + default: + return "未知"; + } +} + +const LOCAL_PROVIDER_SUMMARY = "本地开发者 Provider"; + +export function useOemCloudAccess() { + const runtime = resolveOemCloudRuntimeContext(); + const restoreTargetKey = runtime + ? `${runtime.baseUrl}::${runtime.tenantId}` + : "__runtime_unavailable__"; + const [loginMode, setLoginMode] = useState("password"); + const [passwordForm, setPasswordForm] = useState({ + identifier: "", + password: "", + }); + const [emailCodeForm, setEmailCodeForm] = + useState({ + identifier: "", + code: "", + displayName: "", + username: "", + }); + const [codeDelivery, setCodeDelivery] = + useState(null); + const [session, setSession] = useState(null); + const [bootstrap, setBootstrap] = useState( + null, + ); + const [offers, setOffers] = useState([]); + const [preference, setPreference] = + useState(null); + const [selectedOffer, setSelectedOffer] = + useState(null); + const [selectedModels, setSelectedModels] = useState( + [], + ); + const [initializing, setInitializing] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [sendingCode, setSendingCode] = useState(false); + const [loggingIn, setLoggingIn] = useState(false); + const [loggingOut, setLoggingOut] = useState(false); + const [openingGoogleLogin, setOpeningGoogleLogin] = useState(false); + const [loadingDetail, setLoadingDetail] = useState(false); + const [savingDefault, setSavingDefault] = useState(""); + const [errorMessage, setErrorMessage] = useState(null); + const [infoMessage, setInfoMessage] = useState(null); + + const configuredTarget = useMemo( + () => + runtime + ? { + baseUrl: runtime.baseUrl, + tenantId: runtime.tenantId, + } + : null, + [runtime], + ); + + const clearCloudState = useCallback((message?: string) => { + clearStoredOemCloudSessionState(); + clearOemCloudBootstrapSnapshot(); + clearServiceSkillCatalogCache(); + setSession(null); + setBootstrap(null); + setOffers([]); + setPreference(null); + setSelectedOffer(null); + setSelectedModels([]); + setCodeDelivery(null); + if (message) { + setInfoMessage(message); + } + }, []); + + const applyBootstrap = useCallback( + ( + nextBootstrap: OemCloudBootstrapResponse, + fallbackToken?: string, + extraOffers?: OemCloudProviderOfferSummary[], + extraPreference?: OemCloudProviderPreference, + ) => { + const nextSession: OemCloudCurrentSession = { + ...nextBootstrap.session, + token: + nextBootstrap.session.token ?? + fallbackToken ?? + session?.token, + }; + + setStoredOemCloudSessionState(nextSession); + setOemCloudBootstrapSnapshot({ + ...nextBootstrap, + session: nextSession, + }); + syncServiceSkillCatalogFromBootstrapPayload({ + ...nextBootstrap, + session: nextSession, + }); + + setSession(nextSession); + setBootstrap({ + ...nextBootstrap, + session: nextSession, + }); + setOffers(extraOffers ?? nextBootstrap.providerOffersSummary); + setPreference(extraPreference ?? nextBootstrap.providerPreference); + setErrorMessage(null); + }, + [session?.token], + ); + + const refreshAuthenticatedState = useCallback( + async (tenantIdOverride?: string, fallbackToken?: string) => { + const targetTenantId = + tenantIdOverride ?? session?.tenant.id ?? runtime?.tenantId; + if (!runtime || !targetTenantId) { + return null; + } + + const nextBootstrap = await getClientBootstrap(targetTenantId); + const [nextOffers, nextPreference] = await Promise.all([ + listClientProviderOffers(targetTenantId), + getClientProviderPreference(targetTenantId), + ]); + applyBootstrap(nextBootstrap, fallbackToken, nextOffers, nextPreference); + return nextBootstrap; + }, + [applyBootstrap, runtime, session?.tenant.id], + ); + + const runtimeRef = useRef(runtime); + const clearCloudStateRef = useRef(clearCloudState); + const refreshAuthenticatedStateRef = useRef(refreshAuthenticatedState); + + useEffect(() => { + runtimeRef.current = runtime; + }, [runtime]); + + useEffect(() => { + clearCloudStateRef.current = clearCloudState; + }, [clearCloudState]); + + useEffect(() => { + refreshAuthenticatedStateRef.current = refreshAuthenticatedState; + }, [refreshAuthenticatedState]); + + useEffect(() => { + let cancelled = false; + + async function restoreSession() { + const currentRuntime = runtimeRef.current; + if (!currentRuntime) { + setInitializing(false); + return; + } + + setInitializing(true); + const stored = applyStoredOemCloudSessionToWindow(); + if (!stored) { + setInitializing(false); + return; + } + + try { + await refreshAuthenticatedStateRef.current( + stored.session.tenant.id, + stored.token, + ); + } catch (error) { + if (cancelled) { + return; + } + + if (isAuthExpired(error)) { + clearCloudStateRef.current("云端会话已过期,请重新登录。"); + } else { + setErrorMessage(buildErrorMessage(error, "恢复云端会话失败")); + } + } finally { + if (!cancelled) { + setInitializing(false); + } + } + } + + void restoreSession(); + + return () => { + cancelled = true; + }; + }, [restoreTargetKey]); + + useEffect(() => { + if (typeof window === "undefined") { + return () => undefined; + } + + let cancelled = false; + + const handleOauthCompleted = (event: Event) => { + if (cancelled) { + return; + } + + const detail = + event instanceof CustomEvent + ? (event.detail as OemCloudDesktopOAuthCompletedDetail) + : null; + const storedState = getStoredOemCloudSessionState(); + if (!storedState) { + return; + } + + const snapshot = + getOemCloudBootstrapSnapshot(); + + setInitializing(false); + setSession(storedState.session as OemCloudCurrentSession); + setCodeDelivery(null); + setSelectedOffer(null); + setSelectedModels([]); + setErrorMessage(null); + + if (snapshot) { + applyBootstrap(snapshot, storedState.token); + setInfoMessage( + detail?.provider === "google" + ? "Google 登录成功,已同步云端目录。" + : "云端登录成功,已同步目录。", + ); + return; + } + + void refreshAuthenticatedState( + storedState.session.tenant.id, + storedState.token, + ) + .then(() => { + if (!cancelled) { + setInfoMessage( + detail?.provider === "google" + ? "Google 登录成功,已同步云端目录。" + : "云端登录成功,已同步目录。", + ); + } + }) + .catch((error) => { + if (cancelled) { + return; + } + + if (isAuthExpired(error)) { + clearCloudState("云端会话已失效,请重新登录。"); + return; + } + setErrorMessage(buildErrorMessage(error, "同步云端登录结果失败")); + }); + }; + + window.addEventListener( + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + handleOauthCompleted as EventListener, + ); + + return () => { + cancelled = true; + window.removeEventListener( + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + handleOauthCompleted as EventListener, + ); + }; + }, [applyBootstrap, clearCloudState, refreshAuthenticatedState]); + + const handleRefresh = useCallback(async () => { + if (!runtime || !session?.tenant.id) { + return; + } + + setRefreshing(true); + try { + await refreshAuthenticatedState(session.tenant.id, session.token); + setInfoMessage("已同步最新云端会话、服务目录与服务技能快照。"); + setSelectedOffer(null); + setSelectedModels([]); + } catch (error) { + if (isAuthExpired(error)) { + clearCloudState("云端会话已失效,请重新登录。"); + return; + } + setErrorMessage(buildErrorMessage(error, "刷新云端状态失败")); + } finally { + setRefreshing(false); + } + }, [clearCloudState, refreshAuthenticatedState, runtime, session]); + + const handleSendEmailCode = useCallback(async () => { + if (!runtime) { + setErrorMessage("缺少 OEM 云端配置,请先配置域名与租户。"); + return; + } + + if (!emailCodeForm.identifier.trim()) { + setErrorMessage("请输入邮箱或账号后再发送验证码。"); + return; + } + + setSendingCode(true); + setErrorMessage(null); + setInfoMessage(null); + try { + const response = await sendClientAuthEmailCode(runtime.tenantId, { + identifier: emailCodeForm.identifier.trim(), + }); + setCodeDelivery(response); + setInfoMessage( + `验证码已发送至 ${response.maskedEmail},有效期约 ${Math.max( + 1, + Math.round(response.expiresInSeconds / 60), + )} 分钟。`, + ); + } catch (error) { + setErrorMessage(buildErrorMessage(error, "发送验证码失败")); + } finally { + setSendingCode(false); + } + }, [emailCodeForm.identifier, runtime]); + + const handleEmailCodeLogin = useCallback(async () => { + if (!runtime) { + setErrorMessage("缺少 OEM 云端配置,请先配置域名与租户。"); + return; + } + + if (!emailCodeForm.identifier.trim() || !emailCodeForm.code.trim()) { + setErrorMessage("请先填写邮箱/账号和验证码。"); + return; + } + + setLoggingIn(true); + setErrorMessage(null); + setInfoMessage(null); + try { + const nextSession = await verifyClientAuthEmailCode(runtime.tenantId, { + identifier: emailCodeForm.identifier.trim(), + code: emailCodeForm.code.trim(), + displayName: emailCodeForm.displayName?.trim() || undefined, + username: emailCodeForm.username?.trim() || undefined, + }); + setStoredOemCloudSessionState(nextSession); + setSession(nextSession); + await refreshAuthenticatedState(nextSession.tenant.id, nextSession.token); + setInfoMessage("验证码登录成功,已同步云端目录。"); + setCodeDelivery(null); + setEmailCodeForm({ + identifier: nextSession.user.email || emailCodeForm.identifier, + code: "", + displayName: "", + username: "", + }); + } catch (error) { + setErrorMessage(buildErrorMessage(error, "验证码登录失败")); + } finally { + setLoggingIn(false); + } + }, [emailCodeForm, refreshAuthenticatedState, runtime]); + + const handlePasswordLogin = useCallback(async () => { + if (!runtime) { + setErrorMessage("缺少 OEM 云端配置,请先配置域名与租户。"); + return; + } + + if (!passwordForm.identifier.trim() || !passwordForm.password.trim()) { + setErrorMessage("请输入账号和密码。"); + return; + } + + setLoggingIn(true); + setErrorMessage(null); + setInfoMessage(null); + try { + const nextSession = await loginClientByPassword(runtime.tenantId, { + identifier: passwordForm.identifier.trim(), + password: passwordForm.password, + }); + setStoredOemCloudSessionState(nextSession); + setSession(nextSession); + await refreshAuthenticatedState(nextSession.tenant.id, nextSession.token); + setInfoMessage("账号登录成功,已同步云端目录。"); + setPasswordForm((current) => ({ + ...current, + password: "", + })); + } catch (error) { + setErrorMessage(buildErrorMessage(error, "账号登录失败")); + } finally { + setLoggingIn(false); + } + }, [passwordForm, refreshAuthenticatedState, runtime]); + + const handleLogout = useCallback(async () => { + if (!session?.tenant.id) { + clearCloudState("已清理本地云端会话。"); + return; + } + + setLoggingOut(true); + setErrorMessage(null); + try { + await logoutClient(session.tenant.id); + clearCloudState("已退出云端会话。"); + } catch (error) { + clearCloudState("本地会话已清理,但服务端注销未确认。"); + setErrorMessage(buildErrorMessage(error, "服务端注销失败")); + } finally { + setLoggingOut(false); + } + }, [clearCloudState, session?.tenant.id]); + + const handleGoogleLogin = useCallback(async () => { + if (!runtime) { + setErrorMessage("缺少 OEM 云端配置,请先配置域名与租户。"); + return; + } + + setOpeningGoogleLogin(true); + setErrorMessage(null); + setInfoMessage(null); + + let oauthCompleted = false; + let disposeCompletionListener = () => undefined; + + try { + const oauthCompletedPromise = + typeof window === "undefined" + ? new Promise(() => undefined) + : new Promise((resolve) => { + const handleOauthCompleted = (event: Event) => { + const detail = + event instanceof CustomEvent + ? (event.detail as OemCloudDesktopOAuthCompletedDetail) + : null; + if ( + detail?.provider !== "google" || + detail.tenantId !== runtime.tenantId + ) { + return; + } + + oauthCompleted = true; + resolve(); + }; + + window.addEventListener( + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + handleOauthCompleted as EventListener, + ); + disposeCompletionListener = () => { + window.removeEventListener( + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + handleOauthCompleted as EventListener, + ); + }; + }); + + const authSession = await createGoogleDesktopAuthSession(runtime); + + await openExternalUrl(authSession.authorizeUrl); + setInfoMessage( + "已打开系统浏览器,请完成 Google 授权;桌面端会自动同步登录结果。", + ); + + const pollPromise = pollGoogleDesktopAuthSession( + runtime, + authSession, + () => oauthCompleted, + ); + + const winner = await Promise.race([ + oauthCompletedPromise.then(() => "event" as const), + pollPromise.then(() => "poll" as const), + ]); + + if (winner === "event") { + void pollPromise.catch(() => undefined); + } + } catch (error) { + if (!oauthCompleted) { + setErrorMessage(buildErrorMessage(error, "Google 登录失败")); + } + } finally { + disposeCompletionListener(); + setOpeningGoogleLogin(false); + } + }, [runtime]); + + const openOfferDetail = useCallback( + async (providerKey: string) => { + if (!session?.tenant.id) { + return; + } + + setLoadingDetail(true); + setErrorMessage(null); + try { + const [detail, models] = await Promise.all([ + getClientProviderOffer(session.tenant.id, providerKey), + listClientProviderOfferModels(session.tenant.id, providerKey), + ]); + setSelectedOffer(detail); + setSelectedModels(models); + } catch (error) { + if (isAuthExpired(error)) { + clearCloudState("云端会话已失效,请重新登录。"); + return; + } + setErrorMessage(buildErrorMessage(error, "加载云服务详情失败")); + } finally { + setLoadingDetail(false); + } + }, + [clearCloudState, session?.tenant.id], + ); + + const handleSetDefault = useCallback( + async ( + offer: OemCloudProviderOfferSummary | OemCloudProviderOfferDetail, + defaultModel?: string, + ) => { + if (!session?.tenant.id) { + return; + } + + const nextDefaultModel = defaultModel || offer.defaultModel; + setSavingDefault(offer.providerKey); + setErrorMessage(null); + try { + const nextPreference = await updateClientProviderPreference( + session.tenant.id, + { + providerSource: "oem_cloud", + providerKey: offer.providerKey, + defaultModel: nextDefaultModel, + }, + ); + setPreference(nextPreference); + await refreshAuthenticatedState(session.tenant.id, session.token); + setInfoMessage(`已将 ${offer.displayName} 设为默认云端服务来源。`); + } catch (error) { + if (isAuthExpired(error)) { + clearCloudState("云端会话已失效,请重新登录。"); + return; + } + setErrorMessage(buildErrorMessage(error, "设置默认服务商失败")); + } finally { + setSavingDefault(""); + } + }, + [clearCloudState, refreshAuthenticatedState, session], + ); + + const openUserCenter = useCallback( + async (path = "") => { + if (!configuredTarget) { + return; + } + + await openExternalUrl(buildUserCenterUrl(configuredTarget.baseUrl, path)); + }, + [configuredTarget], + ); + + const hubProviderName = useMemo( + () => resolveOemLimeHubProviderName(runtime), + [runtime], + ); + + const defaultCloudOffer = useMemo(() => { + if (!offers.length) { + return null; + } + + if (preference?.providerSource === "oem_cloud") { + const matchedOffer = offers.find( + (offer) => offer.providerKey === preference.providerKey, + ); + if (matchedOffer) { + return matchedOffer; + } + } + + return offers[0] ?? null; + }, [offers, preference]); + + const activeCloudOffer = selectedOffer ?? defaultCloudOffer; + + const defaultProviderSummary = useMemo(() => { + if (!preference) { + return null; + } + + if (preference.providerSource === "local") { + return `${LOCAL_PROVIDER_SUMMARY}${ + preference.defaultModel ? ` · ${preference.defaultModel}` : "" + }`; + } + + const matchedOffer = offers.find( + (offer) => offer.providerKey === preference.providerKey, + ); + if (!matchedOffer) { + return `${hubProviderName}${ + preference.defaultModel ? ` · ${preference.defaultModel}` : "" + }`; + } + + return `${matchedOffer.displayName}${ + preference.defaultModel ? ` · ${preference.defaultModel}` : "" + }`; + }, [offers, preference, hubProviderName]); + + const defaultProviderSourceLabel = useMemo(() => { + if (!preference) { + return "未设定"; + } + + return preference.providerSource === "local" + ? LOCAL_PROVIDER_SUMMARY + : "云端服务"; + }, [preference]); + + const activeAccessModeLabel = useMemo( + () => formatOemCloudAccessModeLabel(activeCloudOffer?.effectiveAccessMode), + [activeCloudOffer?.effectiveAccessMode], + ); + + const activeConfigModeLabel = useMemo( + () => formatOemCloudConfigModeLabel(activeCloudOffer?.configMode), + [activeCloudOffer?.configMode], + ); + + const activeModelsSourceLabel = useMemo( + () => formatOemCloudModelsSourceLabel(activeCloudOffer?.modelsSource), + [activeCloudOffer?.modelsSource], + ); + + const activeDeveloperAccessEnabled = Boolean( + activeCloudOffer?.apiKeyModeEnabled && activeCloudOffer?.developerAccessVisible, + ); + + const activeDeveloperAccessLabel = useMemo(() => { + if (!activeCloudOffer) { + return "未设定"; + } + + if (!activeCloudOffer.apiKeyModeEnabled) { + return "已关闭"; + } + + return activeCloudOffer.developerAccessVisible ? "可见" : "已隐藏"; + }, [activeCloudOffer]); + + return { + runtime, + configuredTarget, + hubProviderName, + loginMode, + setLoginMode, + passwordForm, + setPasswordForm, + emailCodeForm, + setEmailCodeForm, + codeDelivery, + session, + bootstrap, + offers, + preference, + defaultCloudOffer, + activeCloudOffer, + selectedOffer, + selectedModels, + initializing, + refreshing, + sendingCode, + loggingIn, + loggingOut, + openingGoogleLogin, + loadingDetail, + savingDefault, + errorMessage, + setErrorMessage, + infoMessage, + setInfoMessage, + defaultProviderSummary, + defaultProviderSourceLabel, + activeAccessModeLabel, + activeConfigModeLabel, + activeModelsSourceLabel, + activeDeveloperAccessEnabled, + activeDeveloperAccessLabel, + clearCloudState, + handleRefresh, + handleSendEmailCode, + handleEmailCodeLogin, + handlePasswordLogin, + handleGoogleLogin, + handleLogout, + openOfferDetail, + handleSetDefault, + openUserCenter, + }; +} diff --git a/src/hooks/useOemLimeHubProviderSync.test.tsx b/src/hooks/useOemLimeHubProviderSync.test.tsx new file mode 100644 index 000000000..2bdaf588b --- /dev/null +++ b/src/hooks/useOemLimeHubProviderSync.test.tsx @@ -0,0 +1,213 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearOemCloudBootstrapSnapshot, + clearStoredOemCloudSessionState, + setOemCloudBootstrapSnapshot, + setStoredOemCloudSessionState, +} from "@/lib/oemCloudSession"; + +const apiKeyProviderMocks = vi.hoisted(() => ({ + getProviders: vi.fn(), + updateProvider: vi.fn(), +})); + +const controlPlaneMocks = vi.hoisted(() => ({ + listClientProviderOfferModels: vi.fn(), +})); + +vi.mock("@/lib/api/apiKeyProvider", () => ({ + apiKeyProviderApi: { + getProviders: apiKeyProviderMocks.getProviders, + updateProvider: apiKeyProviderMocks.updateProvider, + }, +})); + +vi.mock("@/lib/api/oemCloudControlPlane", () => ({ + listClientProviderOfferModels: controlPlaneMocks.listClientProviderOfferModels, +})); + +import { useOemLimeHubProviderSync } from "./useOemLimeHubProviderSync"; + +interface MountedHarness { + container: HTMLDivElement; + root: Root; +} + +function HookHarness() { + useOemLimeHubProviderSync(); + return
; +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + await Promise.resolve(); + }); +} + +describe("useOemLimeHubProviderSync", () => { + let mountedHarness: MountedHarness | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { + IS_REACT_ACT_ENVIRONMENT?: boolean; + } + ).IS_REACT_ACT_ENVIRONMENT = true; + + window.localStorage.clear(); + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + gatewayBaseUrl: "https://gateway.150404.xyz/root", + tenantId: "tenant-0001", + hubProviderName: "Acme Hub", + }; + + apiKeyProviderMocks.getProviders.mockResolvedValue([ + { + id: "lime-hub", + name: "旧 Provider", + api_host: "https://old-gateway.example.com", + type: "anthropic", + enabled: false, + sort_order: 9, + custom_models: [], + api_keys: [], + }, + ]); + apiKeyProviderMocks.updateProvider.mockResolvedValue(undefined); + controlPlaneMocks.listClientProviderOfferModels.mockResolvedValue([ + { + id: "model-001", + modelId: "gpt-5.2-fast", + }, + { + id: "model-002", + modelId: "gpt-5.2-pro", + }, + ]); + }); + + afterEach(() => { + vi.clearAllMocks(); + clearStoredOemCloudSessionState(); + clearOemCloudBootstrapSnapshot(); + delete window.__LIME_OEM_CLOUD__; + + if (mountedHarness) { + act(() => { + mountedHarness?.root.unmount(); + }); + mountedHarness.container.remove(); + mountedHarness = null; + } + }); + + it("应把默认云端来源的模型目录同步到内部 lime-hub provider", async () => { + setStoredOemCloudSessionState({ + token: "session-token-001", + tenant: { + id: "tenant-0001", + }, + user: { + id: "user-001", + }, + session: { + id: "session-001", + }, + }); + setOemCloudBootstrapSnapshot({ + providerPreference: { + providerSource: "oem_cloud", + providerKey: "offer-main", + defaultModel: "gpt-5.2-pro", + }, + providerOffersSummary: [ + { + providerKey: "offer-main", + defaultModel: "gpt-5.2-pro", + }, + ], + }); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedHarness = { container, root }; + + act(() => { + root.render(); + }); + + await flushEffects(); + + expect(controlPlaneMocks.listClientProviderOfferModels).toHaveBeenCalledWith( + "tenant-0001", + "offer-main", + ); + expect(apiKeyProviderMocks.updateProvider).toHaveBeenCalledWith("lime-hub", { + name: "Acme Hub", + api_host: "https://gateway.150404.xyz/root", + type: "openai", + enabled: true, + sort_order: 0, + custom_models: ["gpt-5.2-pro", "gpt-5.2-fast"], + }); + }); + + it("bootstrap 切换到本地来源后应清空内部云端模型目录", async () => { + setStoredOemCloudSessionState({ + token: "session-token-001", + tenant: { + id: "tenant-0001", + }, + user: { + id: "user-001", + }, + session: { + id: "session-001", + }, + }); + setOemCloudBootstrapSnapshot({ + providerPreference: { + providerSource: "local", + providerKey: "openai", + }, + }); + + apiKeyProviderMocks.getProviders.mockResolvedValue([ + { + id: "lime-hub", + name: "Acme Hub", + api_host: "https://gateway.150404.xyz/root", + type: "openai", + enabled: true, + sort_order: 0, + custom_models: ["stale-model"], + api_keys: [], + }, + ]); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + mountedHarness = { container, root }; + + act(() => { + root.render(); + }); + + await flushEffects(); + + expect(controlPlaneMocks.listClientProviderOfferModels).not.toHaveBeenCalled(); + expect(apiKeyProviderMocks.updateProvider).toHaveBeenCalledWith("lime-hub", { + custom_models: [], + }); + }); +}); diff --git a/src/hooks/useOemLimeHubProviderSync.ts b/src/hooks/useOemLimeHubProviderSync.ts new file mode 100644 index 000000000..f7048629d --- /dev/null +++ b/src/hooks/useOemLimeHubProviderSync.ts @@ -0,0 +1,174 @@ +import { useEffect, useRef } from "react"; +import { apiKeyProviderApi, type UpdateProviderRequest } from "@/lib/api/apiKeyProvider"; +import { + type OemCloudBootstrapResponse, + listClientProviderOfferModels, +} from "@/lib/api/oemCloudControlPlane"; +import { resolveOemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; +import { + getOemCloudBootstrapSnapshot, + subscribeOemCloudBootstrapChanged, + subscribeOemCloudSessionChanged, +} from "@/lib/oemCloudSession"; +import { + buildOemLimeHubApiHost, + OEM_LIME_HUB_PROVIDER_ID, + resolveOemLimeHubProviderName, +} from "@/lib/oemLimeHubProvider"; + +function buildSyncSignature( + runtime: ReturnType, + customModels: string[], +): string { + return JSON.stringify({ + gatewayBaseUrl: runtime?.gatewayBaseUrl ?? null, + hubProviderName: runtime?.hubProviderName ?? null, + tenantId: runtime?.tenantId ?? null, + sessionToken: runtime?.sessionToken ?? null, + customModels, + }); +} + +function normalizeCustomModels( + modelIds: string[], + defaultModel?: string, +): string[] { + const normalized = [defaultModel ?? "", ...modelIds] + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean); + + return normalized.filter((value, index) => normalized.indexOf(value) === index); +} + +async function resolveSyncedCustomModels( + runtime: NonNullable>, +): Promise { + if (!runtime.sessionToken) { + return []; + } + + const snapshot = + getOemCloudBootstrapSnapshot(); + const preference = snapshot?.providerPreference; + if (preference?.providerSource !== "oem_cloud" || !preference.providerKey) { + return []; + } + + const matchedOffer = snapshot?.providerOffersSummary?.find( + (offer) => offer.providerKey === preference.providerKey, + ); + const models = await listClientProviderOfferModels( + runtime.tenantId, + preference.providerKey, + ); + + return normalizeCustomModels( + models.map((model) => model.modelId), + preference.defaultModel || matchedOffer?.defaultModel, + ); +} + +export function useOemLimeHubProviderSync() { + const runtime = resolveOemCloudRuntimeContext(); + const lastAppliedSignatureRef = useRef(""); + + useEffect(() => { + let disposed = false; + + async function syncProviders() { + try { + const providers = await apiKeyProviderApi.getProviders({ + forceRefresh: true, + }); + if (disposed) { + return; + } + + if (!runtime) { + lastAppliedSignatureRef.current = buildSyncSignature(null, []); + return; + } + + const limeHubProvider = providers.find( + (provider) => provider.id === OEM_LIME_HUB_PROVIDER_ID, + ); + if (!limeHubProvider) { + lastAppliedSignatureRef.current = buildSyncSignature(runtime, []); + return; + } + + const nextProviderName = resolveOemLimeHubProviderName(runtime); + const nextApiHost = buildOemLimeHubApiHost(runtime); + let nextCustomModels: string[] = []; + try { + nextCustomModels = await resolveSyncedCustomModels(runtime); + } catch (error) { + console.warn("[OEM] 同步云端模型目录失败,保留现有内部模型配置:", error); + } + if (disposed) { + return; + } + + const signature = buildSyncSignature(runtime, nextCustomModels); + if (lastAppliedSignatureRef.current === signature) { + return; + } + + const updateRequest: UpdateProviderRequest = {}; + + if (limeHubProvider.name !== nextProviderName) { + updateRequest.name = nextProviderName; + } + if (nextApiHost && limeHubProvider.api_host !== nextApiHost) { + updateRequest.api_host = nextApiHost; + } + if (limeHubProvider.type !== "openai") { + updateRequest.type = "openai"; + } + if (!limeHubProvider.enabled) { + updateRequest.enabled = true; + } + if (limeHubProvider.sort_order !== 0) { + updateRequest.sort_order = 0; + } + const currentCustomModels = Array.isArray(limeHubProvider.custom_models) + ? limeHubProvider.custom_models + : []; + const customModelsChanged = + currentCustomModels.length !== nextCustomModels.length || + currentCustomModels.some( + (modelId, index) => modelId !== nextCustomModels[index], + ); + if (customModelsChanged) { + updateRequest.custom_models = nextCustomModels; + } + + if (Object.keys(updateRequest).length > 0) { + await apiKeyProviderApi.updateProvider( + OEM_LIME_HUB_PROVIDER_ID, + updateRequest, + ); + } + + lastAppliedSignatureRef.current = signature; + } catch (error) { + console.warn("[OEM] 同步 Lime Hub Provider 失败:", error); + } + } + + void syncProviders(); + + const unsubscribeSession = subscribeOemCloudSessionChanged(() => { + void syncProviders(); + }); + const unsubscribeBootstrap = subscribeOemCloudBootstrapChanged(() => { + void syncProviders(); + }); + + return () => { + disposed = true; + unsubscribeSession(); + unsubscribeBootstrap(); + }; + }, [runtime]); +} diff --git a/src/hooks/useServiceSkillCatalogBootstrap.ts b/src/hooks/useServiceSkillCatalogBootstrap.ts new file mode 100644 index 000000000..93d085f1c --- /dev/null +++ b/src/hooks/useServiceSkillCatalogBootstrap.ts @@ -0,0 +1,12 @@ +import { useEffect } from "react"; +import { + applyInitialServiceSkillCatalogBootstrap, + subscribeServiceSkillCatalogBootstrap, +} from "@/lib/serviceSkillCatalogBootstrap"; + +export function useServiceSkillCatalogBootstrap(): void { + useEffect(() => { + applyInitialServiceSkillCatalogBootstrap(); + return subscribeServiceSkillCatalogBootstrap(); + }, []); +} diff --git a/src/lib/api/oemCloudControlPlane.test.ts b/src/lib/api/oemCloudControlPlane.test.ts new file mode 100644 index 000000000..5a3242fe4 --- /dev/null +++ b/src/lib/api/oemCloudControlPlane.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + createClientDesktopAuthSession, + getClientProviderOffer, + listClientProviderOffers, + pollClientDesktopAuthSession, +} from "./oemCloudControlPlane"; + +describe("oemCloudControlPlane desktop auth", () => { + beforeEach(() => { + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_SESSION_TOKEN__; + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + tenantId: "tenant-0001", + }; + }); + + afterEach(() => { + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("应创建桌面授权会话并返回浏览器授权地址", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 201, + json: async () => ({ + code: 201, + message: "success", + data: { + authSessionId: "desktop-auth-0001", + deviceCode: "device-code-001", + tenantId: "tenant-0001", + clientId: "desktop-client", + clientName: "Desktop Client", + provider: "google", + desktopRedirectUri: "lime://oauth/callback", + status: "pending_login", + expiresInSeconds: 600, + pollIntervalSeconds: 2, + authorizeUrl: + "https://user.150404.xyz/oauth/desktop/device-code-001/authorize?provider=google", + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await createClientDesktopAuthSession("tenant-0001", { + clientId: "desktop-client", + provider: "google", + desktopRedirectUri: "lime://oauth/callback", + }); + + expect(fetchMock).toHaveBeenCalledWith( + "https://user.150404.xyz/api/v1/public/tenants/tenant-0001/desktop/auth-sessions", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Accept: "application/json", + "Content-Type": "application/json", + }), + body: JSON.stringify({ + clientId: "desktop-client", + provider: "google", + desktopRedirectUri: "lime://oauth/callback", + }), + }), + ); + expect(result).toEqual({ + authSessionId: "desktop-auth-0001", + deviceCode: "device-code-001", + tenantId: "tenant-0001", + clientId: "desktop-client", + clientName: "Desktop Client", + provider: "google", + desktopRedirectUri: "lime://oauth/callback", + status: "pending_login", + expiresInSeconds: 600, + pollIntervalSeconds: 2, + authorizeUrl: + "https://user.150404.xyz/oauth/desktop/device-code-001/authorize?provider=google", + }); + }); + + it("应轮询桌面授权结果并解析 session token", async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ + code: 200, + message: "success", + data: { + deviceCode: "device-code-001", + tenantId: "tenant-0001", + clientId: "desktop-client", + clientName: "Desktop Client", + provider: "google", + status: "approved", + expiresInSeconds: 388, + pollIntervalSeconds: 2, + sessionToken: "session-token-001", + sessionExpiresAt: "2026-03-24T16:00:00.000Z", + }, + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const result = await pollClientDesktopAuthSession("device-code-001"); + + expect(fetchMock).toHaveBeenCalledWith( + "https://user.150404.xyz/api/v1/public/desktop/auth-sessions/device-code-001/poll", + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Accept: "application/json", + }), + }), + ); + expect(result).toEqual({ + deviceCode: "device-code-001", + tenantId: "tenant-0001", + clientId: "desktop-client", + clientName: "Desktop Client", + provider: "google", + desktopRedirectUri: undefined, + status: "approved", + expiresInSeconds: 388, + pollIntervalSeconds: 2, + sessionToken: "session-token-001", + sessionExpiresAt: "2026-03-24T16:00:00.000Z", + }); + }); + + it("应解析服务端下发的云端治理字段", async () => { + window.__LIME_SESSION_TOKEN__ = "session-token-001"; + + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + code: 200, + message: "success", + data: { + items: [ + { + providerKey: "lime-hub-main", + displayName: "Lime Hub 主服务", + source: "oem_cloud", + state: "available_ready", + visible: true, + loggedIn: true, + accountStatus: "logged_in", + subscriptionStatus: "active", + quotaStatus: "ok", + canInvoke: true, + defaultModel: "gpt-5.2-pro", + effectiveAccessMode: "session", + apiKeyModeEnabled: false, + tenantOverrideApplied: true, + configMode: "managed", + modelsSource: "hub_catalog", + developerAccessVisible: false, + availableModelCount: 18, + fallbackToLocalAllowed: true, + }, + ], + }, + }), + }) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ + code: 200, + message: "success", + data: { + providerKey: "lime-hub-main", + displayName: "Lime Hub 主服务", + source: "oem_cloud", + state: "available_ready", + visible: true, + loggedIn: true, + accountStatus: "logged_in", + subscriptionStatus: "active", + quotaStatus: "ok", + canInvoke: true, + defaultModel: "gpt-5.2-pro", + effectiveAccessMode: "session", + apiKeyModeEnabled: true, + tenantOverrideApplied: false, + configMode: "hybrid", + modelsSource: "manual", + developerAccessVisible: true, + availableModelCount: 6, + fallbackToLocalAllowed: true, + access: { + offerId: "offer-001", + accessMode: "api_key", + hubTokenEnabled: true, + hubTokenRef: "hub-token-ref", + }, + }, + }), + }); + vi.stubGlobal("fetch", fetchMock); + + const [offers, detail] = await Promise.all([ + listClientProviderOffers("tenant-0001"), + getClientProviderOffer("tenant-0001", "lime-hub-main"), + ]); + + expect(offers[0]).toMatchObject({ + effectiveAccessMode: "session", + apiKeyModeEnabled: false, + tenantOverrideApplied: true, + configMode: "managed", + modelsSource: "hub_catalog", + developerAccessVisible: false, + }); + expect(detail).toMatchObject({ + effectiveAccessMode: "session", + apiKeyModeEnabled: true, + configMode: "hybrid", + modelsSource: "manual", + developerAccessVisible: true, + access: { + offerId: "offer-001", + accessMode: "api_key", + hubTokenEnabled: true, + hubTokenRef: "hub-token-ref", + }, + }); + }); +}); diff --git a/src/lib/api/oemCloudControlPlane.ts b/src/lib/api/oemCloudControlPlane.ts new file mode 100644 index 000000000..74d7629ae --- /dev/null +++ b/src/lib/api/oemCloudControlPlane.ts @@ -0,0 +1,1007 @@ +import { resolveOemCloudRuntimeContext } from "./oemCloudRuntime"; +import type { OemCloudCurrentSessionLike } from "@/lib/oemCloudSession"; + +export type OemCloudProviderSource = "local" | "oem_cloud"; +export type OemCloudProviderOfferState = + | "available_logged_out" + | "available_subscribe_required" + | "available_ready" + | "available_quota_low" + | "blocked" + | "unavailable"; +export type OemCloudPartnerHubAccessMode = "session" | "hub_token" | "api_key"; +export type OemCloudPartnerHubConfigMode = "managed" | "hybrid" | "developer"; +export type OemCloudPartnerHubModelsSource = "hub_catalog" | "manual"; + +export interface OemCloudTenant { + id: string; + name: string; + slug: string; +} + +export interface OemCloudUser { + id: string; + email: string; + displayName: string; + avatarUrl?: string; + username?: string; + passwordConfigured: boolean; + roles: string[]; +} + +export interface OemCloudUserSession { + id: string; + tenantId: string; + userId: string; + provider: string; + roles: string[]; + issuedAt: string; + expiresAt: string; +} + +export interface OemCloudCurrentSession + extends Omit { + token?: string; + tenant: OemCloudTenant; + user: OemCloudUser; + session: OemCloudUserSession; +} + +export interface OemCloudFeatureFlags { + oauthLoginEnabled: boolean; + emailCodeLoginEnabled: boolean; + passwordLoginEnabled: boolean; + profileEditable: boolean; + hubTokensEnabled: boolean; + billingEnabled: boolean; + referralEnabled: boolean; + gatewayEnabled: boolean; +} + +export interface OemCloudProviderPreference { + tenantId: string; + userId: string; + providerSource: OemCloudProviderSource; + providerKey: string; + defaultModel?: string; + needsValidation: boolean; + lastValidatedAt?: string; + updatedAt: string; +} + +export interface OemCloudProviderOfferSummary { + providerKey: string; + displayName: string; + source: OemCloudProviderSource; + state: OemCloudProviderOfferState; + logoUrl?: string; + description?: string; + supportUrl?: string; + visible: boolean; + loggedIn: boolean; + accountStatus: string; + subscriptionStatus: string; + quotaStatus: string; + canInvoke: boolean; + defaultModel?: string; + effectiveAccessMode: OemCloudPartnerHubAccessMode; + apiKeyModeEnabled: boolean; + tenantOverrideApplied: boolean; + configMode: OemCloudPartnerHubConfigMode; + modelsSource: OemCloudPartnerHubModelsSource; + developerAccessVisible: boolean; + availableModelCount: number; + fallbackToLocalAllowed: boolean; + currentPlan?: string; + creditsSummary?: string; + statusReason?: string; + tags?: string[]; +} + +export interface OemCloudProviderOfferAccess { + offerId: string; + accessMode: OemCloudPartnerHubAccessMode; + sessionTokenRef?: string; + hubTokenRef?: string; + hubTokenEnabled: boolean; + lastIssuedAt?: string; +} + +export interface OemCloudProviderOfferDetail + extends OemCloudProviderOfferSummary { + loginHint?: string; + subscribeHint?: string; + unavailableHint?: string; + access: OemCloudProviderOfferAccess; +} + +export interface OemCloudProviderModelItem { + id: string; + offerId: string; + modelId: string; + displayName: string; + description?: string; + abilities: string[]; + recommended: boolean; + status: string; + sort: number; + upstreamMapping?: string; + createdAt: string; + updatedAt: string; +} + +export interface OemCloudBootstrapResponse { + session: OemCloudCurrentSession; + app: { + id: string; + key: string; + name: string; + slug: string; + category: string; + description?: string; + status: string; + distributionChannels: string[]; + }; + providerOffersSummary: OemCloudProviderOfferSummary[]; + providerPreference: OemCloudProviderPreference; + serviceSkillCatalog?: unknown; + sceneCatalog?: Array<{ id: string }>; + features: OemCloudFeatureFlags; + gateway?: { + basePath?: string; + chatCompletionsPath?: string; + }; +} + +export interface SendClientAuthEmailCodePayload { + identifier: string; +} + +export interface SendAuthEmailCodeResponse { + sent: boolean; + maskedEmail: string; + expiresInSeconds: number; +} + +export interface VerifyClientAuthEmailCodePayload { + identifier: string; + code: string; + displayName?: string; + username?: string; +} + +export interface ClientPasswordLoginPayload { + identifier: string; + password: string; +} + +export type OemCloudDesktopAuthSessionStatus = + | "pending_login" + | "pending_consent" + | "approved" + | "denied" + | "cancelled" + | "consumed" + | "expired"; + +export interface CreateClientDesktopAuthSessionPayload { + clientId: string; + provider?: string; + desktopRedirectUri?: string; +} + +export interface OemCloudDesktopAuthSessionStartResponse { + authSessionId: string; + deviceCode: string; + tenantId: string; + clientId: string; + clientName: string; + provider?: string; + desktopRedirectUri?: string; + status: OemCloudDesktopAuthSessionStatus; + expiresInSeconds: number; + pollIntervalSeconds: number; + authorizeUrl: string; +} + +export interface OemCloudDesktopAuthSessionStatusResponse { + deviceCode: string; + tenantId: string; + clientId: string; + clientName: string; + provider?: string; + desktopRedirectUri?: string; + status: OemCloudDesktopAuthSessionStatus; + expiresInSeconds: number; + pollIntervalSeconds: number; + sessionToken?: string; + sessionExpiresAt?: string; +} + +export interface UpdateClientProviderPreferencePayload { + providerSource: OemCloudProviderSource; + providerKey: string; + defaultModel?: string; +} + +interface OemCloudEnvelope { + code?: number; + message?: string; + data?: T; +} + +export class OemCloudControlPlaneError extends Error { + status: number; + code?: number; + + constructor(message: string, options?: { status?: number; code?: number }) { + super(message); + this.name = "OemCloudControlPlaneError"; + this.status = options?.status ?? 0; + this.code = options?.code; + } +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function normalizeText(value: unknown): string | undefined { + if (typeof value !== "string") { + return undefined; + } + + const normalized = value.trim(); + return normalized ? normalized : undefined; +} + +function normalizeStringArray(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + + return value + .map((item) => normalizeText(item)) + .filter((item): item is string => Boolean(item)); +} + +function normalizeBoolean(value: unknown, fallback = false): boolean { + return typeof value === "boolean" ? value : fallback; +} + +function normalizeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +const PARTNER_HUB_ACCESS_MODE_SET = new Set([ + "session", + "hub_token", + "api_key", +]); + +const PARTNER_HUB_CONFIG_MODE_SET = new Set([ + "managed", + "hybrid", + "developer", +]); + +const PARTNER_HUB_MODELS_SOURCE_SET = + new Set(["hub_catalog", "manual"]); + +function parsePartnerHubAccessMode( + value: unknown, + fallback?: OemCloudPartnerHubAccessMode, +): OemCloudPartnerHubAccessMode { + const accessMode = normalizeText(value) as + | OemCloudPartnerHubAccessMode + | undefined; + if (accessMode && PARTNER_HUB_ACCESS_MODE_SET.has(accessMode)) { + return accessMode; + } + + if (fallback) { + return fallback; + } + + throw new OemCloudControlPlaneError("服务商接入模式格式非法"); +} + +function parsePartnerHubConfigMode( + value: unknown, + fallback?: OemCloudPartnerHubConfigMode, +): OemCloudPartnerHubConfigMode { + const configMode = normalizeText(value) as + | OemCloudPartnerHubConfigMode + | undefined; + if (configMode && PARTNER_HUB_CONFIG_MODE_SET.has(configMode)) { + return configMode; + } + + if (fallback) { + return fallback; + } + + throw new OemCloudControlPlaneError("服务商配置模式格式非法"); +} + +function parsePartnerHubModelsSource( + value: unknown, + fallback?: OemCloudPartnerHubModelsSource, +): OemCloudPartnerHubModelsSource { + const modelsSource = normalizeText(value) as + | OemCloudPartnerHubModelsSource + | undefined; + if (modelsSource && PARTNER_HUB_MODELS_SOURCE_SET.has(modelsSource)) { + return modelsSource; + } + + if (fallback) { + return fallback; + } + + throw new OemCloudControlPlaneError("服务商模型来源格式非法"); +} + +function unwrapEnvelope(payload: unknown): { + data: T | undefined; + message: string; + code: number | undefined; +} { + if (isRecord(payload)) { + const code = + typeof payload.code === "number" ? payload.code : undefined; + const message = normalizeText(payload.message) ?? ""; + const data = payload.data as T | undefined; + return { + data, + message, + code, + }; + } + + return { + data: payload as T, + message: "", + code: undefined, + }; +} + +function ensureRuntime() { + const runtime = resolveOemCloudRuntimeContext(); + if (!runtime) { + throw new OemCloudControlPlaneError( + "缺少 OEM 云端配置,请先配置域名与租户。", + ); + } + return runtime; +} + +async function requestControlPlane( + path: string, + options?: { + method?: "GET" | "POST" | "PUT"; + payload?: unknown; + auth?: boolean; + }, +): Promise { + const runtime = ensureRuntime(); + const method = options?.method ?? "GET"; + const headers: Record = { + Accept: "application/json", + }; + + if (options?.payload !== undefined) { + headers["Content-Type"] = "application/json"; + } + + if (options?.auth) { + const token = normalizeText(runtime.sessionToken); + if (!token) { + throw new OemCloudControlPlaneError( + "缺少 OEM 云端 Session Token,请先完成登录。", + ); + } + headers.Authorization = `Bearer ${token}`; + } + + const response = await fetch(`${runtime.controlPlaneBaseUrl}${path}`, { + method, + headers, + body: + options?.payload === undefined + ? undefined + : JSON.stringify(options.payload), + }); + + let payload: OemCloudEnvelope | unknown = null; + try { + payload = (await response.json()) as OemCloudEnvelope; + } catch { + payload = null; + } + + const { data, message, code } = unwrapEnvelope(payload); + if (!response.ok) { + throw new OemCloudControlPlaneError( + message || `请求失败 (${response.status})`, + { + status: response.status, + code, + }, + ); + } + + if (data === undefined) { + throw new OemCloudControlPlaneError(message || "服务端返回格式非法", { + status: response.status, + code, + }); + } + + return data; +} + +function parseCurrentSession(value: unknown): OemCloudCurrentSession { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("当前会话格式非法"); + } + + const tenant = isRecord(value.tenant) ? value.tenant : null; + const user = isRecord(value.user) ? value.user : null; + const session = isRecord(value.session) ? value.session : null; + const tenantId = normalizeText(tenant?.id); + const userId = normalizeText(user?.id); + const sessionId = normalizeText(session?.id); + + if (!tenantId || !userId || !sessionId) { + throw new OemCloudControlPlaneError("当前会话格式非法"); + } + + return { + token: normalizeText(value.token), + tenant: { + id: tenantId, + name: normalizeText(tenant?.name) ?? tenantId, + slug: normalizeText(tenant?.slug) ?? tenantId, + }, + user: { + id: userId, + email: normalizeText(user?.email) ?? "", + displayName: normalizeText(user?.displayName) ?? userId, + avatarUrl: normalizeText(user?.avatarUrl), + username: normalizeText(user?.username), + passwordConfigured: normalizeBoolean(user?.passwordConfigured), + roles: normalizeStringArray(user?.roles), + }, + session: { + id: sessionId, + tenantId: normalizeText(session?.tenantId) ?? tenantId, + userId: normalizeText(session?.userId) ?? userId, + provider: normalizeText(session?.provider) ?? "password", + roles: normalizeStringArray(session?.roles), + issuedAt: normalizeText(session?.issuedAt) ?? "", + expiresAt: normalizeText(session?.expiresAt) ?? "", + }, + }; +} + +function parseProviderOfferSummary( + value: unknown, +): OemCloudProviderOfferSummary { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("服务商摘要格式非法"); + } + + const providerKey = normalizeText(value.providerKey); + const displayName = normalizeText(value.displayName); + const source = normalizeText(value.source) as OemCloudProviderSource | undefined; + const state = normalizeText( + value.state, + ) as OemCloudProviderOfferState | undefined; + + if (!providerKey || !displayName || !source || !state) { + throw new OemCloudControlPlaneError("服务商摘要格式非法"); + } + + return { + providerKey, + displayName, + source, + state, + logoUrl: normalizeText(value.logoUrl), + description: normalizeText(value.description), + supportUrl: normalizeText(value.supportUrl), + visible: normalizeBoolean(value.visible), + loggedIn: normalizeBoolean(value.loggedIn), + accountStatus: normalizeText(value.accountStatus) ?? "anonymous", + subscriptionStatus: normalizeText(value.subscriptionStatus) ?? "none", + quotaStatus: normalizeText(value.quotaStatus) ?? "ok", + canInvoke: normalizeBoolean(value.canInvoke), + defaultModel: normalizeText(value.defaultModel), + effectiveAccessMode: parsePartnerHubAccessMode( + value.effectiveAccessMode, + "session", + ), + apiKeyModeEnabled: normalizeBoolean(value.apiKeyModeEnabled), + tenantOverrideApplied: normalizeBoolean(value.tenantOverrideApplied), + configMode: parsePartnerHubConfigMode(value.configMode, "managed"), + modelsSource: parsePartnerHubModelsSource( + value.modelsSource, + "hub_catalog", + ), + developerAccessVisible: normalizeBoolean(value.developerAccessVisible), + availableModelCount: + typeof value.availableModelCount === "number" + ? value.availableModelCount + : 0, + fallbackToLocalAllowed: normalizeBoolean(value.fallbackToLocalAllowed), + currentPlan: normalizeText(value.currentPlan), + creditsSummary: normalizeText(value.creditsSummary), + statusReason: normalizeText(value.statusReason), + tags: normalizeStringArray(value.tags), + }; +} + +function parseProviderOfferDetail(value: unknown): OemCloudProviderOfferDetail { + const summary = parseProviderOfferSummary(value); + const access = isRecord((value as Record).access) + ? ((value as Record).access as Record) + : null; + if (!access) { + throw new OemCloudControlPlaneError("服务商详情格式非法"); + } + + const offerId = normalizeText(access.offerId); + if (!offerId) { + throw new OemCloudControlPlaneError("服务商详情格式非法"); + } + + return { + ...summary, + loginHint: normalizeText((value as Record).loginHint), + subscribeHint: normalizeText( + (value as Record).subscribeHint, + ), + unavailableHint: normalizeText( + (value as Record).unavailableHint, + ), + access: { + offerId, + accessMode: parsePartnerHubAccessMode( + access.accessMode, + summary.effectiveAccessMode, + ), + sessionTokenRef: normalizeText(access.sessionTokenRef), + hubTokenRef: normalizeText(access.hubTokenRef), + hubTokenEnabled: normalizeBoolean(access.hubTokenEnabled), + lastIssuedAt: normalizeText(access.lastIssuedAt), + }, + }; +} + +function parseProviderModelItem(value: unknown): OemCloudProviderModelItem { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("服务商模型格式非法"); + } + + const id = normalizeText(value.id); + const offerId = normalizeText(value.offerId); + const modelId = normalizeText(value.modelId); + const displayName = normalizeText(value.displayName); + if (!id || !offerId || !modelId || !displayName) { + throw new OemCloudControlPlaneError("服务商模型格式非法"); + } + + return { + id, + offerId, + modelId, + displayName, + description: normalizeText(value.description), + abilities: normalizeStringArray(value.abilities), + recommended: normalizeBoolean(value.recommended), + status: normalizeText(value.status) ?? "active", + sort: typeof value.sort === "number" ? value.sort : 0, + upstreamMapping: normalizeText(value.upstreamMapping), + createdAt: normalizeText(value.createdAt) ?? "", + updatedAt: normalizeText(value.updatedAt) ?? "", + }; +} + +function parseProviderPreference(value: unknown): OemCloudProviderPreference { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("默认服务商配置格式非法"); + } + + const tenantId = normalizeText(value.tenantId); + const userId = normalizeText(value.userId); + const providerSource = normalizeText( + value.providerSource, + ) as OemCloudProviderSource | undefined; + const providerKey = normalizeText(value.providerKey); + if (!tenantId || !userId || !providerSource || !providerKey) { + throw new OemCloudControlPlaneError("默认服务商配置格式非法"); + } + + return { + tenantId, + userId, + providerSource, + providerKey, + defaultModel: normalizeText(value.defaultModel), + needsValidation: normalizeBoolean(value.needsValidation), + lastValidatedAt: normalizeText(value.lastValidatedAt), + updatedAt: normalizeText(value.updatedAt) ?? "", + }; +} + +function parseFeatureFlags(value: unknown): OemCloudFeatureFlags { + const record = isRecord(value) ? value : {}; + return { + oauthLoginEnabled: normalizeBoolean(record.oauthLoginEnabled), + emailCodeLoginEnabled: normalizeBoolean(record.emailCodeLoginEnabled), + passwordLoginEnabled: normalizeBoolean(record.passwordLoginEnabled, true), + profileEditable: normalizeBoolean(record.profileEditable), + hubTokensEnabled: normalizeBoolean(record.hubTokensEnabled), + billingEnabled: normalizeBoolean(record.billingEnabled), + referralEnabled: normalizeBoolean(record.referralEnabled), + gatewayEnabled: normalizeBoolean(record.gatewayEnabled), + }; +} + +const DESKTOP_AUTH_SESSION_STATUS_SET = new Set([ + "pending_login", + "pending_consent", + "approved", + "denied", + "cancelled", + "consumed", + "expired", +]); + +function parseDesktopAuthSessionStatus( + value: unknown, +): OemCloudDesktopAuthSessionStatus { + const status = normalizeText(value) as + | OemCloudDesktopAuthSessionStatus + | undefined; + if (!status || !DESKTOP_AUTH_SESSION_STATUS_SET.has(status)) { + throw new OemCloudControlPlaneError("桌面授权状态格式非法"); + } + + return status; +} + +function parseDesktopAuthDuration(value: unknown, fieldName: string): number { + const duration = normalizeNumber(value); + if (duration === undefined) { + throw new OemCloudControlPlaneError(`${fieldName} 格式非法`); + } + + return duration; +} + +function parseDesktopAuthSessionStartResponse( + value: unknown, +): OemCloudDesktopAuthSessionStartResponse { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("桌面授权会话创建结果格式非法"); + } + + const authSessionId = normalizeText(value.authSessionId); + const deviceCode = normalizeText(value.deviceCode); + const tenantId = normalizeText(value.tenantId); + const clientId = normalizeText(value.clientId); + const clientName = normalizeText(value.clientName); + const authorizeUrl = normalizeText(value.authorizeUrl); + + if ( + !authSessionId || + !deviceCode || + !tenantId || + !clientId || + !clientName || + !authorizeUrl + ) { + throw new OemCloudControlPlaneError("桌面授权会话创建结果格式非法"); + } + + return { + authSessionId, + deviceCode, + tenantId, + clientId, + clientName, + provider: normalizeText(value.provider), + desktopRedirectUri: normalizeText(value.desktopRedirectUri), + status: parseDesktopAuthSessionStatus(value.status), + expiresInSeconds: parseDesktopAuthDuration( + value.expiresInSeconds, + "桌面授权会话过期时间", + ), + pollIntervalSeconds: parseDesktopAuthDuration( + value.pollIntervalSeconds, + "桌面授权轮询间隔", + ), + authorizeUrl, + }; +} + +function parseDesktopAuthSessionStatusResponse( + value: unknown, +): OemCloudDesktopAuthSessionStatusResponse { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("桌面授权状态格式非法"); + } + + const deviceCode = normalizeText(value.deviceCode); + const tenantId = normalizeText(value.tenantId); + const clientId = normalizeText(value.clientId); + const clientName = normalizeText(value.clientName); + + if (!deviceCode || !tenantId || !clientId || !clientName) { + throw new OemCloudControlPlaneError("桌面授权状态格式非法"); + } + + return { + deviceCode, + tenantId, + clientId, + clientName, + provider: normalizeText(value.provider), + desktopRedirectUri: normalizeText(value.desktopRedirectUri), + status: parseDesktopAuthSessionStatus(value.status), + expiresInSeconds: parseDesktopAuthDuration( + value.expiresInSeconds, + "桌面授权状态过期时间", + ), + pollIntervalSeconds: parseDesktopAuthDuration( + value.pollIntervalSeconds, + "桌面授权轮询间隔", + ), + sessionToken: normalizeText(value.sessionToken), + sessionExpiresAt: normalizeText(value.sessionExpiresAt), + }; +} + +function parseBootstrap(value: unknown): OemCloudBootstrapResponse { + if (!isRecord(value)) { + throw new OemCloudControlPlaneError("bootstrap 格式非法"); + } + + return { + session: parseCurrentSession(value.session), + app: { + id: normalizeText(value.app && isRecord(value.app) ? value.app.id : "") ?? "", + key: + normalizeText(value.app && isRecord(value.app) ? value.app.key : "") ?? "", + name: + normalizeText(value.app && isRecord(value.app) ? value.app.name : "") ?? + "", + slug: + normalizeText(value.app && isRecord(value.app) ? value.app.slug : "") ?? + "", + category: + normalizeText( + value.app && isRecord(value.app) ? value.app.category : "", + ) ?? "", + description: + normalizeText( + value.app && isRecord(value.app) ? value.app.description : "", + ) ?? undefined, + status: + normalizeText(value.app && isRecord(value.app) ? value.app.status : "") ?? + "", + distributionChannels: normalizeStringArray( + value.app && isRecord(value.app) ? value.app.distributionChannels : [], + ), + }, + providerOffersSummary: Array.isArray(value.providerOffersSummary) + ? value.providerOffersSummary.map(parseProviderOfferSummary) + : [], + providerPreference: parseProviderPreference(value.providerPreference), + serviceSkillCatalog: value.serviceSkillCatalog, + sceneCatalog: Array.isArray(value.sceneCatalog) + ? value.sceneCatalog + .filter((item) => isRecord(item) && normalizeText(item.id)) + .map((item) => ({ + id: normalizeText((item as Record).id) ?? "", + })) + : [], + features: parseFeatureFlags(value.features), + gateway: + value.gateway && isRecord(value.gateway) + ? { + basePath: normalizeText(value.gateway.basePath), + chatCompletionsPath: normalizeText(value.gateway.chatCompletionsPath), + } + : undefined, + }; +} + +export function getConfiguredOemCloudTarget() { + const runtime = ensureRuntime(); + return { + baseUrl: runtime.baseUrl, + tenantId: runtime.tenantId, + }; +} + +export async function sendClientAuthEmailCode( + tenantId: string, + payload: SendClientAuthEmailCodePayload, +): Promise { + return requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/auth/email-code/send`, + { + method: "POST", + payload, + }, + ); +} + +export async function createClientDesktopAuthSession( + tenantId: string, + payload: CreateClientDesktopAuthSessionPayload, +): Promise { + return parseDesktopAuthSessionStartResponse( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/desktop/auth-sessions`, + { + method: "POST", + payload, + }, + ), + ); +} + +export async function pollClientDesktopAuthSession( + deviceCode: string, +): Promise { + return parseDesktopAuthSessionStatusResponse( + await requestControlPlane( + `/v1/public/desktop/auth-sessions/${encodeURIComponent(deviceCode)}/poll`, + { + method: "POST", + }, + ), + ); +} + +export async function verifyClientAuthEmailCode( + tenantId: string, + payload: VerifyClientAuthEmailCodePayload, +): Promise { + return parseCurrentSession( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/auth/email-code/verify`, + { + method: "POST", + payload, + }, + ), + ); +} + +export async function loginClientByPassword( + tenantId: string, + payload: ClientPasswordLoginPayload, +): Promise { + return parseCurrentSession( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/auth/password/login`, + { + method: "POST", + payload, + }, + ), + ); +} + +export async function getClientBootstrap( + tenantId: string, +): Promise { + return parseBootstrap( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/bootstrap`, + { + auth: true, + }, + ), + ); +} + +export async function getClientSession( + tenantId: string, +): Promise { + return parseCurrentSession( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/session`, + { + auth: true, + }, + ), + ); +} + +export async function listClientProviderOffers( + tenantId: string, +): Promise { + const payload = await requestControlPlane<{ items?: unknown[] }>( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/provider-offers`, + { + auth: true, + }, + ); + return Array.isArray(payload.items) + ? payload.items.map(parseProviderOfferSummary) + : []; +} + +export async function getClientProviderOffer( + tenantId: string, + providerKey: string, +): Promise { + return parseProviderOfferDetail( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/provider-offers/${encodeURIComponent(providerKey)}`, + { + auth: true, + }, + ), + ); +} + +export async function listClientProviderOfferModels( + tenantId: string, + providerKey: string, +): Promise { + const payload = await requestControlPlane<{ items?: unknown[] }>( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/provider-offers/${encodeURIComponent(providerKey)}/models`, + { + auth: true, + }, + ); + return Array.isArray(payload.items) + ? payload.items.map(parseProviderModelItem) + : []; +} + +export async function getClientProviderPreference( + tenantId: string, +): Promise { + return parseProviderPreference( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/provider-preferences`, + { + auth: true, + }, + ), + ); +} + +export async function updateClientProviderPreference( + tenantId: string, + payload: UpdateClientProviderPreferencePayload, +): Promise { + return parseProviderPreference( + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/provider-preferences`, + { + method: "PUT", + payload, + auth: true, + }, + ), + ); +} + +export async function logoutClient(tenantId: string): Promise { + await requestControlPlane( + `/v1/public/tenants/${encodeURIComponent(tenantId)}/client/logout`, + { + method: "POST", + auth: true, + }, + ); +} diff --git a/src/lib/api/oemCloudRuntime.test.ts b/src/lib/api/oemCloudRuntime.test.ts new file mode 100644 index 000000000..8f8c006e0 --- /dev/null +++ b/src/lib/api/oemCloudRuntime.test.ts @@ -0,0 +1,98 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { setStoredOemCloudSessionState } from "@/lib/oemCloudSession"; +import { resolveOemCloudRuntimeContext } from "./oemCloudRuntime"; + +describe("oemCloudRuntime", () => { + beforeEach(() => { + window.localStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + }); + + it("应优先从运行时配置解析基础地址与 Lime Hub 元信息", () => { + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz/", + gatewayBaseUrl: "https://gateway.150404.xyz/root/", + tenantId: "tenant-0001", + hubProviderName: "Acme Hub", + sessionToken: "runtime-session-token", + loginPath: "/login", + desktopClientId: "limehub-desktop", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }; + + expect(resolveOemCloudRuntimeContext()).toEqual({ + baseUrl: "https://user.150404.xyz", + controlPlaneBaseUrl: "https://user.150404.xyz/api", + sceneBaseUrl: "https://user.150404.xyz/scene-api", + gatewayBaseUrl: "https://gateway.150404.xyz/root", + tenantId: "tenant-0001", + sessionToken: "runtime-session-token", + hubProviderName: "Acme Hub", + loginPath: "/login", + desktopClientId: "limehub-desktop", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }); + }); + + it("未显式提供 gatewayBaseUrl 时应回退到 baseUrl/gateway-api", () => { + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + tenantId: "tenant-0001", + }; + + expect(resolveOemCloudRuntimeContext()).toMatchObject({ + baseUrl: "https://user.150404.xyz", + gatewayBaseUrl: "https://user.150404.xyz/gateway-api", + tenantId: "tenant-0001", + hubProviderName: null, + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }); + }); + + it("运行时缺租户时应回退复用本地持久化会话", () => { + setStoredOemCloudSessionState({ + token: "persisted-session-token", + tenant: { + id: "tenant-from-storage", + }, + user: { + id: "user-001", + }, + session: { + id: "session-001", + }, + }); + + window.__LIME_OEM_CLOUD__ = { + enabled: true, + baseUrl: "https://user.150404.xyz", + }; + + expect(resolveOemCloudRuntimeContext()).toMatchObject({ + baseUrl: "https://user.150404.xyz", + gatewayBaseUrl: "https://user.150404.xyz/gateway-api", + tenantId: "tenant-from-storage", + sessionToken: "persisted-session-token", + loginPath: "/login", + desktopClientId: "desktop-client", + desktopOauthRedirectUrl: "lime://oauth/callback", + desktopOauthNextPath: "/welcome", + }); + }); +}); diff --git a/src/lib/api/oemCloudRuntime.ts b/src/lib/api/oemCloudRuntime.ts new file mode 100644 index 000000000..412ac15ef --- /dev/null +++ b/src/lib/api/oemCloudRuntime.ts @@ -0,0 +1,365 @@ +import { + getStoredOemCloudAccessToken, + getStoredOemCloudTenantId, +} from "@/lib/oemCloudSession"; + +export const DEFAULT_OEM_CLOUD_LOGIN_PATH = "/login"; +export const DEFAULT_OEM_CLOUD_DESKTOP_CLIENT_ID = "desktop-client"; +export const DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_REDIRECT_URL = + "lime://oauth/callback"; +export const DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH = "/welcome"; + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeBaseUrl(value: unknown): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + + return normalized.replace(/\/+$/, ""); +} + +function normalizeAppPath(value: unknown): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + + if (normalized === "/") { + return normalized; + } + + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function readRecordText( + record: Record, + ...keys: string[] +): string | null { + for (const key of keys) { + const value = normalizeText(record[key]); + if (value) { + return value; + } + } + + return null; +} + +function extractTenantIdFromPayload(payload: unknown): string | null { + if (!isRecord(payload)) { + return null; + } + + const directTenantId = readRecordText(payload, "tenantId", "tenant_id"); + if (directTenantId) { + return directTenantId; + } + + const tenant = payload.tenant; + if (isRecord(tenant)) { + const nestedTenantId = readRecordText(tenant, "id", "tenantId", "tenant_id"); + if (nestedTenantId) { + return nestedTenantId; + } + } + + if (payload.bootstrap) { + return extractTenantIdFromPayload(payload.bootstrap); + } + + return null; +} + +function extractSessionTokenFromPayload(payload: unknown): string | null { + if (!isRecord(payload)) { + return null; + } + + const directToken = readRecordText( + payload, + "sessionToken", + "session_token", + "authToken", + "auth_token", + "token", + ); + if (directToken) { + return directToken; + } + + const session = payload.session; + if (isRecord(session)) { + const sessionToken = readRecordText( + session, + "token", + "sessionToken", + "session_token", + ); + if (sessionToken) { + return sessionToken; + } + } + + if (payload.bootstrap) { + return extractSessionTokenFromPayload(payload.bootstrap); + } + + return null; +} + +interface OemCloudRuntimeOverride { + enabled?: boolean; + baseUrl: string | null; + gatewayBaseUrl: string | null; + tenantId: string | null; + sessionToken: string | null; + hubProviderName: string | null; + loginPath: string | null; + desktopClientId: string | null; + desktopOauthRedirectUrl: string | null; + desktopOauthNextPath: string | null; +} + +function parseOemCloudRuntimeOverride(payload: unknown): OemCloudRuntimeOverride { + if (!isRecord(payload)) { + return { + baseUrl: null, + gatewayBaseUrl: null, + tenantId: null, + sessionToken: null, + hubProviderName: null, + loginPath: null, + desktopClientId: null, + desktopOauthRedirectUrl: null, + desktopOauthNextPath: null, + }; + } + + const baseUrl = normalizeBaseUrl( + payload.baseUrl ?? payload.base_url ?? payload.origin, + ); + const gatewayBaseUrl = normalizeBaseUrl( + payload.gatewayBaseUrl ?? + payload.gateway_base_url ?? + payload.gatewayUrl ?? + payload.gateway_url, + ); + const tenantId = + readRecordText(payload, "tenantId", "tenant_id") ?? + extractTenantIdFromPayload(payload); + const sessionToken = + readRecordText(payload, "sessionToken", "session_token") ?? + extractSessionTokenFromPayload(payload); + const hubProviderName = readRecordText( + payload, + "hubProviderName", + "hub_provider_name", + "limeHubProviderName", + "lime_hub_provider_name", + ); + const loginPath = normalizeAppPath( + payload.loginPath ?? + payload.login_path ?? + payload.userCenterLoginPath ?? + payload.user_center_login_path, + ); + const desktopClientId = readRecordText( + payload, + "desktopClientId", + "desktop_client_id", + ); + const desktopOauthRedirectUrl = readRecordText( + payload, + "desktopOauthRedirectUrl", + "desktop_oauth_redirect_url", + "desktopOAuthRedirectUrl", + "desktopRedirectUri", + "desktop_redirect_uri", + ); + const desktopOauthNextPath = normalizeAppPath( + payload.desktopOauthNextPath ?? + payload.desktop_oauth_next_path ?? + payload.desktopOAuthNextPath, + ); + + return { + enabled: typeof payload.enabled === "boolean" ? payload.enabled : undefined, + baseUrl, + gatewayBaseUrl, + tenantId, + sessionToken, + hubProviderName, + loginPath, + desktopClientId, + desktopOauthRedirectUrl, + desktopOauthNextPath, + }; +} + +function readEnvValue(name: keyof ImportMetaEnv): string | null { + return normalizeText(import.meta.env[name]); +} + +export interface OemCloudRuntimeContext { + baseUrl: string; + controlPlaneBaseUrl: string; + sceneBaseUrl: string; + gatewayBaseUrl: string; + tenantId: string; + sessionToken: string | null; + hubProviderName: string | null; + loginPath: string; + desktopClientId: string; + desktopOauthRedirectUrl: string; + desktopOauthNextPath: string; +} + +declare global { + interface Window { + __LIME_BOOTSTRAP__?: unknown; + __LIME_OEM_CLOUD__?: unknown; + __LIME_SESSION_TOKEN__?: unknown; + } +} + +export function resolveOemCloudRuntimeContext(): OemCloudRuntimeContext | null { + const envBaseUrl = normalizeBaseUrl(readEnvValue("VITE_OEM_CLOUD_BASE_URL")); + const envGatewayBaseUrl = normalizeBaseUrl( + readEnvValue("VITE_OEM_GATEWAY_BASE_URL"), + ); + const envTenantId = readEnvValue("VITE_OEM_TENANT_ID"); + const envSessionToken = readEnvValue("VITE_OEM_SESSION_TOKEN"); + const envEnabled = readEnvValue("VITE_OEM_CLOUD_ENABLED"); + const envHubProviderName = readEnvValue("VITE_OEM_HUB_PROVIDER_NAME"); + const envLoginPath = normalizeAppPath( + readEnvValue("VITE_OEM_USER_CENTER_LOGIN_PATH"), + ); + const envDesktopClientId = readEnvValue("VITE_OEM_DESKTOP_CLIENT_ID"); + const envDesktopOauthRedirectUrl = readEnvValue( + "VITE_OEM_DESKTOP_OAUTH_REDIRECT_URL", + ); + const envDesktopOauthNextPath = normalizeAppPath( + readEnvValue("VITE_OEM_DESKTOP_OAUTH_NEXT_PATH"), + ); + + const envOverride: OemCloudRuntimeOverride = { + enabled: + envEnabled === null + ? undefined + : ["1", "true", "yes", "on"].includes(envEnabled.toLowerCase()), + baseUrl: envBaseUrl, + gatewayBaseUrl: envGatewayBaseUrl, + tenantId: envTenantId, + sessionToken: envSessionToken, + hubProviderName: envHubProviderName, + loginPath: envLoginPath, + desktopClientId: envDesktopClientId, + desktopOauthRedirectUrl: envDesktopOauthRedirectUrl, + desktopOauthNextPath: envDesktopOauthNextPath, + }; + + const runtimeOverride = + typeof window === "undefined" + ? { + baseUrl: null, + gatewayBaseUrl: null, + tenantId: null, + sessionToken: null, + hubProviderName: null, + loginPath: null, + desktopClientId: null, + desktopOauthRedirectUrl: null, + desktopOauthNextPath: null, + } + : parseOemCloudRuntimeOverride(window.__LIME_OEM_CLOUD__); + + const bootstrapPayload = + typeof window === "undefined" ? null : window.__LIME_BOOTSTRAP__; + const bootstrapTenantId = extractTenantIdFromPayload(bootstrapPayload); + const bootstrapSessionToken = extractSessionTokenFromPayload(bootstrapPayload); + const storedTenantId = + typeof window === "undefined" ? null : getStoredOemCloudTenantId(); + const storedSessionToken = + typeof window === "undefined" ? null : getStoredOemCloudAccessToken(); + const explicitWindowSessionToken = + typeof window === "undefined" + ? null + : normalizeText(window.__LIME_SESSION_TOKEN__); + + const enabled = + runtimeOverride.enabled ?? + envOverride.enabled ?? + Boolean(runtimeOverride.baseUrl ?? envOverride.baseUrl); + + const baseUrl = runtimeOverride.baseUrl ?? envOverride.baseUrl; + const gatewayBaseUrl = + runtimeOverride.gatewayBaseUrl ?? + envOverride.gatewayBaseUrl ?? + (baseUrl ? `${baseUrl}/gateway-api` : null); + const tenantId = + runtimeOverride.tenantId ?? + envOverride.tenantId ?? + bootstrapTenantId ?? + storedTenantId; + const sessionToken = + explicitWindowSessionToken ?? + runtimeOverride.sessionToken ?? + envOverride.sessionToken ?? + bootstrapSessionToken ?? + storedSessionToken; + const hubProviderName = + runtimeOverride.hubProviderName ?? envOverride.hubProviderName ?? null; + const loginPath = + runtimeOverride.loginPath ?? + envOverride.loginPath ?? + DEFAULT_OEM_CLOUD_LOGIN_PATH; + const desktopClientId = + runtimeOverride.desktopClientId ?? + envOverride.desktopClientId ?? + DEFAULT_OEM_CLOUD_DESKTOP_CLIENT_ID; + const desktopOauthRedirectUrl = + runtimeOverride.desktopOauthRedirectUrl ?? + envOverride.desktopOauthRedirectUrl ?? + DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_REDIRECT_URL; + const desktopOauthNextPath = + runtimeOverride.desktopOauthNextPath ?? + envOverride.desktopOauthNextPath ?? + DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH; + + if (!enabled || !baseUrl || !gatewayBaseUrl || !tenantId) { + return null; + } + + return { + baseUrl, + controlPlaneBaseUrl: `${baseUrl}/api`, + sceneBaseUrl: `${baseUrl}/scene-api`, + gatewayBaseUrl, + tenantId, + sessionToken, + hubProviderName, + loginPath, + desktopClientId, + desktopOauthRedirectUrl, + desktopOauthNextPath, + }; +} + +export function hasOemCloudSession( + context: OemCloudRuntimeContext | null, +): context is OemCloudRuntimeContext & { sessionToken: string } { + return Boolean(context?.sessionToken); +} diff --git a/src/lib/api/serviceSkills.test.ts b/src/lib/api/serviceSkills.test.ts new file mode 100644 index 000000000..29734b446 --- /dev/null +++ b/src/lib/api/serviceSkills.test.ts @@ -0,0 +1,126 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + clearServiceSkillCatalogCache, + getSeededServiceSkillCatalog, + getServiceSkillCatalog, + listServiceSkills, + refreshServiceSkillCatalogFromRemote, + saveServiceSkillCatalog, + subscribeServiceSkillCatalogChanged, + type ServiceSkillCatalog, +} from "./serviceSkills"; + +function buildRemoteCatalog(): ServiceSkillCatalog { + const seeded = getSeededServiceSkillCatalog(); + return { + version: "tenant-2026-03-24", + tenantId: "tenant-demo", + syncedAt: "2026-03-24T12:00:00.000Z", + items: [ + { + ...seeded.items[0]!, + id: "tenant-remote-skill", + title: "租户短视频脚本", + summary: "租户下发的远端目录项", + version: "tenant-2026-03-24", + }, + ], + }; +} + +describe("serviceSkills API", () => { + beforeEach(() => { + window.localStorage.clear(); + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__LIME_OEM_CLOUD__; + delete window.__LIME_SESSION_TOKEN__; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("无缓存时应回退到 seeded catalog", async () => { + const catalog = await getServiceSkillCatalog(); + + expect(catalog.tenantId).toBe("local-seeded"); + expect(catalog.items.length).toBeGreaterThan(0); + + const cached = window.localStorage.getItem("lime:service-skill-catalog:v1"); + expect(cached).toContain('"tenantId":"local-seeded"'); + }); + + it("保存远端目录后应优先返回远端 catalog", async () => { + const remoteCatalog = buildRemoteCatalog(); + + saveServiceSkillCatalog(remoteCatalog, "bootstrap_sync"); + + const catalog = await getServiceSkillCatalog(); + const skills = await listServiceSkills(); + + expect(catalog.tenantId).toBe("tenant-demo"); + expect(catalog.version).toBe("tenant-2026-03-24"); + expect(skills).toHaveLength(1); + expect(skills[0]?.id).toBe("tenant-remote-skill"); + }); + + it("清空缓存后应恢复到 seeded catalog", async () => { + saveServiceSkillCatalog(buildRemoteCatalog(), "bootstrap_sync"); + + clearServiceSkillCatalogCache(); + const catalog = await getServiceSkillCatalog(); + + expect(catalog.tenantId).toBe("local-seeded"); + expect(catalog.version).toBe("client-seed-2026-03-24"); + }); + + it("目录变更时应广播 catalog change 事件", () => { + const callback = vi.fn(); + const unsubscribe = subscribeServiceSkillCatalogChanged(callback); + + try { + saveServiceSkillCatalog(buildRemoteCatalog(), "bootstrap_sync"); + clearServiceSkillCatalogCache(); + + expect(callback).toHaveBeenNthCalledWith(1, "bootstrap_sync"); + expect(callback).toHaveBeenNthCalledWith(2, "cache_clear"); + } finally { + unsubscribe(); + } + }); + + it("存在 OEM 注入时应优先从远端刷新目录", async () => { + window.__LIME_OEM_CLOUD__ = { + baseUrl: "https://oem.example.com", + tenantId: "tenant-demo", + }; + window.__LIME_SESSION_TOKEN__ = "session-token-demo"; + + const fetchMock = vi.fn(async () => ({ + ok: true, + json: async () => ({ + code: 200, + message: "success", + data: buildRemoteCatalog(), + }), + })); + vi.stubGlobal("fetch", fetchMock); + + const catalog = await refreshServiceSkillCatalogFromRemote(); + const stored = await getServiceSkillCatalog(); + + expect(fetchMock).toHaveBeenCalledWith( + "https://oem.example.com/api/v1/public/tenants/tenant-demo/client/service-skills", + expect.objectContaining({ + method: "GET", + headers: expect.objectContaining({ + Accept: "application/json", + Authorization: "Bearer session-token-demo", + }), + }), + ); + expect(catalog?.tenantId).toBe("tenant-demo"); + expect(stored.tenantId).toBe("tenant-demo"); + }); +}); diff --git a/src/lib/api/serviceSkills.ts b/src/lib/api/serviceSkills.ts index 253b9ced2..7734c09a9 100644 --- a/src/lib/api/serviceSkills.ts +++ b/src/lib/api/serviceSkills.ts @@ -1,3 +1,8 @@ +import { + hasOemCloudSession, + resolveOemCloudRuntimeContext, +} from "./oemCloudRuntime"; + export type ServiceSkillSource = "cloud_catalog" | "local_custom"; export type ServiceSkillRunnerType = "instant" | "scheduled" | "managed"; @@ -47,6 +52,7 @@ export interface ServiceSkillReadinessRequirements { export interface ServiceSkillItem { id: string; + skillKey?: string; title: string; summary: string; category: string; @@ -68,7 +74,21 @@ export interface ServiceSkillCatalog { items: ServiceSkillItem[]; } +export type ServiceSkillCatalogChangeSource = + | "seeded_fallback" + | "bootstrap_sync" + | "manual_override" + | "cache_clear"; + +interface ServiceSkillCatalogResponseEnvelope { + code?: number; + message?: string; + data?: unknown; +} + const SERVICE_SKILL_CATALOG_STORAGE_KEY = "lime:service-skill-catalog:v1"; +export const SERVICE_SKILL_CATALOG_CHANGED_EVENT = + "lime:service-skill-catalog-changed"; const SEEDED_SERVICE_SKILL_CATALOG_VERSION = "client-seed-2026-03-24"; const PLATFORM_OPTIONS: ServiceSkillSlotOption[] = [ @@ -86,6 +106,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { items: [ { id: "carousel-post-replication", + skillKey: "carousel-post-replication", title: "复制轮播帖", summary: "拆解参考轮播帖的结构、文风和卖点,再输出一版可继续改写的轮播内容。", category: "社媒内容", @@ -140,6 +161,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, { id: "short-video-script-replication", + skillKey: "short-video-script-replication", title: "复制短视频脚本", summary: "围绕参考视频的结构和节奏,输出一版可直接继续加工的脚本。", category: "视频创作", @@ -194,6 +216,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, { id: "article-to-slide-video-outline", + skillKey: "article-to-slide-video-outline", title: "文章转 Slide 视频提纲", summary: "把文章拆成镜头化结构,先生成一版适合做 Slide 视频的提纲。", category: "知识转化", @@ -237,6 +260,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, { id: "video-dubbing-language", + skillKey: "video-dubbing-language", title: "视频配音成其他语言", summary: "先整理配音脚本和语言要求,输出一版可继续进入配音流程的执行稿。", category: "视频创作", @@ -284,6 +308,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, { id: "daily-trend-briefing", + skillKey: "daily-trend-briefing", title: "每日趋势摘要", summary: "围绕指定平台、行业和地区,先产出一版趋势摘要和后续本地定时任务建议。", category: "社媒运营", @@ -343,6 +368,7 @@ const SEEDED_SERVICE_SKILL_CATALOG: ServiceSkillCatalog = { }, { id: "account-performance-tracking", + skillKey: "account-performance-tracking", title: "跟踪账号表现", summary: "围绕指定账号先做一版表现分析,并整理后续本地跟踪任务需要的指标和告警条件。", category: "社媒运营", @@ -435,6 +461,7 @@ function isServiceSkillItem(value: unknown): value is ServiceSkillItem { const item = value as Partial; return ( typeof item.id === "string" && + (item.skillKey === undefined || typeof item.skillKey === "string") && typeof item.title === "string" && typeof item.summary === "string" && typeof item.category === "string" && @@ -464,6 +491,49 @@ function isServiceSkillCatalog(value: unknown): value is ServiceSkillCatalog { ); } +function cloneServiceSkillCatalog(catalog: ServiceSkillCatalog): ServiceSkillCatalog { + return { + ...catalog, + items: catalog.items.map((item) => ({ + ...item, + slotSchema: item.slotSchema.map((slot) => ({ + ...slot, + options: slot.options ? [...slot.options] : undefined, + })), + readinessRequirements: item.readinessRequirements + ? { ...item.readinessRequirements } + : undefined, + })), + }; +} + +function isSameServiceSkillCatalog( + left: ServiceSkillCatalog, + right: ServiceSkillCatalog, +): boolean { + return JSON.stringify(left) === JSON.stringify(right); +} + +function emitServiceSkillCatalogChanged( + source: ServiceSkillCatalogChangeSource, +): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent<{ + source: ServiceSkillCatalogChangeSource; + timestamp: number; + }>(SERVICE_SKILL_CATALOG_CHANGED_EVENT, { + detail: { + source, + timestamp: Date.now(), + }, + }), + ); +} + function readCachedServiceSkillCatalog(): ServiceSkillCatalog | null { if (typeof window === "undefined") { return null; @@ -478,12 +548,22 @@ function readCachedServiceSkillCatalog(): ServiceSkillCatalog | null { if (!isServiceSkillCatalog(parsed)) { return null; } - return parsed; + return cloneServiceSkillCatalog(parsed); } catch { return null; } } +export function parseServiceSkillCatalog( + value: unknown, +): ServiceSkillCatalog | null { + if (!isServiceSkillCatalog(value)) { + return null; + } + + return cloneServiceSkillCatalog(value); +} + function persistServiceSkillCatalog(catalog: ServiceSkillCatalog): void { if (typeof window === "undefined") { return; @@ -499,25 +579,130 @@ function persistServiceSkillCatalog(catalog: ServiceSkillCatalog): void { } } +async function requestRemoteServiceSkillCatalog(): Promise { + const runtime = resolveOemCloudRuntimeContext(); + if (!runtime) { + throw new Error("缺少 OEM 云端配置,请先注入 base_url 与 tenant_id。"); + } + if (!hasOemCloudSession(runtime)) { + throw new Error("缺少 OEM 云端 Session Token,请先完成登录或注入会话。"); + } + + const response = await fetch( + `${runtime.controlPlaneBaseUrl}/v1/public/tenants/${encodeURIComponent(runtime.tenantId)}/client/service-skills`, + { + method: "GET", + headers: { + Accept: "application/json", + Authorization: `Bearer ${runtime.sessionToken}`, + }, + }, + ); + + let payload: ServiceSkillCatalogResponseEnvelope | null = null; + try { + payload = (await response.json()) as ServiceSkillCatalogResponseEnvelope; + } catch { + payload = null; + } + + if (!response.ok) { + throw new Error(payload?.message?.trim() || `请求失败 (${response.status})`); + } + + const catalog = parseServiceSkillCatalog(payload?.data); + if (!catalog) { + throw new Error(payload?.message?.trim() || "服务端返回的目录格式非法"); + } + + return catalog; +} + export function getSeededServiceSkillCatalog(): ServiceSkillCatalog { - return { - ...SEEDED_SERVICE_SKILL_CATALOG, - items: SEEDED_SERVICE_SKILL_CATALOG.items.map((item) => ({ - ...item, - slotSchema: item.slotSchema.map((slot) => ({ - ...slot, - options: slot.options ? [...slot.options] : undefined, - })), - readinessRequirements: item.readinessRequirements - ? { ...item.readinessRequirements } - : undefined, - })), + return cloneServiceSkillCatalog(SEEDED_SERVICE_SKILL_CATALOG); +} + +export function isSeededServiceSkillCatalog( + catalog: ServiceSkillCatalog, +): boolean { + return ( + catalog.tenantId === SEEDED_SERVICE_SKILL_CATALOG.tenantId && + catalog.version === SEEDED_SERVICE_SKILL_CATALOG.version + ); +} + +export function saveServiceSkillCatalog( + catalog: ServiceSkillCatalog, + source: Exclude = "manual_override", +): ServiceSkillCatalog { + const normalized = parseServiceSkillCatalog(catalog); + if (!normalized) { + throw new Error("invalid service skill catalog"); + } + const current = readCachedServiceSkillCatalog(); + if (current && isSameServiceSkillCatalog(current, normalized)) { + persistServiceSkillCatalog(normalized); + return normalized; + } + persistServiceSkillCatalog(normalized); + emitServiceSkillCatalogChanged(source); + return normalized; +} + +export function clearServiceSkillCatalogCache(): void { + if (typeof window !== "undefined") { + try { + window.localStorage.removeItem(SERVICE_SKILL_CATALOG_STORAGE_KEY); + } catch { + // ignore local cache errors + } + } + + emitServiceSkillCatalogChanged("cache_clear"); +} + +export function subscribeServiceSkillCatalogChanged( + callback: (source: ServiceSkillCatalogChangeSource) => void, +): () => void { + if (typeof window === "undefined") { + return () => {}; + } + + const customEventHandler = (event: Event) => { + const customEvent = event as CustomEvent<{ + source?: ServiceSkillCatalogChangeSource; + }>; + const source = customEvent.detail?.source; + if (source) { + callback(source); + } + }; + + const storageHandler = (event: StorageEvent) => { + if (event.key !== SERVICE_SKILL_CATALOG_STORAGE_KEY) { + return; + } + callback(event.newValue ? "manual_override" : "cache_clear"); + }; + + window.addEventListener( + SERVICE_SKILL_CATALOG_CHANGED_EVENT, + customEventHandler, + ); + window.addEventListener("storage", storageHandler); + + return () => { + window.removeEventListener( + SERVICE_SKILL_CATALOG_CHANGED_EVENT, + customEventHandler, + ); + window.removeEventListener("storage", storageHandler); }; } export async function getServiceSkillCatalog(): Promise { const cached = readCachedServiceSkillCatalog(); - if (cached && cached.version === SEEDED_SERVICE_SKILL_CATALOG_VERSION) { + if (cached) { return cached; } @@ -526,6 +711,16 @@ export async function getServiceSkillCatalog(): Promise { return seeded; } +export async function refreshServiceSkillCatalogFromRemote(): Promise { + const runtime = resolveOemCloudRuntimeContext(); + if (!runtime || !hasOemCloudSession(runtime)) { + return null; + } + + const catalog = await requestRemoteServiceSkillCatalog(); + return saveServiceSkillCatalog(catalog, "bootstrap_sync"); +} + export async function listServiceSkills(): Promise { const catalog = await getServiceSkillCatalog(); return catalog.items.filter((item) => item.source === "cloud_catalog"); diff --git a/src/lib/oemCloudDesktopAuth.test.ts b/src/lib/oemCloudDesktopAuth.test.ts new file mode 100644 index 000000000..500e99e0a --- /dev/null +++ b/src/lib/oemCloudDesktopAuth.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { parseOemCloudDesktopOAuthCallbackUrl } from "./oemCloudDesktopAuth"; + +describe("oemCloudDesktopAuth", () => { + it("应解析桌面端 OAuth 回调", () => { + expect( + parseOemCloudDesktopOAuthCallbackUrl( + "lime://oauth/callback?tenantId=tenant-0001&token=session-token&next=%2Fwelcome", + ), + ).toEqual({ + tenantId: "tenant-0001", + token: "session-token", + nextPath: "/welcome", + error: null, + }); + }); + + it("缺少 next 时应回退默认欢迎页", () => { + expect( + parseOemCloudDesktopOAuthCallbackUrl( + "lime://oauth/callback?tenantId=tenant-0001&token=session-token", + ), + ).toEqual({ + tenantId: "tenant-0001", + token: "session-token", + nextPath: "/welcome", + error: null, + }); + }); +}); diff --git a/src/lib/oemCloudDesktopAuth.ts b/src/lib/oemCloudDesktopAuth.ts new file mode 100644 index 000000000..5b2298b52 --- /dev/null +++ b/src/lib/oemCloudDesktopAuth.ts @@ -0,0 +1,203 @@ +import { + getClientBootstrap, + type OemCloudBootstrapResponse, +} from "@/lib/api/oemCloudControlPlane"; +import { + DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH, +} from "@/lib/api/oemCloudRuntime"; +import { + setOemCloudBootstrapSnapshot, + setStoredOemCloudSessionState, +} from "@/lib/oemCloudSession"; +import { syncServiceSkillCatalogFromBootstrapPayload } from "@/lib/serviceSkillCatalogBootstrap"; + +export const OEM_CLOUD_OAUTH_COMPLETED_EVENT = + "lime:oem-cloud-oauth-completed"; + +export interface OemCloudDesktopOAuthCallbackPayload { + tenantId: string | null; + token: string | null; + nextPath: string; + error: string | null; +} + +export interface OemCloudDesktopOAuthCompletedDetail { + tenantId: string; + nextPath: string; + provider: "google"; +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeAppPath(value: unknown, fallback: string): string { + const normalized = normalizeText(value); + if (!normalized) { + return fallback; + } + + if (normalized === "/") { + return normalized; + } + + return normalized.startsWith("/") ? normalized : `/${normalized}`; +} + +function setWindowSessionToken(token: string | null): void { + if (typeof window === "undefined") { + return; + } + + if (!token) { + delete window.__LIME_SESSION_TOKEN__; + return; + } + + window.__LIME_SESSION_TOKEN__ = token; +} + +function setWindowRuntimeTenantId(tenantId: string | null): void { + if (typeof window === "undefined") { + return; + } + + const currentRuntime = isRecord(window.__LIME_OEM_CLOUD__) + ? window.__LIME_OEM_CLOUD__ + : {}; + + if (!tenantId) { + window.__LIME_OEM_CLOUD__ = { + ...currentRuntime, + }; + return; + } + + window.__LIME_OEM_CLOUD__ = { + ...currentRuntime, + tenantId, + }; +} + +function restoreWindowOauthContext(previous: { + runtime: unknown; + sessionToken: unknown; +}): void { + if (typeof window === "undefined") { + return; + } + + if (previous.runtime === undefined) { + delete window.__LIME_OEM_CLOUD__; + } else { + window.__LIME_OEM_CLOUD__ = previous.runtime; + } + + if (previous.sessionToken === undefined) { + delete window.__LIME_SESSION_TOKEN__; + } else { + window.__LIME_SESSION_TOKEN__ = previous.sessionToken; + } +} + +export function parseOemCloudDesktopOAuthCallbackUrl( + value: string, +): OemCloudDesktopOAuthCallbackPayload | null { + try { + const parsed = new URL(value); + const normalizedPath = parsed.pathname.replace(/\/+$/, "") || "/"; + if ( + parsed.protocol !== "lime:" || + parsed.hostname !== "oauth" || + normalizedPath !== "/callback" + ) { + return null; + } + + return { + tenantId: normalizeText(parsed.searchParams.get("tenantId")), + token: normalizeText(parsed.searchParams.get("token")), + nextPath: normalizeAppPath( + parsed.searchParams.get("next"), + DEFAULT_OEM_CLOUD_DESKTOP_OAUTH_NEXT_PATH, + ), + error: normalizeText(parsed.searchParams.get("error")), + }; + } catch { + return null; + } +} + +export function dispatchOemCloudOAuthCompleted( + detail: OemCloudDesktopOAuthCompletedDetail, +): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent( + OEM_CLOUD_OAUTH_COMPLETED_EVENT, + { + detail, + }, + ), + ); +} + +export async function completeOemCloudDesktopOAuthLogin( + payload: OemCloudDesktopOAuthCallbackPayload, +): Promise { + if (!payload.tenantId || !payload.token) { + throw new Error("桌面 OAuth 回调缺少 tenantId 或 token"); + } + + const previousWindowState = + typeof window === "undefined" + ? { + runtime: undefined, + sessionToken: undefined, + } + : { + runtime: window.__LIME_OEM_CLOUD__, + sessionToken: window.__LIME_SESSION_TOKEN__, + }; + + setWindowRuntimeTenantId(payload.tenantId); + setWindowSessionToken(payload.token); + + try { + const bootstrap = await getClientBootstrap(payload.tenantId); + const nextSession = { + ...bootstrap.session, + token: bootstrap.session.token ?? payload.token, + }; + const nextBootstrap = { + ...bootstrap, + session: nextSession, + }; + + setStoredOemCloudSessionState(nextSession); + setOemCloudBootstrapSnapshot(nextBootstrap); + syncServiceSkillCatalogFromBootstrapPayload(nextBootstrap); + dispatchOemCloudOAuthCompleted({ + tenantId: payload.tenantId, + nextPath: payload.nextPath, + provider: "google", + }); + + return nextBootstrap; + } catch (error) { + restoreWindowOauthContext(previousWindowState); + throw error; + } +} diff --git a/src/lib/oemCloudSession.test.ts b/src/lib/oemCloudSession.test.ts new file mode 100644 index 000000000..66b153caa --- /dev/null +++ b/src/lib/oemCloudSession.test.ts @@ -0,0 +1,106 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + applyStoredOemCloudSessionToWindow, + clearStoredOemCloudSessionState, + getStoredOemCloudAccessToken, + getStoredOemCloudSessionState, + setStoredOemCloudSessionState, +} from "./oemCloudSession"; + +describe("oemCloudSession", () => { + beforeEach(() => { + window.localStorage.clear(); + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_OEM_CLOUD__; + }); + + it("应持久化 session 并同步到 window 全局", () => { + const saved = setStoredOemCloudSessionState({ + token: "Bearer session-token-demo", + tenant: { + id: "tenant-0001", + name: "JustAI Demo", + slug: "justai-demo", + }, + user: { + id: "user-001", + email: "operator@example.com", + displayName: "Demo Operator", + roles: ["customer"], + }, + session: { + id: "session-001", + tenantId: "tenant-0001", + userId: "user-001", + provider: "password", + roles: ["customer"], + issuedAt: "2026-03-24T08:00:00.000Z", + expiresAt: "2026-03-25T08:00:00.000Z", + }, + }); + + expect(saved.token).toBe("session-token-demo"); + expect(getStoredOemCloudAccessToken()).toBe("session-token-demo"); + expect(getStoredOemCloudSessionState()?.session.user.displayName).toBe( + "Demo Operator", + ); + expect(window.__LIME_SESSION_TOKEN__).toBe("session-token-demo"); + expect(window.__LIME_OEM_CLOUD__).toMatchObject({ + tenantId: "tenant-0001", + }); + }); + + it("应支持从 localStorage 重新恢复到 window 全局", () => { + setStoredOemCloudSessionState({ + token: "session-token-restore", + tenant: { + id: "tenant-restore", + }, + user: { + id: "user-restore", + }, + session: { + id: "session-restore", + }, + }); + + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_OEM_CLOUD__; + + const restored = applyStoredOemCloudSessionToWindow(); + expect(restored?.token).toBe("session-token-restore"); + expect(window.__LIME_SESSION_TOKEN__).toBe("session-token-restore"); + expect(window.__LIME_OEM_CLOUD__).toMatchObject({ + tenantId: "tenant-restore", + }); + }); + + it("清理后应移除本地和全局会话", () => { + setStoredOemCloudSessionState({ + token: "session-token-clear", + tenant: { + id: "tenant-clear", + }, + user: { + id: "user-clear", + }, + session: { + id: "session-clear", + }, + }); + + clearStoredOemCloudSessionState(); + + expect(getStoredOemCloudAccessToken()).toBeNull(); + expect(getStoredOemCloudSessionState()).toBeNull(); + expect(window.__LIME_SESSION_TOKEN__).toBeUndefined(); + }); +}); diff --git a/src/lib/oemCloudSession.ts b/src/lib/oemCloudSession.ts new file mode 100644 index 000000000..15936819e --- /dev/null +++ b/src/lib/oemCloudSession.ts @@ -0,0 +1,372 @@ +const OEM_CLOUD_SESSION_STORAGE_KEY = "lime:oem-cloud-session:v1"; +export const OEM_CLOUD_SESSION_CHANGED_EVENT = + "lime:oem-cloud-session-changed"; +export const OEM_CLOUD_BOOTSTRAP_CHANGED_EVENT = + "lime:oem-cloud-bootstrap-changed"; + +export interface OemCloudTenantLike { + id: string; + name?: string; + slug?: string; +} + +export interface OemCloudUserLike { + id: string; + email?: string; + displayName?: string; + avatarUrl?: string; + username?: string; + passwordConfigured?: boolean; + roles?: string[]; +} + +export interface OemCloudUserSessionLike { + id: string; + tenantId?: string; + userId?: string; + provider?: string; + roles?: string[]; + issuedAt?: string; + expiresAt?: string; +} + +export interface OemCloudCurrentSessionLike { + token?: string; + tenant: OemCloudTenantLike; + user: OemCloudUserLike; + session: OemCloudUserSessionLike; +} + +export interface OemCloudStoredSessionState { + token: string; + session: OemCloudCurrentSessionLike; + savedAt: string; +} + +declare global { + interface Window { + __LIME_BOOTSTRAP__?: unknown; + __LIME_OEM_CLOUD__?: unknown; + __LIME_SESSION_TOKEN__?: unknown; + } +} + +function canUseStorage() { + return typeof window !== "undefined" && Boolean(window.localStorage); +} + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object"; +} + +function normalizeText(value: unknown): string | null { + if (typeof value !== "string") { + return null; + } + + const normalized = value.trim(); + return normalized ? normalized : null; +} + +function normalizeAccessToken(value: unknown): string | null { + const normalized = normalizeText(value); + if (!normalized) { + return null; + } + + const match = normalized.match(/^Bearer\s+(.+)$/i); + if (!match) { + return normalized; + } + + return normalizeText(match[1]); +} + +function normalizeSessionLike( + value: unknown, +): OemCloudCurrentSessionLike | null { + if (!isRecord(value)) { + return null; + } + + const tenant = isRecord(value.tenant) ? value.tenant : null; + const user = isRecord(value.user) ? value.user : null; + const session = isRecord(value.session) ? value.session : null; + + const tenantId = normalizeText(tenant?.id); + const userId = normalizeText(user?.id); + const sessionId = normalizeText(session?.id); + if (!tenantId || !userId || !sessionId) { + return null; + } + + return { + token: normalizeAccessToken(value.token) ?? undefined, + tenant: { + id: tenantId, + name: normalizeText(tenant?.name) ?? undefined, + slug: normalizeText(tenant?.slug) ?? undefined, + }, + user: { + id: userId, + email: normalizeText(user?.email) ?? undefined, + displayName: normalizeText(user?.displayName) ?? undefined, + avatarUrl: normalizeText(user?.avatarUrl) ?? undefined, + username: normalizeText(user?.username) ?? undefined, + passwordConfigured: + typeof user?.passwordConfigured === "boolean" + ? user.passwordConfigured + : undefined, + roles: Array.isArray(user?.roles) + ? user.roles + .map((item) => normalizeText(item)) + .filter((item): item is string => Boolean(item)) + : undefined, + }, + session: { + id: sessionId, + tenantId: normalizeText(session?.tenantId) ?? tenantId, + userId: normalizeText(session?.userId) ?? userId, + provider: normalizeText(session?.provider) ?? undefined, + roles: Array.isArray(session?.roles) + ? session.roles + .map((item) => normalizeText(item)) + .filter((item): item is string => Boolean(item)) + : undefined, + issuedAt: normalizeText(session?.issuedAt) ?? undefined, + expiresAt: normalizeText(session?.expiresAt) ?? undefined, + }, + }; +} + +function normalizeStoredSessionState( + value: unknown, +): OemCloudStoredSessionState | null { + if (!isRecord(value)) { + return null; + } + + const session = normalizeSessionLike(value.session); + const token = + normalizeAccessToken(value.token) ?? normalizeAccessToken(session?.token); + if (!session || !token) { + return null; + } + + return { + token, + session: { + ...session, + token, + }, + savedAt: normalizeText(value.savedAt) ?? new Date().toISOString(), + }; +} + +function emitSessionChanged(state: OemCloudStoredSessionState | null) { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(OEM_CLOUD_SESSION_CHANGED_EVENT, { + detail: { + hasSession: Boolean(state), + tenantId: state?.session.tenant.id ?? null, + sessionId: state?.session.session.id ?? null, + }, + }), + ); +} + +function emitBootstrapChanged(payload: unknown): void { + if (typeof window === "undefined") { + return; + } + + window.dispatchEvent( + new CustomEvent(OEM_CLOUD_BOOTSTRAP_CHANGED_EVENT, { + detail: { + hasBootstrap: payload !== undefined && payload !== null, + }, + }), + ); +} + +function applySessionGlobals(state: OemCloudStoredSessionState | null) { + if (typeof window === "undefined") { + return; + } + + if (!state) { + delete window.__LIME_SESSION_TOKEN__; + delete window.__LIME_BOOTSTRAP__; + return; + } + + window.__LIME_SESSION_TOKEN__ = state.token; + + const currentRuntime = isRecord(window.__LIME_OEM_CLOUD__) + ? window.__LIME_OEM_CLOUD__ + : {}; + window.__LIME_OEM_CLOUD__ = { + ...currentRuntime, + tenantId: state.session.tenant.id, + }; +} + +export function getStoredOemCloudSessionState(): OemCloudStoredSessionState | null { + if (!canUseStorage()) { + return null; + } + + try { + const rawValue = window.localStorage.getItem(OEM_CLOUD_SESSION_STORAGE_KEY); + if (!rawValue) { + return null; + } + + const parsed = JSON.parse(rawValue); + const normalized = normalizeStoredSessionState(parsed); + if (!normalized) { + window.localStorage.removeItem(OEM_CLOUD_SESSION_STORAGE_KEY); + return null; + } + return normalized; + } catch { + window.localStorage.removeItem(OEM_CLOUD_SESSION_STORAGE_KEY); + return null; + } +} + +export function getStoredOemCloudAccessToken(): string | null { + return getStoredOemCloudSessionState()?.token ?? null; +} + +export function getStoredOemCloudTenantId(): string | null { + return getStoredOemCloudSessionState()?.session.tenant.id ?? null; +} + +export function setStoredOemCloudSessionState( + session: OemCloudCurrentSessionLike, +): OemCloudStoredSessionState { + const normalizedSession = normalizeSessionLike(session); + const token = + normalizeAccessToken(session.token) ?? + normalizeAccessToken(normalizedSession?.token); + if (!normalizedSession || !token) { + throw new Error("缺少有效的 OEM 云端 Session Token"); + } + + const nextState: OemCloudStoredSessionState = { + token, + session: { + ...normalizedSession, + token, + }, + savedAt: new Date().toISOString(), + }; + + if (canUseStorage()) { + window.localStorage.setItem( + OEM_CLOUD_SESSION_STORAGE_KEY, + JSON.stringify(nextState), + ); + } + applySessionGlobals(nextState); + emitSessionChanged(nextState); + return nextState; +} + +export function clearStoredOemCloudSessionState(): void { + if (canUseStorage()) { + window.localStorage.removeItem(OEM_CLOUD_SESSION_STORAGE_KEY); + } + applySessionGlobals(null); + emitSessionChanged(null); +} + +export function applyStoredOemCloudSessionToWindow(): OemCloudStoredSessionState | null { + const current = getStoredOemCloudSessionState(); + applySessionGlobals(current); + return current; +} + +export function setOemCloudBootstrapSnapshot(payload: unknown): void { + if (typeof window === "undefined") { + return; + } + + window.__LIME_BOOTSTRAP__ = payload; + emitBootstrapChanged(payload); +} + +export function getOemCloudBootstrapSnapshot(): T | null { + if (typeof window === "undefined" || window.__LIME_BOOTSTRAP__ === undefined) { + return null; + } + + return window.__LIME_BOOTSTRAP__ as T; +} + +export function clearOemCloudBootstrapSnapshot(): void { + if (typeof window === "undefined") { + return; + } + + delete window.__LIME_BOOTSTRAP__; + emitBootstrapChanged(null); +} + +export function subscribeOemCloudSessionChanged( + listener: (state: OemCloudStoredSessionState | null) => void, +): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + const handleCustomEvent = () => { + listener(getStoredOemCloudSessionState()); + }; + const handleStorage = (event: StorageEvent) => { + if (event.key !== OEM_CLOUD_SESSION_STORAGE_KEY) { + return; + } + listener(getStoredOemCloudSessionState()); + }; + + window.addEventListener(OEM_CLOUD_SESSION_CHANGED_EVENT, handleCustomEvent); + window.addEventListener("storage", handleStorage); + + return () => { + window.removeEventListener( + OEM_CLOUD_SESSION_CHANGED_EVENT, + handleCustomEvent, + ); + window.removeEventListener("storage", handleStorage); + }; +} + +export function subscribeOemCloudBootstrapChanged( + listener: (payload: unknown) => void, +): () => void { + if (typeof window === "undefined") { + return () => undefined; + } + + const handleCustomEvent = () => { + listener(getOemCloudBootstrapSnapshot()); + }; + + window.addEventListener( + OEM_CLOUD_BOOTSTRAP_CHANGED_EVENT, + handleCustomEvent, + ); + + return () => { + window.removeEventListener( + OEM_CLOUD_BOOTSTRAP_CHANGED_EVENT, + handleCustomEvent, + ); + }; +} diff --git a/src/lib/oemLimeHubProvider.test.ts b/src/lib/oemLimeHubProvider.test.ts new file mode 100644 index 000000000..28bfcdbe5 --- /dev/null +++ b/src/lib/oemLimeHubProvider.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { + buildOemLimeHubApiHost, + DEFAULT_OEM_LIME_HUB_PROVIDER_NAME, + isLegacyProxyCastHubProvider, + OEM_LIME_HUB_PROVIDER_ID, + resolveOemLimeHubProviderName, +} from "./oemLimeHubProvider"; + +describe("oemLimeHubProvider", () => { + it("应从运行时配置提取 Lime Hub 网关地址", () => { + expect( + buildOemLimeHubApiHost({ + gatewayBaseUrl: "https://user.150404.xyz/gateway-api/", + }), + ).toBe("https://user.150404.xyz/gateway-api"); + }); + + it("应在未配置品牌名时回退默认 Lime Hub 名称", () => { + expect(resolveOemLimeHubProviderName(null)).toBe( + DEFAULT_OEM_LIME_HUB_PROVIDER_NAME, + ); + expect( + resolveOemLimeHubProviderName({ + hubProviderName: " ", + }), + ).toBe(DEFAULT_OEM_LIME_HUB_PROVIDER_NAME); + }); + + it("应识别旧 ProxyCast Hub 兼容项,但保留新的 lime-hub 系统 provider", () => { + expect( + isLegacyProxyCastHubProvider({ + id: OEM_LIME_HUB_PROVIDER_ID, + name: "Lime Hub", + api_host: "https://user.150404.xyz/gateway-api", + }), + ).toBe(false); + + expect( + isLegacyProxyCastHubProvider({ + id: "proxycast-hub", + name: "ProxyCast Hub", + api_host: "https://proxycast.example.com/v1", + }), + ).toBe(true); + + expect( + isLegacyProxyCastHubProvider({ + id: "custom-provider", + name: "Legacy Provider", + api_host: "https://proxycast.example.com/v1", + }), + ).toBe(true); + }); +}); diff --git a/src/lib/oemLimeHubProvider.ts b/src/lib/oemLimeHubProvider.ts new file mode 100644 index 000000000..0f1d0c825 --- /dev/null +++ b/src/lib/oemLimeHubProvider.ts @@ -0,0 +1,68 @@ +import type { ProviderWithKeysDisplay } from "@/lib/api/apiKeyProvider"; +import type { OemCloudRuntimeContext } from "@/lib/api/oemCloudRuntime"; + +export const OEM_LIME_HUB_PROVIDER_ID = "lime-hub"; +export const DEFAULT_OEM_LIME_HUB_PROVIDER_NAME = "Lime Hub"; + +const LEGACY_PROXYCAST_PROVIDER_IDS = new Set([ + "lobehub", + "proxycast-hub", + "proxycasthub", +]); + +function normalizeText(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function normalizeBaseUrl(value: unknown): string { + return normalizeText(value).replace(/\/+$/, ""); +} + +export function buildOemLimeHubApiHost( + runtime: Pick | null | undefined, +): string | null { + const gatewayBaseUrl = normalizeBaseUrl(runtime?.gatewayBaseUrl); + return gatewayBaseUrl || null; +} + +export function resolveOemLimeHubProviderName( + runtime: + | Pick + | null + | undefined, +): string { + return ( + normalizeText(runtime?.hubProviderName) || DEFAULT_OEM_LIME_HUB_PROVIDER_NAME + ); +} + +export function isLegacyProxyCastHubProvider( + provider: Pick, +): boolean { + const providerId = normalizeText(provider.id).toLowerCase(); + if (providerId === OEM_LIME_HUB_PROVIDER_ID) { + return false; + } + + if (LEGACY_PROXYCAST_PROVIDER_IDS.has(providerId)) { + return true; + } + + const providerName = normalizeText(provider.name).toLowerCase(); + if (providerName.includes("proxycast")) { + return true; + } + + const apiHost = normalizeBaseUrl(provider.api_host).toLowerCase(); + return apiHost.includes("proxycast"); +} + +export function isOemManagedHubProvider( + provider: Pick, +): boolean { + const providerId = normalizeText(provider.id).toLowerCase(); + return ( + providerId === OEM_LIME_HUB_PROVIDER_ID || + isLegacyProxyCastHubProvider(provider) + ); +} diff --git a/src/lib/serviceSkillCatalogBootstrap.test.ts b/src/lib/serviceSkillCatalogBootstrap.test.ts new file mode 100644 index 000000000..695d8f211 --- /dev/null +++ b/src/lib/serviceSkillCatalogBootstrap.test.ts @@ -0,0 +1,114 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + getSeededServiceSkillCatalog, + getServiceSkillCatalog, + type ServiceSkillCatalog, +} from "@/lib/api/serviceSkills"; +import { + applyInitialServiceSkillCatalogBootstrap, + emitServiceSkillCatalogBootstrap, + extractServiceSkillCatalogFromBootstrapPayload, + subscribeServiceSkillCatalogBootstrap, + syncServiceSkillCatalogFromBootstrapPayload, +} from "./serviceSkillCatalogBootstrap"; + +function buildRemoteCatalog(): ServiceSkillCatalog { + const seeded = getSeededServiceSkillCatalog(); + return { + version: "tenant-2026-03-24", + tenantId: "tenant-demo", + syncedAt: "2026-03-24T12:00:00.000Z", + items: [ + { + ...seeded.items[0]!, + id: "tenant-bootstrap-skill", + title: "租户同步技能", + summary: "来自 bootstrap 的目录项", + version: "tenant-2026-03-24", + }, + ], + }; +} + +describe("serviceSkillCatalogBootstrap", () => { + beforeEach(() => { + window.localStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_SERVICE_SKILL_CATALOG__; + }); + + afterEach(() => { + window.localStorage.clear(); + delete window.__LIME_BOOTSTRAP__; + delete window.__LIME_SERVICE_SKILL_CATALOG__; + }); + + it("应支持从 bootstrap 包装层提取服务型技能目录", () => { + const catalog = buildRemoteCatalog(); + + expect( + extractServiceSkillCatalogFromBootstrapPayload({ + bootstrap: { + serviceSkillCatalog: catalog, + }, + }), + ).toEqual(catalog); + }); + + it("启动时应在专用全局快照无效时回退读取 bootstrap payload", async () => { + const catalog = buildRemoteCatalog(); + window.__LIME_SERVICE_SKILL_CATALOG__ = { + invalid: true, + }; + window.__LIME_BOOTSTRAP__ = { + serviceSkillCatalog: catalog, + }; + + const synced = applyInitialServiceSkillCatalogBootstrap(); + const stored = await getServiceSkillCatalog(); + + expect(synced?.tenantId).toBe("tenant-demo"); + expect(stored.items[0]?.id).toBe("tenant-bootstrap-skill"); + }); + + it("收到 bootstrap 事件后应同步目录", async () => { + const catalog = buildRemoteCatalog(); + const listener = vi.fn(); + const unsubscribe = subscribeServiceSkillCatalogBootstrap(listener); + + try { + emitServiceSkillCatalogBootstrap({ + serviceSkillCatalog: catalog, + }); + + const stored = await getServiceSkillCatalog(); + expect(stored.tenantId).toBe("tenant-demo"); + expect(listener).toHaveBeenCalledTimes(1); + expect(listener).toHaveBeenCalledWith( + expect.objectContaining({ + tenantId: "tenant-demo", + }), + ); + } finally { + unsubscribe(); + } + }); + + it("非法 payload 不应覆盖已有目录", async () => { + const catalog = buildRemoteCatalog(); + + syncServiceSkillCatalogFromBootstrapPayload({ + serviceSkillCatalog: catalog, + }); + const ignored = syncServiceSkillCatalogFromBootstrapPayload({ + serviceSkillCatalog: { + invalid: true, + }, + }); + const stored = await getServiceSkillCatalog(); + + expect(ignored).toBeNull(); + expect(stored.tenantId).toBe("tenant-demo"); + expect(stored.items[0]?.id).toBe("tenant-bootstrap-skill"); + }); +}); diff --git a/src/lib/serviceSkillCatalogBootstrap.ts b/src/lib/serviceSkillCatalogBootstrap.ts new file mode 100644 index 000000000..226bb928c --- /dev/null +++ b/src/lib/serviceSkillCatalogBootstrap.ts @@ -0,0 +1,109 @@ +import { + parseServiceSkillCatalog, + saveServiceSkillCatalog, + type ServiceSkillCatalog, +} from "@/lib/api/serviceSkills"; + +export const SERVICE_SKILL_CATALOG_BOOTSTRAP_EVENT = + "lime:service-skill-catalog-bootstrap"; + +declare global { + interface Window { + __LIME_BOOTSTRAP__?: unknown; + __LIME_SERVICE_SKILL_CATALOG__?: unknown; + } +} + +function hasWindow(): boolean { + return typeof window !== "undefined"; +} + +function extractFromRecord(record: Record): ServiceSkillCatalog | null { + const directCatalog = parseServiceSkillCatalog(record.serviceSkillCatalog); + if (directCatalog) { + return directCatalog; + } + + const nestedBootstrap = record.bootstrap; + if (nestedBootstrap && typeof nestedBootstrap === "object") { + return extractServiceSkillCatalogFromBootstrapPayload(nestedBootstrap); + } + + return null; +} + +export function extractServiceSkillCatalogFromBootstrapPayload( + payload: unknown, +): ServiceSkillCatalog | null { + const directCatalog = parseServiceSkillCatalog(payload); + if (directCatalog) { + return directCatalog; + } + + if (!payload || typeof payload !== "object") { + return null; + } + + return extractFromRecord(payload as Record); +} + +export function syncServiceSkillCatalogFromBootstrapPayload( + payload: unknown, +): ServiceSkillCatalog | null { + const catalog = extractServiceSkillCatalogFromBootstrapPayload(payload); + if (!catalog) { + return null; + } + + return saveServiceSkillCatalog(catalog, "bootstrap_sync"); +} + +export function applyInitialServiceSkillCatalogBootstrap(): ServiceSkillCatalog | null { + if (!hasWindow()) { + return null; + } + + if (window.__LIME_SERVICE_SKILL_CATALOG__ !== undefined) { + const directCatalog = syncServiceSkillCatalogFromBootstrapPayload({ + serviceSkillCatalog: window.__LIME_SERVICE_SKILL_CATALOG__, + }); + if (directCatalog) { + return directCatalog; + } + } + + return syncServiceSkillCatalogFromBootstrapPayload(window.__LIME_BOOTSTRAP__); +} + +export function emitServiceSkillCatalogBootstrap(payload: unknown): void { + if (!hasWindow()) { + return; + } + + window.dispatchEvent( + new CustomEvent(SERVICE_SKILL_CATALOG_BOOTSTRAP_EVENT, { + detail: payload, + }), + ); +} + +export function subscribeServiceSkillCatalogBootstrap( + listener?: (catalog: ServiceSkillCatalog | null) => void, +): () => void { + if (!hasWindow()) { + return () => undefined; + } + + const handler = (event: Event) => { + if (!(event instanceof CustomEvent)) { + return; + } + + listener?.(syncServiceSkillCatalogFromBootstrapPayload(event.detail)); + }; + + window.addEventListener(SERVICE_SKILL_CATALOG_BOOTSTRAP_EVENT, handler); + return () => { + window.removeEventListener(SERVICE_SKILL_CATALOG_BOOTSTRAP_EVENT, handler); + }; +}