mirror of
https://github.com/aiclientproxy/proxycast.git
synced 2026-09-24 23:10:56 +08:00
feat: finalize oem cloud access integration
This commit is contained in:
+19
-19
@@ -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
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "lime",
|
||||
"private": true,
|
||||
"version": "0.95.0",
|
||||
"version": "0.95.1",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
|
||||
@@ -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";
|
||||
*/
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}, []);
|
||||
}
|
||||
@@ -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
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user