refactor(store): saasStore 拆分为子模块 (Phase 2B)

1025行单文件 → 5个文件 + barrel re-export:
- saas/types.ts (103行) — 类型定义
- saas/shared.ts (93行) — Device ID、常量、recovery probe
- saas/auth.ts (362行) — 登录/注册/登出/恢复/TOTP
- saas/billing.ts (84行) — 计划/订阅/支付
- saas/index.ts (309行) — Store 组装 + 连接/模板/配置
- saasStore.ts (15行) — re-export barrel(外部零改动)

所有 25+ 消费者 import 路径不变,`tsc --noEmit` ✓
This commit is contained in:
iven
2026-04-17 20:05:43 +08:00
parent f9f5472d99
commit e3b6003be2
6 changed files with 961 additions and 1020 deletions

View File

@@ -0,0 +1,362 @@
/**
* SaaS Auth Slice
*
* Login, register, logout, restoreSession, TOTP actions.
*/
import {
saasClient,
SaaSApiError,
loadSaaSSession,
loadSaaSSessionSync,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
} from '../../lib/saas-client';
import { createLogger } from '../../lib/logger';
import { initTelemetryCollector, stopTelemetryCollector } from '../../lib/telemetry-collector';
import { startPromptOTASync, stopPromptOTASync } from '../../lib/llm-service';
import { DEVICE_ID, DEFAULT_SAAS_URL, resolveInitialMode } from './shared';
import type { SaaSStore } from './types';
const log = createLogger('SaaSStore:Auth');
type SetFn = (partial: Partial<SaaSStore> | ((state: SaaSStore) => Partial<SaaSStore>)) => void;
type GetFn = () => SaaSStore;
export function createAuthSlice(set: SetFn, get: GetFn) {
// Restore session metadata synchronously (URL + account only).
const sessionMeta = loadSaaSSessionSync();
const initialMode = resolveInitialMode(sessionMeta);
if (sessionMeta) {
saasClient.setBaseUrl(sessionMeta.saasUrl);
}
return {
isLoggedIn: false,
account: sessionMeta?.account ?? null,
saasUrl: sessionMeta?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: null,
connectionMode: initialMode,
isLoading: false,
error: null,
totpRequired: false,
totpSetupData: null,
login: async (saasUrl: string, username: string, password: string) => {
set({ isLoading: true, error: null });
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
const requestUrl = normalizedUrl || window.location.origin;
try {
if (!trimmedUsername) throw new Error('请输入用户名');
if (!password) throw new Error('请输入密码');
saasClient.setBaseUrl(normalizedUrl);
const loginData = await saasClient.login(trimmedUsername, password);
const sessionData = {
token: loginData.token,
refreshToken: loginData.refresh_token,
account: loginData.account,
saasUrl: normalizedUrl,
};
try { await saveSaaSSession(sessionData); } catch (e) { log.warn('Failed to persist SaaS session after login', { error: e }); }
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
});
get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device:', err));
get().fetchAvailableTemplates().catch((err: unknown) => log.warn('Failed to fetch templates after login:', err));
get().fetchAssignedTemplate().catch((err: unknown) => log.warn('Failed to fetch assigned template after login:', err));
get().fetchBillingOverview().catch((err: unknown) => log.warn('Failed to fetch billing after login:', err));
get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models after login:', err));
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch((err: unknown) => log.warn('Failed to push config to SaaS:', err));
}).catch((err: unknown) => log.warn('Failed to sync config after login:', err));
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
if (err instanceof SaaSApiError && err.code === 'TOTP_ERROR' && err.status === 400) {
set({ isLoading: false, totpRequired: true, error: null });
return;
}
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
const isTimeout = message.includes('signal timed out') || message.includes('Timeout')
|| message.includes('timed out') || message.includes('AbortError');
const isConnectionRefused = message.includes('Failed to fetch') || message.includes('NetworkError')
|| message.includes('ECONNREFUSED') || message.includes('connection refused');
const userMessage = isTimeout
? `连接 SaaS 服务器超时,请确认后端服务正在运行: ${requestUrl}`
: isConnectionRefused
? `无法连接到 SaaS 服务器,请确认后端服务已启动: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
throw new Error(userMessage);
}
},
loginWithTotp: async (saasUrl: string, username: string, password: string, totpCode: string) => {
set({ isLoading: true, error: null, totpRequired: false });
try {
const normalizedUrl = saasUrl.trim().replace(/\/+$/, '');
saasClient.setBaseUrl(normalizedUrl);
const loginData = await saasClient.login(username.trim(), password, totpCode);
const sessionData = {
token: loginData.token,
refreshToken: loginData.refresh_token,
account: loginData.account,
saasUrl: normalizedUrl,
};
try { await saveSaaSSession(sessionData); } catch (e) { log.warn('Failed to persist SaaS session after TOTP login', { error: e }); }
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
totpRequired: false,
});
get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device:', err));
get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models:', err));
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
throw new Error(message);
}
},
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
set({ isLoading: true, error: null });
try {
const trimmedUrl = saasUrl.trim();
if (!username.trim()) throw new Error('请输入用户名');
if (!email.trim()) throw new Error('请输入邮箱');
if (!password) throw new Error('请输入密码');
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
saasClient.setBaseUrl(normalizedUrl);
const registerData = await saasClient.register({
username: username.trim(),
email: email.trim(),
password,
display_name: displayName,
});
const sessionData = {
token: registerData.token,
refreshToken: registerData.refresh_token,
account: registerData.account,
saasUrl: normalizedUrl,
};
try { await saveSaaSSession(sessionData); } catch (e) { log.warn('Failed to persist SaaS session after register', { error: e }); }
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: registerData.account,
saasUrl: normalizedUrl,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
});
get().registerCurrentDevice().catch((err: unknown) => log.warn('Failed to register device after register:', err));
get().fetchAvailableModels().catch((err: unknown) => log.warn('Failed to fetch models after register:', err));
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
throw new Error(message);
}
},
logout: async () => {
saasClient.setToken(null);
await clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
try {
const { useConversationStore } = await import('../chat/conversationStore');
useConversationStore.getState().setCurrentModel('');
} catch { /* non-critical */ }
set({
isLoggedIn: false,
account: null,
authToken: null,
connectionMode: 'tauri',
availableModels: [],
availableTemplates: [],
assignedTemplate: null,
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
error: null,
totpRequired: false,
totpSetupData: null,
});
},
restoreSession: async () => {
const restored = await loadSaaSSession();
if (!restored) return;
saasClient.setBaseUrl(restored.saasUrl);
let account: import('../../lib/saas-client').SaaSAccountInfo | null = null;
let newToken: string | null = restored.token;
if (restored.token) {
saasClient.setToken(restored.token);
try {
account = await saasClient.me();
} catch {
saasClient.setToken(null);
newToken = null;
}
}
if (!account && restored.refreshToken) {
saasClient.setRefreshToken(restored.refreshToken);
try {
const refreshed = await saasClient.refreshMutex();
newToken = refreshed;
saasClient.setToken(refreshed);
account = await saasClient.me();
try {
const { saveSaaSSession: save } = await import('../../lib/saas-session');
await save({
token: refreshed,
refreshToken: saasClient.getRefreshToken(),
account,
saasUrl: restored.saasUrl,
});
} catch (e) {
log.warn('Failed to persist refreshed session', { error: e });
}
} catch {
saasClient.setRefreshToken(null);
saasClient.setToken(null);
newToken = null;
}
}
if (!account) {
account = await saasClient.restoreFromCookie();
}
if (!account) {
set({ isLoggedIn: false, account: null, saasUrl: restored.saasUrl, authToken: null });
return;
}
set({
isLoggedIn: true,
account,
saasUrl: restored.saasUrl,
authToken: newToken,
connectionMode: 'saas',
});
saveConnectionMode('saas');
get().fetchAvailableModels().catch(() => {});
get().fetchAvailableTemplates().catch(() => {});
get().fetchAssignedTemplate().catch(() => {});
get().fetchBillingOverview().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
},
clearError: () => set({ error: null }),
setupTotp: async () => {
set({ isLoading: true, error: null });
try {
const setupData = await saasClient.setupTotp();
set({ totpSetupData: setupData, isLoading: false });
return setupData;
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
throw new Error(message);
}
},
verifyTotp: async (code: string) => {
set({ isLoading: true, error: null });
try {
await saasClient.verifyTotp(code);
const account = await saasClient.me();
const { saasUrl } = get();
saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) =>
log.warn('Failed to persist SaaS session after verifyTotp', { error: e })
);
set({ totpSetupData: null, isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
throw new Error(message);
}
},
disableTotp: async (password: string) => {
set({ isLoading: true, error: null });
try {
await saasClient.disableTotp(password);
const account = await saasClient.me();
const { saasUrl } = get();
saveSaaSSession({ token: null, refreshToken: null, account, saasUrl }).catch((e) =>
log.warn('Failed to persist SaaS session after disableTotp', { error: e })
);
set({ isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
: err instanceof Error ? err.message : String(err);
set({ isLoading: false, error: message });
throw new Error(message);
}
},
cancelTotpSetup: () => set({ totpSetupData: null }),
};
}

View File

@@ -0,0 +1,84 @@
/**
* SaaS Billing Slice
*
* Plans, subscription, and payment actions.
*/
import { saasClient } from '../../lib/saas-client';
import { createLogger } from '../../lib/logger';
import type { SaaSStore } from './types';
const log = createLogger('SaaSStore:Billing');
type SetFn = (partial: Partial<SaaSStore> | ((state: SaaSStore) => Partial<SaaSStore>)) => void;
type GetFn = () => SaaSStore;
export function createBillingSlice(set: SetFn, _get: GetFn) {
return {
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
fetchPlans: async () => {
try {
const plans = await saasClient.listPlans();
set({ plans });
} catch (err: unknown) {
log.warn('Failed to fetch plans:', err);
}
},
fetchSubscription: async () => {
try {
const sub = await saasClient.getSubscription();
set({ subscription: sub });
} catch (err: unknown) {
log.warn('Failed to fetch subscription:', err);
set({ subscription: null });
}
},
fetchBillingOverview: async () => {
set({ billingLoading: true, billingError: null });
try {
const [plans, sub] = await Promise.all([
saasClient.listPlans(),
saasClient.getSubscription(),
]);
set({ plans, subscription: sub, billingLoading: false });
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ billingLoading: false, billingError: msg });
}
},
createPayment: async (planId: string, method: 'alipay' | 'wechat') => {
set({ billingLoading: true, billingError: null });
try {
const result = await saasClient.createPayment({ plan_id: planId, payment_method: method });
set({ billingLoading: false });
return result;
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : String(err);
set({ billingLoading: false, billingError: msg });
return null;
}
},
pollPaymentStatus: async (paymentId: string) => {
try {
const status = await saasClient.getPaymentStatus(paymentId);
if (status.status === 'succeeded') {
await _get().fetchSubscription();
}
return status;
} catch (err: unknown) {
log.warn('Failed to poll payment status:', err);
return null;
}
},
clearBillingError: () => set({ billingError: null }),
};
}

View File

@@ -0,0 +1,309 @@
/**
* SaaS Store - SaaS Platform Connection State Management
*
* Manages SaaS login state, account info, connection mode,
* and available models. Persists auth state to localStorage
* via saas-client helpers.
*
* Store is composed from slices: auth, billing, and inline
* connection/template/config logic.
*
* Connection modes:
* - 'tauri': Local Kernel via Tauri (default)
* - 'gateway': External Gateway via WebSocket
* - 'saas': SaaS backend relay
*/
import { create } from 'zustand';
import {
saasClient,
saveConnectionMode,
} from '../../lib/saas-client';
import { DEVICE_ID, log, startRecoveryProbe } from './shared';
import { createAuthSlice } from './auth';
import { createBillingSlice } from './billing';
import type { SaaSStore, ConnectionMode } from './types';
// Re-export types for backward compatibility
export type { SaaSStore, SaaSStateSlice, SaaSActionsSlice, ConnectionMode } from './types';
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Combine slices — auth slice includes initial state for auth fields,
// billing slice includes initial state for billing fields.
// Connection/template/config are small enough to inline here.
const authSlice = createAuthSlice(set, get);
const billingSlice = createBillingSlice(set, get);
return {
...authSlice,
...billingSlice,
// === Connection State ===
saasReachable: true,
availableModels: [],
_consecutiveFailures: 0,
// === Template State ===
availableTemplates: [],
assignedTemplate: null,
// === Connection Actions ===
setConnectionMode: (mode: ConnectionMode) => {
const { isLoggedIn } = get();
if (mode === 'saas' && !isLoggedIn) return;
saveConnectionMode(mode);
set({ connectionMode: mode });
},
registerCurrentDevice: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
await saasClient.registerDevice({
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
platform: navigator.platform,
app_version: (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'),
});
log.info('Device registered successfully');
// Start periodic heartbeat (every 5 minutes) with failure tracking
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
const DEGRADE_AFTER_FAILURES = 3;
const timer = window.setInterval(async () => {
const state = get();
if (!state.isLoggedIn) {
window.clearInterval(timer);
return;
}
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
if (state._consecutiveFailures > 0) {
log.info(`Heartbeat recovered after ${state._consecutiveFailures} failures`);
}
set({ _consecutiveFailures: 0, saasReachable: true });
} catch (err) {
const failures = state._consecutiveFailures + 1;
log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`);
set({ _consecutiveFailures: failures });
if (failures >= DEGRADE_AFTER_FAILURES && state.connectionMode === 'saas') {
log.warn(`SaaS unreachable after ${failures} attempts — degrading to local mode`);
set({ saasReachable: false, connectionMode: 'tauri' });
saveConnectionMode('tauri');
startRecoveryProbe(get, set);
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 });
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
}
},
fetchAvailableModels: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) {
set({ availableModels: [] });
return;
}
try {
saasClient.setBaseUrl(saasUrl);
const models = await saasClient.listModels();
set({ availableModels: models });
if (models.length > 0) {
try {
const { useConversationStore } = await import('../chat/conversationStore');
const current = useConversationStore.getState().currentModel;
const modelIds = models.map(m => m.alias || m.id);
const firstModel = models[0];
const fallbackId = firstModel.alias || firstModel.id;
if (!current || !modelIds.includes(current)) {
useConversationStore.getState().setCurrentModel(fallbackId);
if (current) {
log.info(`Synced currentModel: ${current} not available, switched to ${fallbackId}`);
} else {
log.info(`Auto-selected first available model: ${fallbackId}`);
}
}
} catch (syncErr) {
log.warn('Failed to sync currentModel after fetching models:', syncErr);
}
}
} catch (err: unknown) {
log.warn('Failed to fetch available models:', err);
set({ availableModels: [] });
}
},
// === Config Sync Actions ===
pushConfigToSaaS: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
const dirtyKeys: string[] = [];
const dirtyValues: Record<string, unknown> = {};
let i = 0;
while (true) {
const key = localStorage.key(i);
if (!key) break;
i++;
if (key.startsWith('zclaw-config-dirty.') && localStorage.getItem(key) === '1') {
const configKey = key.replace('zclaw-config-dirty.', '');
const storageKey = `zclaw-config.${configKey}`;
const value = localStorage.getItem(storageKey);
if (value !== null) {
dirtyKeys.push(configKey);
dirtyValues[configKey] = value;
}
}
}
if (dirtyKeys.length === 0) return;
const fingerprint = DEVICE_ID;
const syncRequest = {
client_fingerprint: fingerprint,
action: 'merge' as const,
config_keys: dirtyKeys,
client_values: dirtyValues,
};
const diff = await saasClient.computeConfigDiff(syncRequest as import('../../lib/saas-client').SyncConfigRequest);
if (diff.conflicts > 0) {
log.warn(`Config sync has ${diff.conflicts} conflicts, using merge strategy`);
}
const result = await saasClient.syncConfig(syncRequest);
log.info(`Config push result: ${result.updated} updated, ${result.created} created, ${result.skipped} skipped`);
for (const key of dirtyKeys) {
localStorage.removeItem(`zclaw-config-dirty.${key}`);
}
} catch (err: unknown) {
log.warn('Failed to push config to SaaS:', err);
}
},
syncConfigFromSaaS: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
const lastSyncKey = 'zclaw-config-last-sync';
const lastSync = localStorage.getItem(lastSyncKey) || undefined;
const result = await saasClient.pullConfig(lastSync);
if (result.configs.length === 0) {
log.info('No config updates from SaaS');
return;
}
for (const config of result.configs) {
if (config.value === null) continue;
const storageKey = `zclaw-config.${config.category}.${config.key}`;
const existing = localStorage.getItem(storageKey);
const dirtyKey = `zclaw-config-dirty.${config.category}.${config.key}`;
const lastPulledValue = localStorage.getItem(`zclaw-config-pulled.${config.category}.${config.key}`);
if (dirtyKey && localStorage.getItem(dirtyKey) === '1') {
log.warn(`Config conflict, keeping local: ${config.key}`);
continue;
}
if (existing !== null && lastPulledValue !== null && existing !== lastPulledValue && existing !== config.value) {
log.warn(`Config conflict (local modified), keeping local: ${config.key}`);
continue;
}
if (existing !== config.value) {
localStorage.setItem(storageKey, config.value);
localStorage.setItem(`zclaw-config-pulled.${config.category}.${config.key}`, config.value);
log.info(`Config synced: ${config.key}`);
}
}
localStorage.setItem(lastSyncKey, result.pulled_at);
log.info(`Synced ${result.configs.length} config items from SaaS`);
// Propagate Kernel-relevant configs to Rust backend
const kernelCategories = ['agent', 'llm'];
const kernelConfigs = result.configs.filter(
(c) => kernelCategories.includes(c.category) && c.value !== null,
);
if (kernelConfigs.length > 0 && typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window) {
try {
const { invoke } = await import('@tauri-apps/api/core');
await invoke('kernel_apply_saas_config', {
configs: kernelConfigs.map((c) => ({
category: c.category,
key: c.key,
value: c.value,
})),
});
log.info(`Propagated ${kernelConfigs.length} Kernel configs to Rust backend`);
} catch (invokeErr: unknown) {
log.warn('Failed to propagate configs to Kernel (non-fatal):', invokeErr);
}
}
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}
},
// === Template Actions ===
fetchAvailableTemplates: async () => {
try {
const templates = await saasClient.fetchAvailableTemplates();
set({ availableTemplates: templates });
} catch {
set({ availableTemplates: [] });
}
},
assignTemplate: async (templateId: string) => {
try {
const template = await saasClient.assignTemplate(templateId);
set({ assignedTemplate: template });
} catch (err) {
log.warn('Failed to assign template:', err);
}
},
fetchAssignedTemplate: async () => {
try {
const template = await saasClient.getAssignedTemplate();
set({ assignedTemplate: template });
} catch {
set({ assignedTemplate: null });
}
},
unassignTemplate: async () => {
try {
await saasClient.unassignTemplate();
set({ assignedTemplate: null });
} catch (err) {
log.warn('Failed to unassign template:', err);
}
},
};
});

