feat: prepare Zhinian desktop client for pilot release

This commit is contained in:
inman
2026-04-29 10:23:20 +08:00
parent f9361e686a
commit 47b83b79fc
149 changed files with 15341 additions and 3590 deletions

View 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;
}

View 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'),
);
}

View 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
View 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;
}

View 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;
}
}