feat: finalize oem cloud access integration

This commit is contained in:
coso
2026-03-25 01:39:23 +08:00
parent 544a2fa6c9
commit 307b2d3670
33 changed files with 6639 additions and 47 deletions
+19 -19
View File
@@ -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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "lime",
"private": true,
"version": "0.95.0",
"version": "0.95.1",
"type": "module",
"engines": {
"node": ">=22.0.0"
+32
View File
@@ -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";
*/
+2 -2
View File
@@ -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"
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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",
+4
View File
@@ -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,
+12 -5
View File
@@ -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<ModelSelectorProps> = ({
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 =
@@ -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<string, unknown> = {}) {
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(<UserCenterSessionSettings />);
});
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);
});
});
@@ -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 (
<div className="rounded-[22px] border border-white/90 bg-white/88 p-4 shadow-sm shadow-slate-950/5">
<p className="text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
{props.label}
</p>
<p className="mt-2 break-all text-sm font-semibold text-slate-900">
{props.value}
</p>
<p className="mt-2 text-xs text-slate-500">{props.hint}</p>
</div>
);
}
function NoticeBar(props: {
tone: "error" | "success";
message: string;
}) {
return (
<div
className={cn(
"flex items-center gap-3 rounded-[20px] border px-4 py-3 text-sm shadow-sm shadow-slate-950/5",
props.tone === "success"
? "border-emerald-200 bg-emerald-50/90 text-emerald-700"
: "border-rose-200 bg-rose-50/90 text-rose-700",
)}
>
{props.tone === "success" ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 flex-shrink-0" />
)}
<span>{props.message}</span>
</div>
);
}
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 (
<section className="space-y-4">
<div className="relative overflow-hidden rounded-[30px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(245,250,248,0.98)_0%,rgba(255,255,255,0.98)_52%,rgba(242,247,255,0.96)_100%)] shadow-sm shadow-slate-950/5">
<div className="pointer-events-none absolute -left-16 top-[-68px] h-52 w-52 rounded-full bg-emerald-200/25 blur-3xl" />
<div className="pointer-events-none absolute right-[-88px] top-[-14px] h-56 w-56 rounded-full bg-sky-200/25 blur-3xl" />
<div className="relative flex flex-col gap-5 p-6 lg:p-7">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-3xl space-y-3">
<span className="inline-flex items-center rounded-full border border-emerald-200 bg-white/85 px-3 py-1 text-xs font-semibold tracking-[0.16em] text-emerald-700 shadow-sm">
USER CENTER
</span>
<div className="space-y-2">
<h2 className="text-3xl font-semibold tracking-tight text-slate-900">
个人中心会话
</h2>
<p className="max-w-2xl text-sm leading-6 text-slate-600">
用户登录、退出和云端会话同步统一放在这里。登录成功后,Providers 页里的 {hubProviderName} 云服务入口会直接复用这份会话。
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3 xl:min-w-[520px]">
<SessionValueCard
label="用户中心"
value={configuredTarget?.baseUrl || "未配置"}
hint="控制面与场景目录入口"
/>
<SessionValueCard
label="Gateway"
value={runtime?.gatewayBaseUrl || "未配置"}
hint="云端 OpenAI 兼容网关地址"
/>
<SessionValueCard
label="租户"
value={configuredTarget?.tenantId || "未配置"}
hint="个人中心登录所属租户"
/>
</div>
</div>
{errorMessage ? (
<NoticeBar tone="error" message={errorMessage} />
) : null}
{infoMessage ? (
<NoticeBar tone="success" message={infoMessage} />
) : null}
{!runtime ? (
<div className="rounded-[24px] border border-dashed border-slate-300 bg-white/80 px-5 py-6 text-sm leading-6 text-slate-600">
当前没有可用的运行时配置。请先在
<span className="mx-1 rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
public/oem-runtime-config.js
</span>
中配置域名、网关地址和租户信息,然后再进行登录。
</div>
) : initializing ? (
<div className="rounded-[24px] border border-white/90 bg-white/84 p-5 shadow-sm">
<div className="flex items-center gap-3 text-sm text-slate-600">
<LoaderCircle className="h-4 w-4 animate-spin" />
正在恢复个人中心会话...
</div>
</div>
) : session ? (
<div
className="grid gap-4 xl:grid-cols-[minmax(0,2.2fr)_minmax(300px,1fr)]"
data-testid="oem-cloud-session-panel"
>
<article className={SURFACE_CLASS_NAME}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-slate-200 bg-slate-100 text-slate-700">
<Cloud className="h-5 w-5" />
</div>
<div>
<p className="text-lg font-semibold text-slate-900">
{session.user.displayName || session.user.email || "已登录"}
</p>
<p className="text-sm text-slate-500">
{session.user.email || session.user.username || session.user.id}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-5">
<SessionValueCard
label="会话到期"
value={formatOemCloudDateTime(session.session.expiresAt)}
hint="会话过期后需重新登录"
/>
<SessionValueCard
label="服务技能"
value={`${resolveServiceSkillCount(bootstrap?.serviceSkillCatalog)} 项`}
hint="登录后自动同步到本地"
/>
<SessionValueCard
label="Scene 入口"
value={`${bootstrap?.sceneCatalog?.length || 0} 项`}
hint="来自个人中心 bootstrap"
/>
<SessionValueCard
label="默认来源"
value={defaultProviderSummary || "未设定"}
hint="登录后自动同步当前云端默认来源"
/>
<SessionValueCard
label="Gateway"
value={bootstrap?.gateway?.basePath || "/gateway-api"}
hint="OpenAI 兼容调用入口"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
data-testid="oem-cloud-refresh"
>
<RefreshCw
className={cn("h-4 w-4", refreshing && "animate-spin")}
/>
刷新云端状态
</button>
<button
type="button"
onClick={() => void openUserCenter("")}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<ExternalLink className="h-4 w-4" />
打开用户中心
</button>
<button
type="button"
onClick={() => void handleLogout()}
disabled={loggingOut}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60"
data-testid="oem-cloud-logout"
>
<LogOut className="h-4 w-4" />
{loggingOut ? "退出中..." : "退出登录"}
</button>
</div>
</div>
</article>
<article className={SURFACE_CLASS_NAME}>
<div className="space-y-3">
<h3 className="text-base font-semibold text-slate-900">
会话说明
</h3>
<p className="text-sm leading-6 text-slate-600">
Providers 页里的 {hubProviderName} 云服务入口不再单独维护登录表单,而是直接读取这里的会话。你在这里刷新、退出之后,上游云端目录和下游面板都会一起同步。
</p>
{codeDelivery ? (
<p className="text-sm leading-6 text-slate-600">
最近一次验证码已发送到 {codeDelivery.maskedEmail}。
</p>
) : null}
</div>
</article>
</div>
) : (
<article
className={SURFACE_CLASS_NAME}
data-testid="oem-cloud-login-panel"
>
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.75fr)_minmax(280px,0.95fr)]">
<div className="space-y-4">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">
使用 Google 一键登录
</h3>
<p className="text-sm leading-6 text-slate-600">
Google 是默认登录方式。授权完成后桌面端会自动完成登录,并同步默认云端来源、服务技能目录与 Scene 入口。
</p>
</div>
<button
type="button"
onClick={() => void handleGoogleLogin()}
disabled={openingGoogleLogin}
className="flex w-full items-center gap-4 rounded-[22px] border border-slate-200 bg-white px-4 py-4 text-left shadow-sm shadow-slate-950/5 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
data-testid="oem-cloud-google-login"
>
<span className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl border border-slate-200 bg-white">
<ProviderIcon providerType="google" size={22} />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-semibold text-slate-900">
{openingGoogleLogin
? "正在打开 Google 登录..."
: "使用 Google 一键登录"}
</span>
<span className="mt-1 block text-xs leading-5 text-slate-500">
在系统浏览器完成授权,桌面端会自动同步登录结果。
</span>
</span>
</button>
<div className="flex flex-wrap gap-3">
<button
type="button"
onClick={() => void openUserCenter(runtime.loginPath)}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<ExternalLink className="h-4 w-4" />
打开完整登录页
</button>
<button
type="button"
onClick={() =>
setShowAlternativeMethods((current) => !current)
}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-white"
data-testid="oem-cloud-toggle-alternative-login"
>
{showAlternativeMethods ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
{showAlternativeMethods
? "收起其他登录方式"
: "使用邮箱验证码 / 账号密码"}
</button>
</div>
</div>
<div className="rounded-[24px] border border-slate-200/80 bg-slate-50/90 p-5">
<div className="space-y-3">
<h4 className="text-sm font-semibold text-slate-900">
登录后自动完成
</h4>
<ul className="space-y-2 text-sm leading-6 text-slate-600">
<li>同步当前租户下的默认云端来源与模型目录。</li>
<li>把个人中心会话复用到 Providers 页,不再重复登录。</li>
<li>同步服务技能目录与 Scene 入口,形成完整闭环。</li>
</ul>
</div>
</div>
</div>
{showAlternativeMethods ? (
<div className="mt-5 rounded-[24px] border border-slate-200/80 bg-slate-50/80 p-4">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<h4 className="text-base font-semibold text-slate-900">
备用登录方式
</h4>
<p className="text-sm leading-6 text-slate-600">
如果当前租户没有启用 Google,或需要兼容已有账号体系,可以改用邮箱验证码或账号密码登录。
</p>
</div>
<div className="inline-flex rounded-full border border-slate-200 bg-white p-1">
<button
type="button"
onClick={() => setLoginMode("password")}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition",
loginMode === "password"
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900",
)}
>
账号密码
</button>
<button
type="button"
onClick={() => setLoginMode("email_code")}
className={cn(
"rounded-full px-3 py-1.5 text-xs font-medium transition",
loginMode === "email_code"
? "bg-slate-900 text-white"
: "text-slate-600 hover:text-slate-900",
)}
>
邮箱验证码
</button>
</div>
</div>
{loginMode === "password" ? (
<div className="mt-5 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
邮箱 / 账号
</label>
<input
value={passwordForm.identifier}
onChange={(event) =>
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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
密码
</label>
<input
type="password"
value={passwordForm.password}
onChange={(event) =>
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"
/>
</div>
<button
type="button"
onClick={() => void handlePasswordLogin()}
disabled={loggingIn}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-3 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60"
data-testid="oem-cloud-password-submit"
>
<LogIn className="h-4 w-4" />
{loggingIn ? "登录中..." : "登录并同步云端"}
</button>
</div>
) : (
<div className="mt-5 space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
邮箱 / 账号
</label>
<input
value={emailCodeForm.identifier}
onChange={(event) =>
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"
/>
</div>
<div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_auto]">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
验证码
</label>
<input
value={emailCodeForm.code}
onChange={(event) =>
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"
/>
</div>
<button
type="button"
onClick={() => void handleSendEmailCode()}
disabled={sendingCode}
className="self-end rounded-[18px] border border-slate-200 bg-white px-4 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
data-testid="oem-cloud-code-send"
>
{sendingCode ? "发送中..." : "发送验证码"}
</button>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
首次登录昵称
</label>
<input
value={emailCodeForm.displayName || ""}
onChange={(event) =>
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"
/>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-700">
首次登录账号
</label>
<input
value={emailCodeForm.username || ""}
onChange={(event) =>
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"
/>
</div>
</div>
<button
type="button"
onClick={() => void handleEmailCodeLogin()}
disabled={loggingIn}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-3 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60"
data-testid="oem-cloud-code-submit"
>
<LogIn className="h-4 w-4" />
{loggingIn ? "登录中..." : "验证并同步云端"}
</button>
</div>
)}
</div>
) : null}
</article>
)}
</div>
</div>
</section>
);
}
export default UserCenterSessionSettings;
@@ -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: () => <div data-testid="provider-pool-stub">凭证池占位</div>,
}));
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<string, unknown> = {}) {
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<string, unknown> = {}) {
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(<CloudProviderSettings {...props} />);
});
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);
});
});
@@ -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 (
<div className="rounded-[22px] border border-slate-200/80 bg-white p-4 shadow-sm shadow-slate-950/5">
<div className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-[0.14em] text-slate-500">
{props.icon ? <span className="text-slate-400">{props.icon}</span> : null}
<span>{props.label}</span>
</div>
<p className="mt-2 break-all text-sm font-semibold text-slate-900">
{props.value}
</p>
<p className="mt-2 text-xs leading-5 text-slate-500">{props.hint}</p>
</div>
);
}
function NoticeBar(props: {
tone: "error" | "success";
message: string;
}) {
return (
<div
className={cn(
"flex items-center gap-3 rounded-[20px] border px-4 py-3 text-sm shadow-sm shadow-slate-950/5",
props.tone === "success"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: "border-rose-200 bg-rose-50 text-rose-700",
)}
>
{props.tone === "success" ? (
<CheckCircle2 className="h-4 w-4 flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 flex-shrink-0" />
)}
<span>{props.message}</span>
</div>
);
}
function InfoPill(props: { label: string; tone?: "slate" | "emerald" | "amber" }) {
return (
<span
className={cn(
"inline-flex rounded-full border px-2.5 py-1 text-xs font-medium",
props.tone === "emerald"
? "border-emerald-200 bg-emerald-50 text-emerald-700"
: props.tone === "amber"
? "border-amber-200 bg-amber-50 text-amber-700"
: "border-slate-200 bg-slate-50 text-slate-600",
)}
>
{props.label}
</span>
);
}
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 (
<div className="space-y-6">
<section className="relative overflow-hidden rounded-[30px] border border-emerald-200/70 bg-[linear-gradient(135deg,rgba(245,250,248,0.98)_0%,rgba(255,255,255,0.98)_56%,rgba(242,247,255,0.96)_100%)] shadow-sm shadow-slate-950/5">
<div className="pointer-events-none absolute -left-20 top-[-72px] h-52 w-52 rounded-full bg-emerald-200/25 blur-3xl" />
<div className="pointer-events-none absolute right-[-68px] top-[-24px] h-52 w-52 rounded-full bg-sky-200/30 blur-3xl" />
<div className="relative flex flex-col gap-6 p-6 lg:p-7">
<div className="flex flex-col gap-6 xl:flex-row xl:items-start xl:justify-between">
<div className="max-w-3xl space-y-3">
<span className="inline-flex items-center rounded-full border border-emerald-200 bg-white px-3 py-1 text-xs font-semibold tracking-[0.16em] text-emerald-700 shadow-sm">
CLOUD ACCESS
</span>
<div className="space-y-2">
<h2 className="text-3xl font-semibold tracking-tight text-slate-900">
{hubProviderName} 云端接入
</h2>
<p className="max-w-2xl text-sm leading-6 text-slate-600">
这里展示云端会话、默认来源、套餐状态和模型目录;下方的 Provider 面板只负责本地 / 第三方开发者配置,避免把云端消费态和 API Key 管理混在一起。
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:min-w-[640px] xl:grid-cols-3">
<SessionValueCard
label="用户中心"
value={configuredTarget?.baseUrl || "未配置"}
hint="控制面、登录页和账户资料入口"
icon={<Cloud className="h-3.5 w-3.5" />}
/>
<SessionValueCard
label="Gateway"
value={runtime?.gatewayBaseUrl || "未配置"}
hint="云端 OpenAI 兼容调用入口"
icon={<ExternalLink className="h-3.5 w-3.5" />}
/>
<SessionValueCard
label="默认来源"
value={defaultProviderSummary || "未设定"}
hint={`当前来源类型:${defaultProviderSourceLabel}`}
icon={<Layers3 className="h-3.5 w-3.5" />}
/>
<SessionValueCard
label="接入模式"
value={activeAccessModeLabel}
hint="由服务端最终态决定,不由客户端猜测"
icon={<ShieldCheck className="h-3.5 w-3.5" />}
/>
<SessionValueCard
label="配置模式"
value={activeConfigModeLabel}
hint="区分托管、混合和开发者治理形态"
icon={<Database className="h-3.5 w-3.5" />}
/>
<SessionValueCard
label="开发者入口"
value={activeDeveloperAccessLabel}
hint={`模型来源:${activeModelsSourceLabel}`}
icon={<KeyRound className="h-3.5 w-3.5" />}
/>
</div>
</div>
{errorMessage ? <NoticeBar tone="error" message={errorMessage} /> : null}
{infoMessage ? <NoticeBar tone="success" message={infoMessage} /> : null}
{!runtime ? (
<div className="rounded-[24px] border border-dashed border-slate-300 bg-white px-5 py-6 text-sm leading-6 text-slate-600">
当前没有可用的运行时配置。请在
<span className="mx-1 rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-700">
public/oem-runtime-config.js
</span>
中配置域名、网关地址和租户信息,再继续接入云端服务。
</div>
) : initializing ? (
<div className="rounded-[24px] border border-slate-200/80 bg-white p-5 shadow-sm">
<div className="flex items-center gap-3 text-sm text-slate-600">
<LoaderCircle className="h-4 w-4 animate-spin" />
正在恢复个人中心会话...
</div>
</div>
) : session ? (
<div className="space-y-4">
<div
className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,0.95fr)]"
data-testid="oem-cloud-session-summary"
>
<article className={SURFACE_CLASS_NAME}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl border border-slate-200 bg-slate-100 text-slate-700">
<Cloud className="h-5 w-5" />
</div>
<div>
<p className="text-lg font-semibold text-slate-900">
{session.user.displayName || session.user.email || "已登录"}
</p>
<p className="text-sm text-slate-500">
{session.user.email || session.user.username || session.user.id}
</p>
</div>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<SessionValueCard
label="租户"
value={session.tenant.id}
hint="当前云端会话所属租户"
/>
<SessionValueCard
label="到期时间"
value={formatOemCloudDateTime(session.session.expiresAt)}
hint="会话过期后需重新登录"
/>
<SessionValueCard
label="当前云服务"
value={activeCloudOffer?.displayName || hubProviderName}
hint="当前聚焦的云端来源"
/>
</div>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => void handleRefresh()}
disabled={refreshing}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
data-testid="oem-cloud-refresh"
>
<RefreshCw
className={cn("h-4 w-4", refreshing && "animate-spin")}
/>
刷新云端状态
</button>
<button
type="button"
onClick={() => props.onOpenProfile?.()}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<LogIn className="h-4 w-4" />
前往个人中心管理会话
</button>
<button
type="button"
onClick={() => void openUserCenter("")}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800"
>
<ExternalLink className="h-4 w-4" />
打开用户中心
</button>
</div>
</div>
</article>
<article className={SURFACE_CLASS_NAME}>
<div className="space-y-3">
<h3 className="text-base font-semibold text-slate-900">
当前治理结果
</h3>
<p className="text-sm leading-6 text-slate-600">
默认来源:{defaultProviderSummary || "未设定"}。当前接入模式为
{activeAccessModeLabel},配置模式为 {activeConfigModeLabel},
模型目录来自 {activeModelsSourceLabel}。
</p>
<p className="text-sm leading-6 text-slate-600">
开发者入口{activeDeveloperAccessEnabled ? "已开放" : "未开放"}。
当后台关闭 API Key 模式时,这里不会再把云端服务伪装成本地 API Key Provider。
</p>
</div>
</article>
</div>
<section className="space-y-4">
<div className="flex flex-col gap-2">
<h3 className="text-lg font-semibold text-slate-900">
云端服务目录
</h3>
<p className="text-sm leading-6 text-slate-600">
每个卡片都来自服务端最终态。是否允许 API Key 模式、当前模型来源、租户覆盖是否生效,都以服务端返回为准。
</p>
</div>
{offers.length === 0 ? (
<article className={SURFACE_CLASS_NAME}>
<p className="text-sm leading-6 text-slate-600">
当前租户还没有可用的云端服务来源。请先在后台发布可见 Offer。
</p>
</article>
) : (
<div className="grid gap-4 xl:grid-cols-[minmax(0,1.35fr)_minmax(320px,0.95fr)]">
<div className="grid gap-4 lg:grid-cols-2">
{offers.map((offer) => {
const isDefaultCloudOffer =
preference?.providerSource === "oem_cloud" &&
preference.providerKey === offer.providerKey;
const isFocused = selectedOfferKey === offer.providerKey;
const stateTone = resolveOfferTone(offer.state);
return (
<article
key={offer.providerKey}
className={cn(
SURFACE_CLASS_NAME,
isFocused && "border-emerald-300 shadow-emerald-100",
)}
>
<div className="space-y-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-base font-semibold text-slate-900">
{offer.displayName}
</h4>
{isDefaultCloudOffer ? (
<InfoPill label="云端默认" tone="emerald" />
) : null}
{offer.tenantOverrideApplied ? (
<InfoPill label="租户覆盖已生效" tone="amber" />
) : null}
</div>
<p className="text-sm leading-6 text-slate-600">
{offer.description || "当前来源暂无额外说明。"}
</p>
</div>
<InfoPill
label={formatOemCloudOfferStateLabel(offer.state)}
tone={stateTone}
/>
</div>
<div className="flex flex-wrap gap-2">
<InfoPill
label={formatOemCloudAccessModeLabel(
offer.effectiveAccessMode,
)}
/>
<InfoPill
label={formatOemCloudConfigModeLabel(offer.configMode)}
/>
<InfoPill
label={formatOemCloudModelsSourceLabel(
offer.modelsSource,
)}
/>
<InfoPill
label={`${offer.availableModelCount} 个模型`}
/>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<SessionValueCard
label="套餐 / 状态"
value={offer.currentPlan || "未显示"}
hint={
offer.creditsSummary ||
offer.statusReason ||
"由控制面统一下发"
}
/>
<SessionValueCard
label="开发者入口"
value={
offer.apiKeyModeEnabled
? offer.developerAccessVisible
? "可见"
: "已隐藏"
: "已关闭"
}
hint="同时受后台 API Key 模式与显示治理控制"
/>
</div>
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => void openOfferDetail(offer.providerKey)}
disabled={loadingDetail && isFocused}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50 disabled:opacity-60"
>
{loadingDetail && isFocused ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<Layers3 className="h-4 w-4" />
)}
查看模型目录
</button>
<button
type="button"
onClick={() => void handleSetDefault(offer)}
disabled={savingDefault === offer.providerKey}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800 disabled:opacity-60"
>
{savingDefault === offer.providerKey ? (
<LoaderCircle className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
{isDefaultCloudOffer ? "已是默认来源" : "设为默认来源"}
</button>
</div>
</div>
</article>
);
})}
</div>
<article className={SURFACE_CLASS_NAME}>
{selectedOffer ? (
<div className="space-y-4">
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h4 className="text-base font-semibold text-slate-900">
{selectedOffer.displayName}
</h4>
<InfoPill
label={formatOemCloudOfferStateLabel(selectedOffer.state)}
tone={resolveOfferTone(selectedOffer.state)}
/>
</div>
<p className="text-sm leading-6 text-slate-600">
当前实际接入方式为
{formatOemCloudAccessModeLabel(
selectedOffer.access.accessMode,
)}
,共下发 {selectedModels.length} 个模型目录项。
</p>
</div>
<div className="grid gap-3 sm:grid-cols-3">
<SessionValueCard
label="模型目录"
value={`${selectedModels.length} 项`}
hint="来自当前选中来源的服务端目录"
/>
<SessionValueCard
label="模型来源"
value={formatOemCloudModelsSourceLabel(
selectedOffer.modelsSource,
)}
hint="决定模型列表来自云端目录还是手动编排"
/>
<SessionValueCard
label="开发者入口"
value={
selectedOffer.apiKeyModeEnabled
? selectedOffer.developerAccessVisible
? "可见"
: "已隐藏"
: "已关闭"
}
hint="后台可按 Offer / 租户治理"
/>
</div>
{selectedOffer.loginHint ? (
<p className="rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3 text-sm leading-6 text-slate-600">
登录提示:{selectedOffer.loginHint}
</p>
) : null}
{selectedOffer.subscribeHint ? (
<p className="rounded-[18px] border border-amber-200 bg-amber-50 px-4 py-3 text-sm leading-6 text-amber-700">
套餐提示:{selectedOffer.subscribeHint}
</p>
) : null}
{selectedOffer.unavailableHint ? (
<p className="rounded-[18px] border border-rose-200 bg-rose-50 px-4 py-3 text-sm leading-6 text-rose-700">
不可用提示:{selectedOffer.unavailableHint}
</p>
) : null}
<div className="space-y-3">
<h5 className="text-sm font-semibold text-slate-900">
模型目录
</h5>
{loadingDetail ? (
<div className="flex items-center gap-3 rounded-[18px] border border-slate-200 bg-slate-50 px-4 py-3 text-sm text-slate-600">
<LoaderCircle className="h-4 w-4 animate-spin" />
正在加载模型目录...
</div>
) : selectedModels.length > 0 ? (
<div className="space-y-2 rounded-[22px] border border-slate-200/80 bg-slate-50/70 p-3">
{selectedModels.map((model) => (
<div
key={model.id}
className="rounded-[18px] border border-slate-200/80 bg-white px-3 py-3"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-sm font-semibold text-slate-900">
{model.displayName}
</p>
<p className="mt-1 text-xs text-slate-500">
{model.modelId}
</p>
</div>
{model.recommended ? (
<InfoPill label="推荐" tone="emerald" />
) : null}
</div>
{model.description ? (
<p className="mt-2 text-xs leading-5 text-slate-500">
{model.description}
</p>
) : null}
</div>
))}
</div>
) : (
<div className="rounded-[18px] border border-dashed border-slate-300 bg-slate-50 px-4 py-3 text-sm text-slate-500">
当前来源还没有下发模型目录。
</div>
)}
</div>
</div>
) : (
<div className="flex h-full flex-col items-center justify-center rounded-[22px] border border-dashed border-slate-300 bg-slate-50 px-5 py-8 text-center">
<Layers3 className="h-8 w-8 text-slate-400" />
<p className="mt-3 text-sm font-medium text-slate-700">
选择一个云端来源查看详情
</p>
<p className="mt-2 text-sm leading-6 text-slate-500">
这里会展示模型目录、实际接入方式和后台治理结果。
</p>
</div>
)}
</article>
</div>
)}
</section>
</div>
) : (
<div className="grid gap-4 xl:grid-cols-[minmax(0,2fr)_minmax(280px,0.95fr)]">
<article className={SURFACE_CLASS_NAME}>
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">
先在个人中心登录
</h3>
<p className="text-sm leading-6 text-slate-600">
当前还没有可用的个人中心会话。登录后,云端默认来源、模型目录和服务技能目录会自动同步到本地。
</p>
</div>
<div className="flex flex-col gap-2">
<button
type="button"
onClick={() => props.onOpenProfile?.()}
className="inline-flex items-center justify-center gap-2 rounded-[18px] bg-slate-900 px-4 py-2.5 text-sm font-medium text-white transition hover:bg-slate-800"
data-testid="open-profile-login"
>
<LogIn className="h-4 w-4" />
去个人中心登录
</button>
<button
type="button"
onClick={() =>
void openUserCenter(runtime?.loginPath || "/login")
}
className="inline-flex items-center justify-center gap-2 rounded-[18px] border border-slate-200 bg-white px-4 py-2.5 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:bg-slate-50"
>
<ExternalLink className="h-4 w-4" />
在浏览器打开登录页
</button>
</div>
</div>
</article>
<article className={SURFACE_CLASS_NAME}>
<div className="space-y-3">
<h3 className="text-base font-semibold text-slate-900">
接入说明
</h3>
<p className="text-sm leading-6 text-slate-600">
云端服务默认通过登录会话消费。是否开放开发者 API Key 模式、是否允许租户覆盖,都由后台统一治理并在这里展示最终态。
</p>
</div>
</article>
</div>
)}
</div>
</section>
<section id="local-provider-management" className="space-y-4">
<article className={SURFACE_CLASS_NAME}>
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-2">
<h3 className="text-lg font-semibold text-slate-900">
本地 / 其它开发者 Provider
</h3>
<p className="max-w-3xl text-sm leading-6 text-slate-600">
{localProviderHint}
</p>
</div>
<InfoPill
label={
activeDeveloperAccessEnabled
? "云端开发者入口已开放"
: "云端开发者入口未开放"
}
tone={activeDeveloperAccessEnabled ? "emerald" : "slate"}
/>
</div>
</article>
<ProviderPoolPage hideHeader />
</section>
</div>
);
}
export default CloudProviderSettings;
+28 -3
View File
@@ -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),
+46 -1
View File
@@ -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,
+222
View File
@@ -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<T>() {
let resolve!: (value: T | PromiseLike<T>) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((nextResolve, nextReject) => {
resolve = nextResolve;
reject = nextReject;
});
return {
promise,
resolve,
reject,
};
}
let latestState: ReturnType<typeof useOemCloudAccess> | null = null;
function HookHarness() {
latestState = useOemCloudAccess();
return (
<div data-testid="hook-state">
{latestState.initializing
? "initializing"
: latestState.session?.session.id || "anonymous"}
</div>
);
}
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<typeof bootstrapPayload>();
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(<HookHarness />);
});
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");
});
});
File diff suppressed because it is too large Load Diff
@@ -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 <div data-testid="oem-lime-hub-provider-sync" />;
}
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(<HookHarness />);
});
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(<HookHarness />);
});
await flushEffects();
expect(controlPlaneMocks.listClientProviderOfferModels).not.toHaveBeenCalled();
expect(apiKeyProviderMocks.updateProvider).toHaveBeenCalledWith("lime-hub", {
custom_models: [],
});
});
});
+174
View File
@@ -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<typeof resolveOemCloudRuntimeContext>,
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<ReturnType<typeof resolveOemCloudRuntimeContext>>,
): Promise<string[]> {
if (!runtime.sessionToken) {
return [];
}
const snapshot =
getOemCloudBootstrapSnapshot<OemCloudBootstrapResponse>();
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<string>("");
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]);
}
@@ -0,0 +1,12 @@
import { useEffect } from "react";
import {
applyInitialServiceSkillCatalogBootstrap,
subscribeServiceSkillCatalogBootstrap,
} from "@/lib/serviceSkillCatalogBootstrap";
export function useServiceSkillCatalogBootstrap(): void {
useEffect(() => {
applyInitialServiceSkillCatalogBootstrap();
return subscribeServiceSkillCatalogBootstrap();
}, []);
}
+241
View File
@@ -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",
},
});
});
});
File diff suppressed because it is too large Load Diff
+98
View File
@@ -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",
});
});
});
+365
View File
@@ -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<string, unknown> {
return Boolean(value) && typeof value === "object";
}
function readRecordText(
record: Record<string, unknown>,
...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);
}
+126
View File
@@ -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");
});
});
+209 -14
View File
@@ -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<ServiceSkillItem>;
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<ServiceSkillCatalog> {
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<ServiceSkillCatalogChangeSource, "seeded_fallback" | "cache_clear"> = "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<ServiceSkillCatalog> {
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<ServiceSkillCatalog> {
return seeded;
}
export async function refreshServiceSkillCatalogFromRemote(): Promise<ServiceSkillCatalog | null> {
const runtime = resolveOemCloudRuntimeContext();
if (!runtime || !hasOemCloudSession(runtime)) {
return null;
}
const catalog = await requestRemoteServiceSkillCatalog();
return saveServiceSkillCatalog(catalog, "bootstrap_sync");
}
export async function listServiceSkills(): Promise<ServiceSkillItem[]> {
const catalog = await getServiceSkillCatalog();
return catalog.items.filter((item) => item.source === "cloud_catalog");
+30
View File
@@ -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,
});
});
});
+203
View File
@@ -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<string, unknown> {
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<OemCloudDesktopOAuthCompletedDetail>(
OEM_CLOUD_OAUTH_COMPLETED_EVENT,
{
detail,
},
),
);
}
export async function completeOemCloudDesktopOAuthLogin(
payload: OemCloudDesktopOAuthCallbackPayload,
): Promise<OemCloudBootstrapResponse> {
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;
}
}
+106
View File
@@ -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();
});
});
+372
View File
@@ -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<string, unknown> {
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 = unknown>(): 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,
);
};
}
+55
View File
@@ -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);
});
});
+68
View File
@@ -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<OemCloudRuntimeContext, "gatewayBaseUrl"> | null | undefined,
): string | null {
const gatewayBaseUrl = normalizeBaseUrl(runtime?.gatewayBaseUrl);
return gatewayBaseUrl || null;
}
export function resolveOemLimeHubProviderName(
runtime:
| Pick<OemCloudRuntimeContext, "hubProviderName">
| null
| undefined,
): string {
return (
normalizeText(runtime?.hubProviderName) || DEFAULT_OEM_LIME_HUB_PROVIDER_NAME
);
}
export function isLegacyProxyCastHubProvider(
provider: Pick<ProviderWithKeysDisplay, "id" | "name" | "api_host">,
): 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<ProviderWithKeysDisplay, "id" | "name" | "api_host">,
): boolean {
const providerId = normalizeText(provider.id).toLowerCase();
return (
providerId === OEM_LIME_HUB_PROVIDER_ID ||
isLegacyProxyCastHubProvider(provider)
);
}
@@ -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");
});
});
+109
View File
@@ -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<string, unknown>): 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<string, unknown>);
}
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);
};
}