View File

@@ -0,0 +1,93 @@
/**
* SaaS Store Shared Helpers
*
* Device ID, constants, and recovery probe logic shared across slices.
*/
import { saasClient, saveConnectionMode, loadConnectionMode } from '../../lib/saas-client';
import { createLogger } from '../../lib/logger';
import type { SaaSStore, ConnectionMode } from './types';
import type { SaaSAccountInfo } from '../../lib/saas-client';
export const log = createLogger('SaaSStore');
// === Device ID ===
function getOrCreateDeviceId(): string {
const KEY = 'zclaw-device-id';
const existing = localStorage.getItem(KEY);
if (existing) return existing;
const newId = crypto.randomUUID();
localStorage.setItem(KEY, newId);
return newId;
}
export const DEVICE_ID = getOrCreateDeviceId();
export const DEFAULT_SAAS_URL = import.meta.env.VITE_SAAS_URL || 'http://127.0.0.1:8080';
// === Recovery Probe ===
let _recoveryProbeInterval: ReturnType<typeof setInterval> | null = null;
let _recoveryBackoffMs = 2 * 60 * 1000;
const RECOVERY_BACKOFF_CAP_MS = 10 * 60 * 1000;
const RECOVERY_BACKOFF_MULTIPLIER = 1.5;
export function startRecoveryProbe(
_getStore: () => SaaSStore,
_setStore: (partial: Partial<SaaSStore>) => void,
): void {
if (_recoveryProbeInterval) return;
_recoveryBackoffMs = 2 * 60 * 1000;
log.info('[SaaS Recovery] Starting recovery probe...');
const probe = async () => {
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
log.info('[SaaS Recovery] SaaS reachable — switching back to SaaS mode');
_setStore({
saasReachable: true,
connectionMode: 'saas' as ConnectionMode,
_consecutiveFailures: 0,
});
saveConnectionMode('saas');
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('saas-recovered'));
}
stopRecoveryProbe();
} catch {
_recoveryBackoffMs = Math.min(
_recoveryBackoffMs * RECOVERY_BACKOFF_MULTIPLIER,
RECOVERY_BACKOFF_CAP_MS,
);
log.debug(`[SaaS Recovery] Still unreachable, next probe in ${Math.round(_recoveryBackoffMs / 1000)}s`);
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
}
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
};
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
export function stopRecoveryProbe(): void {
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
_recoveryProbeInterval = null;
}
}
/** Determine the initial connection mode from persisted state */
export function resolveInitialMode(
sessionMeta: { saasUrl: string; account: SaaSAccountInfo | null } | null,
): ConnectionMode {
const persistedMode = loadConnectionMode();
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
return persistedMode;
}
return sessionMeta ? 'saas' : 'tauri';
}

