feat: prepare Zhinian desktop client for pilot release
This commit is contained in:
54
electron/yinian/control-plane.ts
Normal file
54
electron/yinian/control-plane.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import type {
|
||||
YinianAuthSession,
|
||||
YinianConfigSnapshot,
|
||||
YinianImageCaptcha,
|
||||
YinianLoginWithPasswordInput,
|
||||
YinianLoginWithSmsInput,
|
||||
YinianLocalSkill,
|
||||
YinianServerStatus,
|
||||
YinianSessionState,
|
||||
YinianSkillRegistry,
|
||||
YinianSkillSyncResult,
|
||||
} from '../../shared/yinian';
|
||||
import { HttpYinianControlPlane } from './http-control-plane';
|
||||
import { MockYinianControlPlane } from './mock-control-plane';
|
||||
import { getYinianStorage } from './storage';
|
||||
import { UnconfiguredYinianControlPlane } from './unconfigured-control-plane';
|
||||
|
||||
const DEFAULT_YINIAN_API_BASE_URL = 'https://onefeel.brother7.cn/ingress';
|
||||
|
||||
export interface YinianControlPlane {
|
||||
getServerStatus(): Promise<YinianServerStatus>;
|
||||
createImageCaptcha(randomStr?: string): Promise<YinianImageCaptcha>;
|
||||
restoreSession(): Promise<YinianSessionState>;
|
||||
getSessionState(): Promise<YinianSessionState>;
|
||||
loginWithSms(input: YinianLoginWithSmsInput): Promise<YinianAuthSession>;
|
||||
loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession>;
|
||||
logout(): Promise<YinianSessionState>;
|
||||
switchHotel(hotelId: string): Promise<YinianAuthSession>;
|
||||
getConfigSnapshot(): Promise<YinianConfigSnapshot>;
|
||||
syncSkills(): Promise<YinianSkillSyncResult>;
|
||||
listLocalSkills(): Promise<YinianLocalSkill[]>;
|
||||
getSkillRegistry(hotelId?: string): Promise<YinianSkillRegistry | Record<string, YinianSkillRegistry> | undefined>;
|
||||
}
|
||||
|
||||
let controlPlane: YinianControlPlane | null = null;
|
||||
|
||||
export function getYinianControlPlane(): YinianControlPlane {
|
||||
if (controlPlane) return controlPlane;
|
||||
|
||||
const apiBaseUrl = process.env.YINIAN_API_BASE_URL?.trim() || DEFAULT_YINIAN_API_BASE_URL;
|
||||
const explicitMode = process.env.YINIAN_CONTROL_PLANE_MODE?.trim();
|
||||
const shouldUseMock = explicitMode === 'mock' || process.env.CLAWX_E2E === '1';
|
||||
controlPlane = shouldUseMock
|
||||
? new MockYinianControlPlane({ storage: getYinianStorage() })
|
||||
: apiBaseUrl
|
||||
? new HttpYinianControlPlane({ apiBaseUrl, storage: getYinianStorage() })
|
||||
: new UnconfiguredYinianControlPlane();
|
||||
|
||||
return controlPlane;
|
||||
}
|
||||
|
||||
export function resetYinianControlPlaneForTests(next?: YinianControlPlane): void {
|
||||
controlPlane = next ?? null;
|
||||
}
|
||||
804
electron/yinian/http-control-plane.ts
Normal file
804
electron/yinian/http-control-plane.ts
Normal file
@@ -0,0 +1,804 @@
|
||||
import type {
|
||||
YinianAuthSession,
|
||||
YinianConfigSnapshot,
|
||||
YinianHotel,
|
||||
YinianImageCaptcha,
|
||||
YinianNotificationChannel,
|
||||
YinianSkillEntitlement,
|
||||
YinianLoginWithPasswordInput,
|
||||
YinianLoginWithSmsInput,
|
||||
YinianLocalSkill,
|
||||
YinianServerStatus,
|
||||
YinianSessionState,
|
||||
YinianSkillSyncResult,
|
||||
YinianUser,
|
||||
} from '../../shared/yinian';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { YinianControlPlane } from './control-plane';
|
||||
import { getYinianStorage, type YinianStorage } from './storage';
|
||||
|
||||
interface HttpYinianControlPlaneOptions {
|
||||
apiBaseUrl: string;
|
||||
storage?: YinianStorage;
|
||||
}
|
||||
|
||||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
const DEFAULT_ACCESS_TOKEN_TTL_MS = 30 * 60 * 1000;
|
||||
const SKILL_CATEGORIES: YinianSkillEntitlement['category'][] = [
|
||||
'ota-monitoring',
|
||||
'reporting',
|
||||
'guest-comm',
|
||||
'ops-automation',
|
||||
];
|
||||
const SKILL_TRIGGERS: Array<YinianSkillEntitlement['triggers'][number]> = [
|
||||
'manual',
|
||||
'scheduled',
|
||||
'webhook',
|
||||
'reply',
|
||||
];
|
||||
const DEFAULT_OAUTH_CLIENT_ID = 'customPC';
|
||||
const DEFAULT_OAUTH_CLIENT_SECRET = 'customPC';
|
||||
const DEFAULT_OAUTH_SCOPE = 'server';
|
||||
const DEFAULT_CONFIG_SYNC_PATH = '/config/sync';
|
||||
const DEFAULT_SKILLS_MANIFEST_PATH = '/skills/manifest';
|
||||
|
||||
export class HttpYinianControlPlane implements YinianControlPlane {
|
||||
private readonly apiBaseUrl: string;
|
||||
private readonly storage: YinianStorage;
|
||||
private accessToken: string | null = null;
|
||||
private refreshToken: string | null = null;
|
||||
private currentHotelId: string | null = null;
|
||||
private localSkills: YinianLocalSkill[] = [];
|
||||
|
||||
constructor(options: HttpYinianControlPlaneOptions) {
|
||||
this.apiBaseUrl = options.apiBaseUrl.replace(/\/+$/, '');
|
||||
this.storage = options.storage ?? getYinianStorage();
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<YinianServerStatus> {
|
||||
const checkedAt = Date.now();
|
||||
try {
|
||||
const health = await this.request<JsonObject>('/health', { auth: false });
|
||||
return {
|
||||
mode: 'http',
|
||||
apiBaseUrl: this.apiBaseUrl,
|
||||
reachable: true,
|
||||
checkedAt,
|
||||
serverTime: readNumber(health, 'serverTime', 'server_time'),
|
||||
version: readString(health, 'version', 'appVersion', 'app_version'),
|
||||
message: readString(health, 'message', 'status') ?? '服务端连接正常',
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
mode: 'http',
|
||||
apiBaseUrl: this.apiBaseUrl,
|
||||
reachable: false,
|
||||
checkedAt,
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async createImageCaptcha(randomStr = createRandomString()): Promise<YinianImageCaptcha> {
|
||||
const path = `/auth/code/image?randomStr=${encodeURIComponent(randomStr)}`;
|
||||
const requestUrl = `${this.apiBaseUrl}${path}`;
|
||||
const response = await fetch(requestUrl, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-YINIAN-App-Version': '0.1.0',
|
||||
},
|
||||
}).catch((error: unknown) => {
|
||||
throw new Error(describeNetworkError(error, requestUrl));
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||||
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') ?? '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||||
return normalizeImageCaptcha(data, randomStr);
|
||||
}
|
||||
|
||||
const imageBytes = Buffer.from(await response.arrayBuffer());
|
||||
return {
|
||||
randomStr,
|
||||
imageBase64: imageBytes.toString('base64'),
|
||||
mimeType: contentType.split(';')[0] || 'image/png',
|
||||
raw: {
|
||||
contentType,
|
||||
size: imageBytes.byteLength,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async restoreSession(): Promise<YinianSessionState> {
|
||||
const persisted = await this.storage.getSession();
|
||||
if (!persisted || persisted.mode !== 'http') {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
this.refreshToken = persisted.refreshToken ?? null;
|
||||
this.currentHotelId = persisted.currentHotelId;
|
||||
if (this.refreshToken) {
|
||||
try {
|
||||
return await this.refreshSession();
|
||||
} catch {
|
||||
await this.storage.clearSession();
|
||||
this.refreshToken = null;
|
||||
this.currentHotelId = null;
|
||||
return { authenticated: false };
|
||||
}
|
||||
}
|
||||
|
||||
// Access tokens intentionally remain memory-only. Until refresh-token
|
||||
// exchange is implemented, restore returns the persisted tenant snapshot so
|
||||
// the shell can recover UI context without exposing provider/model keys.
|
||||
return {
|
||||
authenticated: true,
|
||||
user: persisted.user,
|
||||
hotels: persisted.hotels,
|
||||
currentHotelId: persisted.currentHotelId,
|
||||
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
async getSessionState(): Promise<YinianSessionState> {
|
||||
if (!this.accessToken) return this.restoreSession();
|
||||
|
||||
const persisted = await this.storage.getSession();
|
||||
if (persisted?.mode === 'http') {
|
||||
return {
|
||||
authenticated: true,
|
||||
user: persisted.user,
|
||||
hotels: persisted.hotels,
|
||||
currentHotelId: persisted.currentHotelId,
|
||||
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
async loginWithSms(input: YinianLoginWithSmsInput): Promise<YinianAuthSession> {
|
||||
void input;
|
||||
throw new Error('当前组织空间端仅支持账号密码登录');
|
||||
}
|
||||
|
||||
async loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
|
||||
const response = await this.requestOAuthToken({
|
||||
grant_type: 'password',
|
||||
username: input.account,
|
||||
account: input.account,
|
||||
password: input.password,
|
||||
code: input.captchaCode,
|
||||
captchaCode: input.captchaCode,
|
||||
captcha_code: input.captchaCode,
|
||||
randomStr: input.randomStr,
|
||||
random_str: input.randomStr,
|
||||
});
|
||||
|
||||
return this.applyLoginResponse(response);
|
||||
}
|
||||
|
||||
async logout(): Promise<YinianSessionState> {
|
||||
const hotelId = this.currentHotelId;
|
||||
this.accessToken = null;
|
||||
this.refreshToken = null;
|
||||
this.currentHotelId = null;
|
||||
if (hotelId) {
|
||||
await this.storage.clearConfig(hotelId);
|
||||
await this.storage.clearSkillRegistry(hotelId);
|
||||
}
|
||||
await this.storage.clearSession();
|
||||
this.localSkills = [];
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
async switchHotel(hotelId: string): Promise<YinianAuthSession> {
|
||||
const session = await this.getSessionState();
|
||||
if (!session.authenticated) throw new Error('请先登录');
|
||||
const hotel = session.hotels.find((item) => item.id === hotelId);
|
||||
if (!hotel) throw new Error('工作空间不存在或未开通');
|
||||
this.currentHotelId = hotelId;
|
||||
const nextSession = { ...session, currentHotelId: hotelId };
|
||||
await this.persistSession(nextSession);
|
||||
const registry = await this.storage.getSkillRegistry(hotelId);
|
||||
this.localSkills = registry && 'skills' in registry ? registry.skills : [];
|
||||
return nextSession;
|
||||
}
|
||||
|
||||
async getConfigSnapshot(): Promise<YinianConfigSnapshot> {
|
||||
const session = await this.getSessionState();
|
||||
if (!session.authenticated) throw new Error('请先登录');
|
||||
|
||||
const hotelId = session.currentHotelId;
|
||||
const configPath = buildScopedEndpoint(
|
||||
process.env.YINIAN_CONFIG_SYNC_PATH?.trim() || DEFAULT_CONFIG_SYNC_PATH,
|
||||
hotelId,
|
||||
);
|
||||
let response: JsonObject;
|
||||
try {
|
||||
response = await this.request<JsonObject>(configPath);
|
||||
} catch (error) {
|
||||
if (isMissingServerResourceError(error)) {
|
||||
const snapshot = createDefaultConfigSnapshot(session);
|
||||
await this.storage.setConfig(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const hotel = normalizeHotel(response.hotel) ?? session.hotels.find((item) => item.id === hotelId) ?? session.hotels[0];
|
||||
|
||||
const snapshot = {
|
||||
serverTime: readNumber(response, 'serverTime', 'server_time') ?? Date.now(),
|
||||
user: session.user,
|
||||
hotel,
|
||||
hotels: session.hotels,
|
||||
entitlements: normalizeEntitlements(readManifestItems(response, 'entitlements')),
|
||||
notificationChannels: normalizeNotificationChannels(readArray(response, 'notificationChannels', 'notification_channels')),
|
||||
featureFlags: normalizeBooleanRecord(readObject(response, 'featureFlags', 'feature_flags')),
|
||||
uiPolicy: normalizeUiPolicy(readObject(response, 'uiPolicy', 'ui_policy')),
|
||||
};
|
||||
await this.storage.setConfig(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async syncSkills(): Promise<YinianSkillSyncResult> {
|
||||
const session = await this.getSessionState();
|
||||
if (!session.authenticated) throw new Error('请先登录');
|
||||
|
||||
const manifestPath = buildScopedEndpoint(
|
||||
process.env.YINIAN_SKILLS_MANIFEST_PATH?.trim() || DEFAULT_SKILLS_MANIFEST_PATH,
|
||||
session.currentHotelId,
|
||||
);
|
||||
const now = Date.now();
|
||||
let response: JsonObject;
|
||||
try {
|
||||
response = await this.request<JsonObject>(manifestPath);
|
||||
} catch (error) {
|
||||
if (isMissingServerResourceError(error)) {
|
||||
this.localSkills = [];
|
||||
await this.storage.setSkillRegistry({
|
||||
hotelId: session.currentHotelId,
|
||||
updatedAt: now,
|
||||
skills: [],
|
||||
});
|
||||
return {
|
||||
hotelId: session.currentHotelId,
|
||||
syncedAt: now,
|
||||
skills: [],
|
||||
};
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const skills = readManifestItems(response).filter(isObject).map((item) => normalizeLocalSkill(item, now));
|
||||
|
||||
const previousRegistry = await this.storage.getSkillRegistry(session.currentHotelId);
|
||||
const previousSkills = previousRegistry && 'skills' in previousRegistry ? previousRegistry.skills : [];
|
||||
this.localSkills = skills.map((skill) => {
|
||||
const previous = previousSkills.find((item) => item.skillId === skill.skillId);
|
||||
return {
|
||||
...skill,
|
||||
installedAt: previous?.installedAt ?? skill.installedAt,
|
||||
status: !skill.enabled ? 'disabled' : previous?.version === skill.version ? 'skipped' : previous ? 'updated' : skill.status,
|
||||
};
|
||||
});
|
||||
await this.storage.setSkillRegistry({
|
||||
hotelId: session.currentHotelId,
|
||||
updatedAt: now,
|
||||
skills: this.localSkills,
|
||||
});
|
||||
return {
|
||||
hotelId: session.currentHotelId,
|
||||
syncedAt: now,
|
||||
skills: this.localSkills,
|
||||
};
|
||||
}
|
||||
|
||||
async listLocalSkills(): Promise<YinianLocalSkill[]> {
|
||||
const session = await this.getSessionState();
|
||||
if (session.authenticated) {
|
||||
const registry = await this.storage.getSkillRegistry(session.currentHotelId);
|
||||
if (registry && 'skills' in registry) {
|
||||
this.localSkills = registry.skills;
|
||||
}
|
||||
}
|
||||
return this.localSkills;
|
||||
}
|
||||
|
||||
async getSkillRegistry(hotelId?: string) {
|
||||
return this.storage.getSkillRegistry(hotelId);
|
||||
}
|
||||
|
||||
private async applyLoginResponse(response: JsonObject): Promise<YinianAuthSession> {
|
||||
this.accessToken = readString(response, 'accessToken', 'access_token');
|
||||
this.refreshToken = readString(response, 'refreshToken', 'refresh_token');
|
||||
if (!this.accessToken) throw new Error('服务端未返回 access token');
|
||||
|
||||
const session = this.normalizeSessionFromPayload(response) ?? await this.getSessionState();
|
||||
if (!session.authenticated) throw new Error('登录成功但未获取到工作空间信息');
|
||||
this.currentHotelId = session.currentHotelId;
|
||||
await this.persistSession(session);
|
||||
return session;
|
||||
}
|
||||
|
||||
private async refreshSession(): Promise<YinianSessionState> {
|
||||
if (!this.refreshToken) return { authenticated: false };
|
||||
const response = await this.requestOAuthToken({
|
||||
grant_type: 'refresh_token',
|
||||
refreshToken: this.refreshToken,
|
||||
refresh_token: this.refreshToken,
|
||||
});
|
||||
return this.applyLoginResponse(response);
|
||||
}
|
||||
|
||||
private async persistSession(session: YinianAuthSession): Promise<void> {
|
||||
await this.storage.setSession({
|
||||
mode: 'http',
|
||||
user: session.user,
|
||||
hotels: session.hotels,
|
||||
currentHotelId: session.currentHotelId,
|
||||
accessTokenExpiresAt: session.accessTokenExpiresAt,
|
||||
refreshToken: this.refreshToken ?? undefined,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
private async requestOAuthToken(fields: Record<string, string | undefined>): Promise<JsonObject> {
|
||||
const clientId = process.env.YINIAN_AUTH_CLIENT_ID?.trim() || DEFAULT_OAUTH_CLIENT_ID;
|
||||
const headers = createOAuthHeaders(clientId);
|
||||
const clientBodyFields = headers.Authorization
|
||||
? {}
|
||||
: { clientId, client_id: clientId };
|
||||
return this.request<JsonObject>('/auth/oauth2/token', {
|
||||
method: 'POST',
|
||||
auth: false,
|
||||
form: {
|
||||
scope: process.env.YINIAN_AUTH_SCOPE?.trim() || DEFAULT_OAUTH_SCOPE,
|
||||
...clientBodyFields,
|
||||
...fields,
|
||||
},
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
private normalizeSessionFromPayload(payload: JsonObject): YinianAuthSession | null {
|
||||
const sessionPayload = readObject(payload, 'session') ?? payload;
|
||||
const userValue = readObject(sessionPayload, 'user', 'user_info', 'userInfo');
|
||||
if (!userValue) return null;
|
||||
|
||||
const user = normalizeUser(userValue);
|
||||
const hotelsValue = readArray(sessionPayload, 'hotels', 'workspaces', 'businesses', 'tenants');
|
||||
const hotels = normalizeHotels(hotelsValue);
|
||||
if (hotels.length === 0) {
|
||||
hotels.push(createDefaultWorkspace(sessionPayload, userValue, user));
|
||||
}
|
||||
const currentHotelId = readString(sessionPayload, 'currentHotelId', 'current_hotel_id', 'currentWorkspaceId', 'current_workspace_id', 'workspaceId', 'workspace_id')
|
||||
?? this.currentHotelId
|
||||
?? hotels[0]?.id;
|
||||
if (!currentHotelId) return null;
|
||||
|
||||
return {
|
||||
authenticated: true,
|
||||
user,
|
||||
hotels,
|
||||
currentHotelId,
|
||||
accessTokenExpiresAt: readAccessTokenExpiresAt(sessionPayload),
|
||||
};
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
path: string,
|
||||
options: { method?: string; body?: JsonObject; form?: Record<string, string | string[] | undefined>; auth?: boolean; headers?: Record<string, string> } = {},
|
||||
): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
'X-YINIAN-App-Version': '0.1.0',
|
||||
...(options.headers ?? {}),
|
||||
};
|
||||
const body = options.form
|
||||
? createFormBody(options.form)
|
||||
: options.body
|
||||
? JSON.stringify(options.body)
|
||||
: undefined;
|
||||
if (options.body && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/json';
|
||||
}
|
||||
if (options.form && !headers['Content-Type']) {
|
||||
headers['Content-Type'] = 'application/x-www-form-urlencoded';
|
||||
}
|
||||
if (options.auth !== false && this.accessToken) {
|
||||
headers.Authorization = `Bearer ${this.accessToken}`;
|
||||
}
|
||||
|
||||
const requestUrl = `${this.apiBaseUrl}${path}`;
|
||||
const response = await fetch(requestUrl, {
|
||||
method: options.method ?? 'GET',
|
||||
headers,
|
||||
body,
|
||||
}).catch((error: unknown) => {
|
||||
throw new Error(describeNetworkError(error, requestUrl));
|
||||
});
|
||||
|
||||
const data = await response.json().catch(() => ({})) as JsonObject;
|
||||
if (!response.ok) {
|
||||
const message = readErrorMessage(data) ?? `YINIAN API request failed: ${response.status}`;
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const payload = unwrapApiPayload(data);
|
||||
return payload as T;
|
||||
}
|
||||
}
|
||||
|
||||
function createDevicePayload(): JsonObject {
|
||||
return {
|
||||
device_id: 'dev_local_electron',
|
||||
platform: process.platform,
|
||||
app_version: '0.1.0',
|
||||
machine_name: '智念助手',
|
||||
};
|
||||
}
|
||||
|
||||
function describeNetworkError(error: unknown, requestUrl: string): string {
|
||||
const hostname = safeHostname(requestUrl);
|
||||
const code = readNestedErrorString(error, 'code');
|
||||
const syscall = readNestedErrorString(error, 'syscall');
|
||||
const detail = code ? `(${code}${syscall ? `/${syscall}` : ''})` : '';
|
||||
|
||||
if (code === 'ENOTFOUND') {
|
||||
return `无法连接服务端:域名 ${hostname} 解析失败${detail}。请检查服务端地址、网络或 VPN。`;
|
||||
}
|
||||
if (code === 'ECONNREFUSED') {
|
||||
return `无法连接服务端:${hostname} 拒绝连接${detail}。请确认服务端已启动并允许当前网络访问。`;
|
||||
}
|
||||
if (code === 'ETIMEDOUT' || code === 'UND_ERR_CONNECT_TIMEOUT') {
|
||||
return `无法连接服务端:连接 ${hostname} 超时${detail}。请检查网络、代理或 VPN。`;
|
||||
}
|
||||
if (code === 'CERT_HAS_EXPIRED' || code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' || code === 'DEPTH_ZERO_SELF_SIGNED_CERT') {
|
||||
return `无法连接服务端:${hostname} 的 HTTPS 证书校验失败${detail}。请检查证书配置。`;
|
||||
}
|
||||
|
||||
return `无法连接服务端:请求 ${hostname} 失败${detail}。请检查网络、代理、VPN 或服务端地址。`;
|
||||
}
|
||||
|
||||
function safeHostname(requestUrl: string): string {
|
||||
try {
|
||||
return new URL(requestUrl).hostname;
|
||||
} catch {
|
||||
return requestUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function readNestedErrorString(error: unknown, key: string): string | undefined {
|
||||
const visited = new Set<unknown>();
|
||||
let current: unknown = error;
|
||||
while (isObject(current) && !visited.has(current)) {
|
||||
visited.add(current);
|
||||
const value = current[key];
|
||||
if (typeof value === 'string' && value.trim()) return value;
|
||||
current = current.cause;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createOAuthHeaders(clientId: string): Record<string, string> {
|
||||
const explicitBasic = process.env.YINIAN_AUTH_BASIC?.trim();
|
||||
if (explicitBasic) {
|
||||
return { Authorization: explicitBasic.startsWith('Basic ') ? explicitBasic : `Basic ${explicitBasic}` };
|
||||
}
|
||||
|
||||
const clientSecret = process.env.YINIAN_AUTH_CLIENT_SECRET?.trim() || DEFAULT_OAUTH_CLIENT_SECRET;
|
||||
if (!clientSecret) return {};
|
||||
return {
|
||||
Authorization: `Basic ${Buffer.from(`${clientId}:${clientSecret}`).toString('base64')}`,
|
||||
};
|
||||
}
|
||||
|
||||
function createFormBody(fields: Record<string, string | string[] | undefined>): string {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(fields)) {
|
||||
if (value == null || value === '') continue;
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach((item) => params.append(key, item));
|
||||
} else {
|
||||
params.append(key, value);
|
||||
}
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
function createRandomString(): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function buildScopedEndpoint(endpoint: string, workspaceId: string): string {
|
||||
const [rawPath, rawQuery = ''] = endpoint.split('?');
|
||||
const path = rawPath
|
||||
.replaceAll('{workspaceId}', encodeURIComponent(workspaceId))
|
||||
.replaceAll('{workspace_id}', encodeURIComponent(workspaceId))
|
||||
.replaceAll('{hotelId}', encodeURIComponent(workspaceId))
|
||||
.replaceAll('{hotel_id}', encodeURIComponent(workspaceId))
|
||||
.replaceAll('{tenantId}', encodeURIComponent(workspaceId))
|
||||
.replaceAll('{tenant_id}', encodeURIComponent(workspaceId));
|
||||
const params = new URLSearchParams(rawQuery);
|
||||
const hasExplicitScope = endpoint.includes('{workspaceId}')
|
||||
|| endpoint.includes('{workspace_id}')
|
||||
|| endpoint.includes('{hotelId}')
|
||||
|| endpoint.includes('{hotel_id}')
|
||||
|| endpoint.includes('{tenantId}')
|
||||
|| endpoint.includes('{tenant_id}')
|
||||
|| params.has('workspaceId')
|
||||
|| params.has('workspace_id')
|
||||
|| params.has('hotelId')
|
||||
|| params.has('hotel_id')
|
||||
|| params.has('tenantId')
|
||||
|| params.has('tenant_id');
|
||||
|
||||
if (!hasExplicitScope) {
|
||||
params.set('hotel_id', workspaceId);
|
||||
}
|
||||
|
||||
const query = params.toString();
|
||||
return query ? `${path}?${query}` : path;
|
||||
}
|
||||
|
||||
function normalizeImageCaptcha(value: JsonObject, fallbackRandomStr: string): YinianImageCaptcha {
|
||||
const payload = unwrapApiPayload(value);
|
||||
const image = readString(payload, 'image', 'img', 'captcha', 'captchaImage', 'captcha_image');
|
||||
const imageBase64 = readString(payload, 'imageBase64', 'image_base64', 'base64');
|
||||
return {
|
||||
randomStr: readString(payload, 'randomStr', 'random_str', 'key') ?? fallbackRandomStr,
|
||||
image,
|
||||
imageBase64,
|
||||
mimeType: readString(payload, 'mimeType', 'mime_type') ?? (imageBase64 ? 'image/png' : undefined),
|
||||
raw: payload,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeUser(value: unknown): YinianUser {
|
||||
const object = isObject(value) ? value : {};
|
||||
return {
|
||||
id: readString(object, 'id', 'userId', 'user_id') ?? 'user_unknown',
|
||||
name: readString(object, 'name', 'username', 'nickname') ?? '未命名用户',
|
||||
phone: readString(object, 'phone'),
|
||||
email: readString(object, 'email'),
|
||||
avatar: readString(object, 'avatar'),
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultWorkspace(sessionPayload: JsonObject, userPayload: JsonObject, user: YinianUser): YinianHotel {
|
||||
const tenantId = readString(userPayload, 'tenantId', 'tenant_id')
|
||||
?? readString(sessionPayload, 'tenantId', 'tenant_id')
|
||||
?? 'default';
|
||||
return {
|
||||
id: `service_${tenantId}`,
|
||||
name: readString(sessionPayload, 'tenantName', 'tenant_name', 'workspaceName', 'workspace_name')
|
||||
?? `${user.name}的组织空间`,
|
||||
};
|
||||
}
|
||||
|
||||
function createDefaultConfigSnapshot(session: YinianAuthSession): YinianConfigSnapshot {
|
||||
return {
|
||||
serverTime: Date.now(),
|
||||
user: session.user,
|
||||
hotel: session.hotels.find((item) => item.id === session.currentHotelId) ?? session.hotels[0],
|
||||
hotels: session.hotels,
|
||||
entitlements: [],
|
||||
notificationChannels: [],
|
||||
featureFlags: {},
|
||||
uiPolicy: {
|
||||
defaultPage: 'today',
|
||||
showAdvancedSettings: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function isMissingServerResourceError(error: unknown): boolean {
|
||||
return error instanceof Error && /No static resource|404|not found/i.test(error.message);
|
||||
}
|
||||
|
||||
function normalizeHotels(value: unknown): YinianHotel[] {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(normalizeHotel).filter((hotel): hotel is YinianHotel => Boolean(hotel));
|
||||
}
|
||||
|
||||
function normalizeHotel(value: unknown): YinianHotel | null {
|
||||
if (!isObject(value)) return null;
|
||||
const hotel: YinianHotel = {
|
||||
id: readString(value, 'id') ?? readString(value, 'hotelId', 'hotel_id', 'workspaceId', 'workspace_id', 'tenantId', 'tenant_id') ?? 'hotel_unknown',
|
||||
name: readString(value, 'name') ?? '未命名工作空间',
|
||||
brand: readString(value, 'brand'),
|
||||
};
|
||||
|
||||
return hotel;
|
||||
}
|
||||
|
||||
function normalizeLocalSkill(value: JsonObject, now: number): YinianLocalSkill {
|
||||
const skillId = readString(
|
||||
value,
|
||||
'skillId',
|
||||
'skill_id',
|
||||
'appId',
|
||||
'app_id',
|
||||
'applicationId',
|
||||
'application_id',
|
||||
'code',
|
||||
'skillCode',
|
||||
'skill_code',
|
||||
'id',
|
||||
) ?? 'unknown-skill';
|
||||
const version = readString(value, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0';
|
||||
const status = readString(value, 'status', 'state');
|
||||
const enabled = value.enabled !== false
|
||||
&& value.kill_switch !== true
|
||||
&& status !== 'disabled'
|
||||
&& status !== 'disable'
|
||||
&& status !== 'off';
|
||||
return {
|
||||
skillId,
|
||||
name: readString(value, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
|
||||
version,
|
||||
enabled,
|
||||
installedAt: now,
|
||||
lastSyncedAt: now,
|
||||
status: enabled ? 'installed' : 'disabled',
|
||||
source: 'nianxx',
|
||||
bundleSha256: readString(value, 'bundleSha256', 'bundle_sha256', 'sha256', 'checksum', 'digest', 'bundleHash', 'bundle_hash'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntitlements(value: unknown[] | undefined): YinianSkillEntitlement[] {
|
||||
if (!value) return [];
|
||||
return value.filter(isObject).map((item) => {
|
||||
const skillId = readString(item, 'skillId', 'skill_id', 'appId', 'app_id', 'applicationId', 'application_id', 'code', 'id') ?? 'unknown-skill';
|
||||
const status = readString(item, 'status', 'state');
|
||||
return {
|
||||
skillId,
|
||||
name: readString(item, 'name', 'appName', 'app_name', 'applicationName', 'application_name', 'title') ?? skillId,
|
||||
version: readString(item, 'version', 'appVersion', 'app_version', 'currentVersion', 'current_version') ?? '0.0.0',
|
||||
enabled: item.enabled !== false && status !== 'disabled' && status !== 'disable' && status !== 'off',
|
||||
category: normalizeSkillCategory(readString(item, 'category')),
|
||||
triggers: normalizeSkillTriggers(readArray(item, 'triggers')),
|
||||
lastRunAt: readNumber(item, 'lastRunAt', 'last_run_at'),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeNotificationChannels(value: unknown[] | undefined): YinianNotificationChannel[] {
|
||||
if (!value) return [];
|
||||
return value.filter(isObject).map((item) => ({
|
||||
id: readString(item, 'id') ?? 'channel_unknown',
|
||||
kind: readString(item, 'kind') ?? 'unknown',
|
||||
label: readString(item, 'label') ?? '未命名通知通道',
|
||||
recipient: readString(item, 'recipient') ?? '',
|
||||
enabled: item.enabled !== false,
|
||||
source: readString(item, 'source') === 'kernel' ? 'kernel' : 'nianxx',
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeUiPolicy(value: JsonObject | undefined): YinianConfigSnapshot['uiPolicy'] {
|
||||
const defaultPage = readString(value ?? {}, 'defaultPage', 'default_page');
|
||||
return {
|
||||
defaultPage: defaultPage === 'chat' ? 'chat' : 'today',
|
||||
showAdvancedSettings: readBoolean(value ?? {}, 'showAdvancedSettings', 'show_advanced_settings') ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSkillCategory(value: string | undefined): YinianSkillEntitlement['category'] {
|
||||
return SKILL_CATEGORIES.includes(value as YinianSkillEntitlement['category'])
|
||||
? value as YinianSkillEntitlement['category']
|
||||
: 'ops-automation';
|
||||
}
|
||||
|
||||
function normalizeSkillTriggers(value: unknown[] | undefined): YinianSkillEntitlement['triggers'] {
|
||||
if (!value) return [];
|
||||
return value.filter((item): item is YinianSkillEntitlement['triggers'][number] => (
|
||||
typeof item === 'string'
|
||||
&& SKILL_TRIGGERS.includes(item as YinianSkillEntitlement['triggers'][number])
|
||||
));
|
||||
}
|
||||
|
||||
function readAccessTokenExpiresAt(object: JsonObject): number {
|
||||
return readNumber(object, 'accessTokenExpiresAt', 'access_token_expires_at', 'expiresAt', 'expires_at')
|
||||
?? readExpiresIn(object)
|
||||
?? (Date.now() + DEFAULT_ACCESS_TOKEN_TTL_MS);
|
||||
}
|
||||
|
||||
function readExpiresIn(object: JsonObject): number | undefined {
|
||||
const expiresIn = readNumber(object, 'expiresIn', 'expires_in');
|
||||
if (!expiresIn) return undefined;
|
||||
return Date.now() + expiresIn * 1000;
|
||||
}
|
||||
|
||||
function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(object: JsonObject, ...keys: string[]): string | undefined {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
if (typeof value === 'string') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readNumber(object: JsonObject, ...keys: string[]): number | undefined {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
if (typeof value === 'number') return value;
|
||||
if (typeof value === 'string' && value.trim() && Number.isFinite(Number(value))) return Number(value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readBoolean(object: JsonObject, ...keys: string[]): boolean | undefined {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
if (typeof value === 'boolean') return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readObject(object: JsonObject, ...keys: string[]): JsonObject | undefined {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
if (isObject(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function unwrapApiPayload(data: JsonObject): JsonObject {
|
||||
const dataObject = readObject(data, 'data');
|
||||
if (dataObject) {
|
||||
return {
|
||||
...data,
|
||||
...dataObject,
|
||||
};
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function readManifestItems(object: JsonObject, preferredKey?: string): unknown[] {
|
||||
if (preferredKey) {
|
||||
const preferred = readArray(object, preferredKey);
|
||||
if (preferred) return preferred;
|
||||
}
|
||||
|
||||
const direct = readArray(object, 'skills', 'apps', 'applications', 'records', 'list', 'items', 'rows');
|
||||
if (direct) return direct;
|
||||
|
||||
const data = object.data;
|
||||
if (Array.isArray(data)) return data;
|
||||
if (isObject(data)) {
|
||||
return readManifestItems(data, preferredKey);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function readErrorMessage(data: JsonObject): string | undefined {
|
||||
const errorObject = readObject(data, 'error');
|
||||
return readString(errorObject ?? {}, 'message', 'msg', 'detail')
|
||||
?? readString(data, 'message', 'msg', 'error_description')
|
||||
?? (typeof data.error === 'string' ? data.error : undefined);
|
||||
}
|
||||
|
||||
function readArray(object: JsonObject, ...keys: string[]): unknown[] | undefined {
|
||||
for (const key of keys) {
|
||||
const value = object[key];
|
||||
if (Array.isArray(value)) return value;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function normalizeBooleanRecord(value: JsonObject | undefined): Record<string, boolean> {
|
||||
if (!value) return {};
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, boolean] => typeof entry[1] === 'boolean'),
|
||||
);
|
||||
}
|
||||
302
electron/yinian/mock-control-plane.ts
Normal file
302
electron/yinian/mock-control-plane.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
import type {
|
||||
YinianAuthSession,
|
||||
YinianConfigSnapshot,
|
||||
YinianHotel,
|
||||
YinianImageCaptcha,
|
||||
YinianLoginWithPasswordInput,
|
||||
YinianLoginWithSmsInput,
|
||||
YinianLocalSkill,
|
||||
YinianServerStatus,
|
||||
YinianSessionState,
|
||||
YinianSkillSyncResult,
|
||||
YinianUser,
|
||||
} from '../../shared/yinian';
|
||||
import type { YinianControlPlane } from './control-plane';
|
||||
import { getYinianStorage, type YinianStorage } from './storage';
|
||||
|
||||
const MOCK_USER: YinianUser = {
|
||||
id: 'user_demo_manager',
|
||||
name: '王管理员',
|
||||
phone: '13800000000',
|
||||
email: 'manager@yinian.local',
|
||||
};
|
||||
|
||||
const MOCK_HOTELS: YinianHotel[] = [
|
||||
{
|
||||
id: 'workspace_hangzhou_ops',
|
||||
name: '智念企业组织空间',
|
||||
brand: '智念',
|
||||
},
|
||||
{
|
||||
id: 'workspace_shanghai_growth',
|
||||
name: '智念增长组织空间',
|
||||
brand: '智念',
|
||||
},
|
||||
];
|
||||
|
||||
function createSession(user: YinianUser = MOCK_USER, currentHotelId = MOCK_HOTELS[0].id): YinianAuthSession {
|
||||
return {
|
||||
authenticated: true,
|
||||
user,
|
||||
hotels: MOCK_HOTELS,
|
||||
currentHotelId,
|
||||
accessTokenExpiresAt: Date.now() + 60 * 60 * 1000,
|
||||
};
|
||||
}
|
||||
|
||||
interface MockYinianControlPlaneOptions {
|
||||
storage?: YinianStorage;
|
||||
}
|
||||
|
||||
export class MockYinianControlPlane implements YinianControlPlane {
|
||||
private readonly storage: YinianStorage;
|
||||
private currentSession: YinianSessionState = { authenticated: false };
|
||||
private localSkills: YinianLocalSkill[] = [];
|
||||
|
||||
constructor(options: MockYinianControlPlaneOptions = {}) {
|
||||
this.storage = options.storage ?? getYinianStorage();
|
||||
}
|
||||
|
||||
async getServerStatus(): Promise<YinianServerStatus> {
|
||||
return {
|
||||
mode: 'mock',
|
||||
reachable: true,
|
||||
checkedAt: Date.now(),
|
||||
message: '当前使用本地演示数据',
|
||||
};
|
||||
}
|
||||
|
||||
async createImageCaptcha(randomStr = crypto.randomUUID()): Promise<YinianImageCaptcha> {
|
||||
return {
|
||||
randomStr,
|
||||
image: '',
|
||||
mimeType: 'image/svg+xml',
|
||||
raw: { mock: true },
|
||||
};
|
||||
}
|
||||
|
||||
async restoreSession(): Promise<YinianSessionState> {
|
||||
const persisted = await this.storage.getSession();
|
||||
if (!persisted || persisted.mode !== 'mock') {
|
||||
this.currentSession = { authenticated: false };
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
this.currentSession = {
|
||||
authenticated: true,
|
||||
user: persisted.user,
|
||||
hotels: persisted.hotels,
|
||||
currentHotelId: persisted.currentHotelId,
|
||||
accessTokenExpiresAt: persisted.accessTokenExpiresAt,
|
||||
};
|
||||
const registry = await this.storage.getSkillRegistry(persisted.currentHotelId);
|
||||
this.localSkills = registry && 'skills' in registry ? registry.skills : [];
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async getSessionState(): Promise<YinianSessionState> {
|
||||
if (!this.currentSession.authenticated) {
|
||||
return this.restoreSession();
|
||||
}
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async loginWithSms(input: YinianLoginWithSmsInput): Promise<YinianAuthSession> {
|
||||
const phone = input.phone.trim();
|
||||
if (!phone || !input.code.trim()) {
|
||||
throw new Error('手机号和验证码不能为空');
|
||||
}
|
||||
|
||||
this.currentSession = createSession({ ...MOCK_USER, phone });
|
||||
await this.persistCurrentSession();
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async loginWithPassword(input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
|
||||
const account = input.account.trim();
|
||||
if (!account || !input.password.trim()) {
|
||||
throw new Error('账号和密码不能为空');
|
||||
}
|
||||
|
||||
this.currentSession = createSession({
|
||||
...MOCK_USER,
|
||||
name: account === 'admin' ? 'NIANXX 实施' : MOCK_USER.name,
|
||||
email: account.includes('@') ? account : MOCK_USER.email,
|
||||
});
|
||||
await this.persistCurrentSession();
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async logout(): Promise<YinianSessionState> {
|
||||
if (this.currentSession.authenticated) {
|
||||
await this.storage.clearConfig(this.currentSession.currentHotelId);
|
||||
await this.storage.clearSkillRegistry(this.currentSession.currentHotelId);
|
||||
}
|
||||
await this.storage.clearSession();
|
||||
this.currentSession = { authenticated: false };
|
||||
this.localSkills = [];
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async switchHotel(hotelId: string): Promise<YinianAuthSession> {
|
||||
if (!this.currentSession.authenticated) {
|
||||
throw new Error('请先登录');
|
||||
}
|
||||
|
||||
const hotel = MOCK_HOTELS.find((item) => item.id === hotelId);
|
||||
if (!hotel) {
|
||||
throw new Error('工作空间不存在或未开通');
|
||||
}
|
||||
|
||||
this.currentSession = {
|
||||
...this.currentSession,
|
||||
currentHotelId: hotel.id,
|
||||
};
|
||||
await this.persistCurrentSession();
|
||||
const registry = await this.storage.getSkillRegistry(hotel.id);
|
||||
this.localSkills = registry && 'skills' in registry ? registry.skills : [];
|
||||
return this.currentSession;
|
||||
}
|
||||
|
||||
async getConfigSnapshot(): Promise<YinianConfigSnapshot> {
|
||||
if (!this.currentSession.authenticated) {
|
||||
throw new Error('请先登录');
|
||||
}
|
||||
|
||||
const currentHotelId = this.currentSession.currentHotelId;
|
||||
const hotel = this.currentSession.hotels.find((item) => item.id === currentHotelId)
|
||||
?? this.currentSession.hotels[0];
|
||||
|
||||
const snapshot = {
|
||||
serverTime: Date.now(),
|
||||
user: this.currentSession.user,
|
||||
hotel,
|
||||
hotels: this.currentSession.hotels,
|
||||
entitlements: [
|
||||
{
|
||||
skillId: 'data-check',
|
||||
name: '数据检查助手',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
category: 'ota-monitoring',
|
||||
triggers: ['manual', 'scheduled'],
|
||||
lastRunAt: Date.now() - 48 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
skillId: 'workflow-check',
|
||||
name: '流程检查助手',
|
||||
version: '1.0.0',
|
||||
enabled: true,
|
||||
category: 'ops-automation',
|
||||
triggers: ['manual', 'scheduled'],
|
||||
lastRunAt: Date.now() - 55 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
skillId: 'daily-report',
|
||||
name: '日报生成助手',
|
||||
version: '0.1.0',
|
||||
enabled: true,
|
||||
category: 'reporting',
|
||||
triggers: ['manual', 'scheduled'],
|
||||
lastRunAt: Date.now() - 2 * 60 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
skillId: 'customer-reply-helper',
|
||||
name: '客户回复助手',
|
||||
version: '0.1.0',
|
||||
enabled: true,
|
||||
category: 'guest-comm',
|
||||
triggers: ['manual', 'reply'],
|
||||
},
|
||||
],
|
||||
notificationChannels: [
|
||||
{
|
||||
id: 'ch_wecom_ops',
|
||||
kind: 'wecom',
|
||||
label: '业务通知群',
|
||||
recipient: '当前组织空间通知群',
|
||||
enabled: true,
|
||||
source: 'nianxx',
|
||||
},
|
||||
{
|
||||
id: 'ch_desktop',
|
||||
kind: 'desktop',
|
||||
label: '本机桌面通知',
|
||||
recipient: '当前设备',
|
||||
enabled: true,
|
||||
source: 'kernel',
|
||||
},
|
||||
],
|
||||
featureFlags: {
|
||||
yinianTodayPage: true,
|
||||
autoUpdateSkills: true,
|
||||
reviewReplyHelper: true,
|
||||
},
|
||||
uiPolicy: {
|
||||
defaultPage: 'today',
|
||||
showAdvancedSettings: false,
|
||||
},
|
||||
};
|
||||
await this.storage.setConfig(snapshot);
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
async syncSkills(): Promise<YinianSkillSyncResult> {
|
||||
const config = await this.getConfigSnapshot();
|
||||
const now = Date.now();
|
||||
|
||||
const previousRegistry = await this.storage.getSkillRegistry(config.hotel.id);
|
||||
const previousSkills = previousRegistry && 'skills' in previousRegistry ? previousRegistry.skills : this.localSkills;
|
||||
this.localSkills = config.entitlements.map((skill) => {
|
||||
const previous = previousSkills.find((item) => item.skillId === skill.skillId);
|
||||
return {
|
||||
skillId: skill.skillId,
|
||||
name: skill.name,
|
||||
version: skill.version,
|
||||
enabled: skill.enabled,
|
||||
installedAt: previous?.installedAt ?? now,
|
||||
lastSyncedAt: now,
|
||||
status: previous?.version === skill.version ? 'skipped' : previous ? 'updated' : 'installed',
|
||||
source: 'mock',
|
||||
bundleSha256: `mock-${skill.skillId}-${skill.version}`,
|
||||
};
|
||||
});
|
||||
await this.storage.setSkillRegistry({
|
||||
hotelId: config.hotel.id,
|
||||
updatedAt: now,
|
||||
skills: this.localSkills,
|
||||
});
|
||||
|
||||
return {
|
||||
hotelId: config.hotel.id,
|
||||
syncedAt: now,
|
||||
skills: this.localSkills,
|
||||
};
|
||||
}
|
||||
|
||||
async listLocalSkills(): Promise<YinianLocalSkill[]> {
|
||||
if (this.currentSession.authenticated) {
|
||||
const registry = await this.storage.getSkillRegistry(this.currentSession.currentHotelId);
|
||||
if (registry && 'skills' in registry) {
|
||||
this.localSkills = registry.skills;
|
||||
}
|
||||
}
|
||||
return this.localSkills;
|
||||
}
|
||||
|
||||
async getSkillRegistry(hotelId?: string) {
|
||||
return this.storage.getSkillRegistry(hotelId);
|
||||
}
|
||||
|
||||
private async persistCurrentSession(): Promise<void> {
|
||||
if (!this.currentSession.authenticated) return;
|
||||
await this.storage.setSession({
|
||||
mode: 'mock',
|
||||
user: this.currentSession.user,
|
||||
hotels: this.currentSession.hotels,
|
||||
currentHotelId: this.currentSession.currentHotelId,
|
||||
accessTokenExpiresAt: this.currentSession.accessTokenExpiresAt,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
}
|
||||
}
|
||||
258
electron/yinian/storage.ts
Normal file
258
electron/yinian/storage.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import type {
|
||||
YinianConfigSnapshot,
|
||||
YinianPersistedSession,
|
||||
YinianSavedCredentials,
|
||||
YinianSkillRegistry,
|
||||
YinianSkillRegistryByHotel,
|
||||
} from '../../shared/yinian';
|
||||
|
||||
interface YinianStoreShape {
|
||||
session?: YinianPersistedSession;
|
||||
savedCredentials?: YinianSavedCredentials & {
|
||||
passwordEncrypted?: string;
|
||||
passwordEncoding?: 'electron-safe-storage' | 'plain';
|
||||
};
|
||||
configs?: Record<string, YinianConfigSnapshot>;
|
||||
skillRegistryByHotel?: YinianSkillRegistryByHotel;
|
||||
}
|
||||
|
||||
export interface YinianStorage {
|
||||
getSession(): Promise<YinianPersistedSession | undefined>;
|
||||
setSession(session: YinianPersistedSession): Promise<void>;
|
||||
clearSession(): Promise<void>;
|
||||
getSavedCredentials(): Promise<YinianSavedCredentials | undefined>;
|
||||
setSavedCredentials(credentials: YinianSavedCredentials): Promise<void>;
|
||||
clearSavedCredentials(): Promise<void>;
|
||||
getConfig(hotelId: string): Promise<YinianConfigSnapshot | undefined>;
|
||||
setConfig(config: YinianConfigSnapshot): Promise<void>;
|
||||
clearConfig(hotelId: string): Promise<void>;
|
||||
getSkillRegistry(hotelId?: string): Promise<YinianSkillRegistryByHotel | YinianSkillRegistry | undefined>;
|
||||
setSkillRegistry(registry: YinianSkillRegistry): Promise<void>;
|
||||
clearSkillRegistry(hotelId?: string): Promise<void>;
|
||||
clearAll(): Promise<void>;
|
||||
}
|
||||
|
||||
// Lazy-load electron-store only in the Electron main process. Tests and pure
|
||||
// modules can inject createMemoryYinianStorage instead.
|
||||
let storeInstance: {
|
||||
get<K extends keyof YinianStoreShape>(key: K): YinianStoreShape[K];
|
||||
set<K extends keyof YinianStoreShape>(key: K, value: YinianStoreShape[K]): void;
|
||||
delete(key: keyof YinianStoreShape): void;
|
||||
clear(): void;
|
||||
} | null = null;
|
||||
|
||||
async function getStore() {
|
||||
if (!storeInstance) {
|
||||
const Store = (await import('electron-store')).default;
|
||||
storeInstance = new Store<YinianStoreShape>({
|
||||
name: 'yinian',
|
||||
defaults: {
|
||||
configs: {},
|
||||
skillRegistryByHotel: {},
|
||||
},
|
||||
});
|
||||
}
|
||||
return storeInstance;
|
||||
}
|
||||
|
||||
async function encryptPassword(password: string | undefined): Promise<{
|
||||
password?: string;
|
||||
passwordEncrypted?: string;
|
||||
passwordEncoding?: 'electron-safe-storage' | 'plain';
|
||||
}> {
|
||||
if (!password) return {};
|
||||
try {
|
||||
const { safeStorage } = await import('electron');
|
||||
if (safeStorage?.isEncryptionAvailable()) {
|
||||
return {
|
||||
passwordEncrypted: safeStorage.encryptString(password).toString('base64'),
|
||||
passwordEncoding: 'electron-safe-storage',
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
// Fall through to the plain fallback used in dev/test environments.
|
||||
}
|
||||
return { password, passwordEncoding: 'plain' };
|
||||
}
|
||||
|
||||
async function decryptCredentials(
|
||||
credentials: YinianStoreShape['savedCredentials'],
|
||||
): Promise<YinianSavedCredentials | undefined> {
|
||||
if (!credentials) return undefined;
|
||||
if (credentials.passwordEncrypted && credentials.passwordEncoding === 'electron-safe-storage') {
|
||||
try {
|
||||
const { safeStorage } = await import('electron');
|
||||
if (safeStorage?.isEncryptionAvailable()) {
|
||||
return {
|
||||
account: credentials.account,
|
||||
password: safeStorage.decryptString(Buffer.from(credentials.passwordEncrypted, 'base64')),
|
||||
rememberPassword: credentials.rememberPassword,
|
||||
updatedAt: credentials.updatedAt,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
return {
|
||||
account: credentials.account,
|
||||
rememberPassword: false,
|
||||
updatedAt: credentials.updatedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
return {
|
||||
account: credentials.account,
|
||||
password: credentials.password,
|
||||
rememberPassword: credentials.rememberPassword,
|
||||
updatedAt: credentials.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async function prepareCredentialsForStorage(credentials: YinianSavedCredentials): Promise<YinianStoreShape['savedCredentials']> {
|
||||
const passwordState = await encryptPassword(credentials.rememberPassword ? credentials.password : undefined);
|
||||
return {
|
||||
account: credentials.account,
|
||||
rememberPassword: credentials.rememberPassword,
|
||||
updatedAt: credentials.updatedAt,
|
||||
...passwordState,
|
||||
};
|
||||
}
|
||||
|
||||
export function createElectronYinianStorage(): YinianStorage {
|
||||
return {
|
||||
async getSession() {
|
||||
return (await getStore()).get('session');
|
||||
},
|
||||
async setSession(session) {
|
||||
(await getStore()).set('session', session);
|
||||
},
|
||||
async clearSession() {
|
||||
(await getStore()).delete('session');
|
||||
},
|
||||
async getSavedCredentials() {
|
||||
return decryptCredentials((await getStore()).get('savedCredentials'));
|
||||
},
|
||||
async setSavedCredentials(credentials) {
|
||||
(await getStore()).set('savedCredentials', await prepareCredentialsForStorage(credentials));
|
||||
},
|
||||
async clearSavedCredentials() {
|
||||
(await getStore()).delete('savedCredentials');
|
||||
},
|
||||
async getConfig(hotelId) {
|
||||
return (await getStore()).get('configs')?.[hotelId];
|
||||
},
|
||||
async setConfig(config) {
|
||||
const store = await getStore();
|
||||
store.set('configs', {
|
||||
...(store.get('configs') ?? {}),
|
||||
[config.hotel.id]: config,
|
||||
});
|
||||
},
|
||||
async clearConfig(hotelId) {
|
||||
const store = await getStore();
|
||||
const configs = { ...(store.get('configs') ?? {}) };
|
||||
delete configs[hotelId];
|
||||
store.set('configs', configs);
|
||||
},
|
||||
async getSkillRegistry(hotelId) {
|
||||
const registries = (await getStore()).get('skillRegistryByHotel') ?? {};
|
||||
return hotelId ? registries[hotelId] : registries;
|
||||
},
|
||||
async setSkillRegistry(registry) {
|
||||
const store = await getStore();
|
||||
store.set('skillRegistryByHotel', {
|
||||
...(store.get('skillRegistryByHotel') ?? {}),
|
||||
[registry.hotelId]: registry,
|
||||
});
|
||||
},
|
||||
async clearSkillRegistry(hotelId) {
|
||||
const store = await getStore();
|
||||
if (!hotelId) {
|
||||
store.set('skillRegistryByHotel', {});
|
||||
return;
|
||||
}
|
||||
const registries = { ...(store.get('skillRegistryByHotel') ?? {}) };
|
||||
delete registries[hotelId];
|
||||
store.set('skillRegistryByHotel', registries);
|
||||
},
|
||||
async clearAll() {
|
||||
(await getStore()).clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function createMemoryYinianStorage(initial?: Partial<YinianStoreShape>): YinianStorage {
|
||||
const state: YinianStoreShape = {
|
||||
configs: {},
|
||||
skillRegistryByHotel: {},
|
||||
...initial,
|
||||
};
|
||||
|
||||
return {
|
||||
async getSession() {
|
||||
return state.session;
|
||||
},
|
||||
async setSession(session) {
|
||||
state.session = session;
|
||||
},
|
||||
async clearSession() {
|
||||
delete state.session;
|
||||
},
|
||||
async getSavedCredentials() {
|
||||
return decryptCredentials(state.savedCredentials);
|
||||
},
|
||||
async setSavedCredentials(credentials) {
|
||||
state.savedCredentials = await prepareCredentialsForStorage(credentials);
|
||||
},
|
||||
async clearSavedCredentials() {
|
||||
delete state.savedCredentials;
|
||||
},
|
||||
async getConfig(hotelId) {
|
||||
return state.configs?.[hotelId];
|
||||
},
|
||||
async setConfig(config) {
|
||||
state.configs = {
|
||||
...(state.configs ?? {}),
|
||||
[config.hotel.id]: config,
|
||||
};
|
||||
},
|
||||
async clearConfig(hotelId) {
|
||||
if (!state.configs) return;
|
||||
delete state.configs[hotelId];
|
||||
},
|
||||
async getSkillRegistry(hotelId) {
|
||||
const registries = state.skillRegistryByHotel ?? {};
|
||||
return hotelId ? registries[hotelId] : registries;
|
||||
},
|
||||
async setSkillRegistry(registry) {
|
||||
state.skillRegistryByHotel = {
|
||||
...(state.skillRegistryByHotel ?? {}),
|
||||
[registry.hotelId]: registry,
|
||||
};
|
||||
},
|
||||
async clearSkillRegistry(hotelId) {
|
||||
if (!hotelId) {
|
||||
state.skillRegistryByHotel = {};
|
||||
return;
|
||||
}
|
||||
if (!state.skillRegistryByHotel) return;
|
||||
delete state.skillRegistryByHotel[hotelId];
|
||||
},
|
||||
async clearAll() {
|
||||
delete state.session;
|
||||
delete state.savedCredentials;
|
||||
state.configs = {};
|
||||
state.skillRegistryByHotel = {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
let yinianStorage: YinianStorage | null = null;
|
||||
|
||||
export function getYinianStorage(): YinianStorage {
|
||||
yinianStorage ??= createElectronYinianStorage();
|
||||
return yinianStorage;
|
||||
}
|
||||
|
||||
export function resetYinianStorageForTests(next?: YinianStorage): void {
|
||||
yinianStorage = next ?? null;
|
||||
storeInstance = null;
|
||||
}
|
||||
70
electron/yinian/unconfigured-control-plane.ts
Normal file
70
electron/yinian/unconfigured-control-plane.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
YinianAuthSession,
|
||||
YinianConfigSnapshot,
|
||||
YinianImageCaptcha,
|
||||
YinianLocalSkill,
|
||||
YinianLoginWithPasswordInput,
|
||||
YinianLoginWithSmsInput,
|
||||
YinianServerStatus,
|
||||
YinianSessionState,
|
||||
YinianSkillRegistry,
|
||||
YinianSkillSyncResult,
|
||||
} from '../../shared/yinian';
|
||||
import type { YinianControlPlane } from './control-plane';
|
||||
|
||||
const MESSAGE = '服务端地址未配置,请设置 YINIAN_API_BASE_URL 后重新启动智念助手。';
|
||||
|
||||
export class UnconfiguredYinianControlPlane implements YinianControlPlane {
|
||||
async getServerStatus(): Promise<YinianServerStatus> {
|
||||
return {
|
||||
mode: 'http',
|
||||
reachable: false,
|
||||
checkedAt: Date.now(),
|
||||
message: MESSAGE,
|
||||
};
|
||||
}
|
||||
|
||||
async createImageCaptcha(randomStr = `${Date.now()}`): Promise<YinianImageCaptcha> {
|
||||
return { randomStr, raw: { error: MESSAGE } };
|
||||
}
|
||||
|
||||
async restoreSession(): Promise<YinianSessionState> {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
async getSessionState(): Promise<YinianSessionState> {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
async loginWithSms(_input: YinianLoginWithSmsInput): Promise<YinianAuthSession> {
|
||||
throw new Error(MESSAGE);
|
||||
}
|
||||
|
||||
async loginWithPassword(_input: YinianLoginWithPasswordInput): Promise<YinianAuthSession> {
|
||||
throw new Error(MESSAGE);
|
||||
}
|
||||
|
||||
async logout(): Promise<YinianSessionState> {
|
||||
return { authenticated: false };
|
||||
}
|
||||
|
||||
async switchHotel(_hotelId: string): Promise<YinianAuthSession> {
|
||||
throw new Error(MESSAGE);
|
||||
}
|
||||
|
||||
async getConfigSnapshot(): Promise<YinianConfigSnapshot> {
|
||||
throw new Error(MESSAGE);
|
||||
}
|
||||
|
||||
async syncSkills(): Promise<YinianSkillSyncResult> {
|
||||
throw new Error(MESSAGE);
|
||||
}
|
||||
|
||||
async listLocalSkills(): Promise<YinianLocalSkill[]> {
|
||||
return [];
|
||||
}
|
||||
|
||||
async getSkillRegistry(_hotelId?: string): Promise<YinianSkillRegistry | Record<string, YinianSkillRegistry> | undefined> {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user