View File

@@ -0,0 +1,103 @@
/**
* SaaS Store Types
*
* Shared type definitions for all SaaS store slices.
*/
import type {
SaaSAccountInfo,
SaaSModelInfo,
TotpSetupResponse,
AgentTemplateAvailable,
AgentTemplateFull,
BillingPlan,
SubscriptionInfo,
PaymentResult,
PaymentStatus,
} from '../../lib/saas-client';
// === Types ===
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
export interface SaaSAuthState {
isLoggedIn: boolean;
account: SaaSAccountInfo | null;
saasUrl: string;
authToken: string | null;
isLoading: boolean;
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
}
export interface SaaSAuthActions {
login: (saasUrl: string, username: string, password: string) => Promise<void>;
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => Promise<void>;
restoreSession: () => Promise<void>;
clearError: () => void;
setupTotp: () => Promise<TotpSetupResponse>;
verifyTotp: (code: string) => Promise<void>;
disableTotp: (password: string) => Promise<void>;
cancelTotpSetup: () => void;
}
export interface SaaSConnectionState {
connectionMode: ConnectionMode;
saasReachable: boolean;
_consecutiveFailures: number;
_heartbeatTimer?: number;
_healthCheckTimer?: number;
_recoveryProbeTimer?: number;
}
export interface SaaSConnectionActions {
setConnectionMode: (mode: ConnectionMode) => void;
registerCurrentDevice: () => Promise<void>;
fetchAvailableModels: () => Promise<void>;
availableModels: SaaSModelInfo[];
}
export interface SaaSConfigState {
// No separate state — uses auth.isLoggedIn and auth.saasUrl
}
export interface SaaSConfigActions {
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
}
export interface SaaSTemplateState {
availableTemplates: AgentTemplateAvailable[];
assignedTemplate: AgentTemplateFull | null;
}
export interface SaaSTemplateActions {
fetchAvailableTemplates: () => Promise<void>;
assignTemplate: (templateId: string) => Promise<void>;
fetchAssignedTemplate: () => Promise<void>;
unassignTemplate: () => Promise<void>;
}
export interface SaaSBillingState {
plans: BillingPlan[];
subscription: SubscriptionInfo | null;
billingLoading: boolean;
billingError: string | null;
}
export interface SaaSBillingActions {
fetchPlans: () => Promise<void>;
fetchSubscription: () => Promise<void>;
fetchBillingOverview: () => Promise<void>;
createPayment: (planId: string, method: 'alipay' | 'wechat') => Promise<PaymentResult | null>;
pollPaymentStatus: (paymentId: string) => Promise<PaymentStatus | null>;
clearBillingError: () => void;
}
// Combined types for backward compatibility
export type SaaSStateSlice = SaaSAuthState & SaaSConnectionState & SaaSTemplateState & SaaSBillingState;
export type SaaSActionsSlice = SaaSAuthActions & SaaSConnectionActions & SaaSConfigActions & SaaSTemplateActions & SaaSBillingActions;
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;

File diff suppressed because it is too large Load Diff