Files
zclaw_openfang/desktop/src/store/saasStore.ts
iven 215c079d29
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
Rust 后端 (heartbeat.rs):
- 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert
- 动态间隔: tokio::select! + Notify 替代不可变 interval
- Config 持久化: update_config 写入 VikingStorage
- heartbeat_init 从 VikingStorage 恢复 config
- 移除 dead code (subscribe, HeartbeatCheckFn)
- Memory stats fallback 分层处理

新增 health_snapshot.rs:
- HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态
- 注册到 lib.rs invoke_handler

前端修复:
- HeartbeatConfig handleSave 同步到 Rust 后端
- App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast
- saasStore 降级后指数退避探测恢复 + saas-recovered 事件
- 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表)
- SettingsLayout 添加 health 导航入口

清理:
- 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
2026-04-15 23:19:24 +08:00

1026 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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.
*
* Connection modes:
* - 'tauri': Local Kernel via Tauri (default)
* - 'gateway': External Gateway via WebSocket
* - 'saas': SaaS backend relay
*/
import { create } from 'zustand';
import {
saasClient,
SaaSApiError,
loadSaaSSession,
loadSaaSSessionSync,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
loadConnectionMode,
type SaaSAccountInfo,
type SaaSModelInfo,
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
type AgentTemplateAvailable,
type AgentTemplateFull,
type BillingPlan,
type SubscriptionInfo,
type PaymentResult,
type PaymentStatus,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
initTelemetryCollector,
stopTelemetryCollector,
} from '../lib/telemetry-collector';
import {
startPromptOTASync,
stopPromptOTASync,
} from '../lib/llm-service';
const log = createLogger('SaaSStore');
// === Device ID ===
/** Generate or load a persistent device ID for this browser instance */
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;
}
const DEVICE_ID = getOrCreateDeviceId();
// === Types ===
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
export interface SaaSStateSlice {
isLoggedIn: boolean;
account: SaaSAccountInfo | null;
saasUrl: string;
authToken: string | null;
connectionMode: ConnectionMode;
availableModels: SaaSModelInfo[];
isLoading: boolean;
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Agent templates available for onboarding */
availableTemplates: AgentTemplateAvailable[];
/** Currently assigned template (null if not yet assigned or assignment removed) */
assignedTemplate: AgentTemplateFull | null;
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
_recoveryProbeTimer?: ReturnType<typeof setInterval>;
// === Billing State ===
plans: BillingPlan[];
subscription: SubscriptionInfo | null;
billingLoading: boolean;
billingError: string | null;
}
export interface SaaSActionsSlice {
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>;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
fetchAvailableTemplates: () => Promise<void>;
/** Assign a template to the current account */
assignTemplate: (templateId: string) => Promise<void>;
/** Fetch the currently assigned template (auto-called after login) */
fetchAssignedTemplate: () => Promise<void>;
/** Unassign the current template */
unassignTemplate: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
// === Billing Actions ===
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;
setupTotp: () => Promise<TotpSetupResponse>;
verifyTotp: (code: string) => Promise<void>;
disableTotp: (password: string) => Promise<void>;
cancelTotpSetup: () => void;
}
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
// === Constants ===
const DEFAULT_SAAS_URL = import.meta.env.VITE_SAAS_URL || 'http://127.0.0.1:8080';
// === Helpers ===
/** Determine the initial connection mode from persisted state */
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';
}
// === SaaS Recovery Probe ===
// When SaaS degrades to local mode, periodically probes SaaS reachability
// with exponential backoff (2min → 3min → 4.5min → 6.75min → 10min cap).
// On recovery, switches back to SaaS mode and notifies user via toast.
let _recoveryProbeInterval: ReturnType<typeof setInterval> | null = null;
let _recoveryBackoffMs = 2 * 60 * 1000; // Start at 2 minutes
const RECOVERY_BACKOFF_CAP_MS = 10 * 60 * 1000; // Max 10 minutes
const RECOVERY_BACKOFF_MULTIPLIER = 1.5;
function startRecoveryProbe() {
if (_recoveryProbeInterval) return; // Already probing
_recoveryBackoffMs = 2 * 60 * 1000; // Reset backoff
log.info('[SaaS Recovery] Starting recovery probe...');
const probe = async () => {
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// SaaS is reachable again — recover
log.info('[SaaS Recovery] SaaS reachable — switching back to SaaS mode');
useSaaSStore.setState({
saasReachable: true,
connectionMode: 'saas',
_consecutiveFailures: 0,
} as unknown as Partial<SaaSStore>);
saveConnectionMode('saas');
// Notify user via custom event (App.tsx listens)
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('saas-recovered'));
}
// Stop probing
stopRecoveryProbe();
} catch {
// Still unreachable — increase backoff
_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`);
// Reschedule with new backoff
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
}
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
};
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
}
function stopRecoveryProbe() {
if (_recoveryProbeInterval) {
clearInterval(_recoveryProbeInterval);
_recoveryProbeInterval = null;
}
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Restore session metadata synchronously (URL + account only).
// Token is loaded from secure storage asynchronously by restoreSession().
const sessionMeta = loadSaaSSessionSync();
const initialMode = resolveInitialMode(sessionMeta);
// If session URL exists, configure the singleton client base URL
if (sessionMeta) {
saasClient.setBaseUrl(sessionMeta.saasUrl);
}
return {
// === Initial State ===
// isLoggedIn starts false — will be set to true by restoreSession()
isLoggedIn: false,
account: sessionMeta?.account ?? null,
saasUrl: sessionMeta?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: null, // In-memory only — loaded from secure storage by restoreSession()
connectionMode: initialMode,
availableModels: [],
isLoading: false,
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
availableTemplates: [],
assignedTemplate: null,
_consecutiveFailures: 0,
// === Billing State ===
plans: [],
subscription: null,
billingLoading: false,
billingError: null,
// === Actions ===
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 {
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!trimmedUsername) {
throw new Error('请输入用户名');
}
if (!password) {
throw new Error('请输入密码');
}
// Configure singleton client and attempt login
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
// Persist session: token + refresh token → secure storage, metadata → localStorage
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, // Not stored in Zustand state — saasClient holds in memory
connectionMode: 'saas',
isLoading: false,
error: null,
});
// Register device and start heartbeat in background
get().registerCurrentDevice().catch((err: unknown) => {
log.warn('Failed to register device:', err);
});
// Fetch available templates in background (non-blocking)
get().fetchAvailableTemplates().catch((err: unknown) => {
log.warn('Failed to fetch templates after login:', err);
});
// Fetch assigned template in background (non-blocking)
get().fetchAssignedTemplate().catch((err: unknown) => {
log.warn('Failed to fetch assigned template after login:', err);
});
// Fetch billing data in background (non-blocking)
get().fetchBillingOverview().catch((err: unknown) => {
log.warn('Failed to fetch billing after login:', err);
});
// Fetch available models in background (non-blocking)
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
});
// Auto-pull SaaS config in background (non-blocking)
get().syncConfigFromSaaS().then(() => {
// After pull, push any locally modified configs back to SaaS
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);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
startPromptOTASync(DEVICE_ID);
} catch (err: unknown) {
// Check for TOTP required signal
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);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync (background, non-blocking)
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();
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
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: SaaSLoginResponse = 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);
});
// Initialize telemetry collector
initTelemetryCollector(DEVICE_ID);
// Start Prompt OTA sync
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();
// Clear currentModel so next connection uses fresh model resolution
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,
});
},
setConnectionMode: (mode: ConnectionMode) => {
const { isLoggedIn } = get();
// Cannot switch to SaaS mode if not logged in
if (mode === 'saas' && !isLoggedIn) {
return;
}
saveConnectionMode(mode);
set({ connectionMode: mode });
},
fetchAvailableModels: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) {
set({ availableModels: [] });
return;
}
try {
saasClient.setBaseUrl(saasUrl);
const models = await saasClient.listModels();
set({ availableModels: models });
// Sync currentModel: if the stored model is not in the available list,
// switch to the first available model to prevent 404 errors
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);
// Do not set error state - model fetch failure is non-critical
set({ availableModels: [] });
}
},
/**
* Push locally modified configs to SaaS (push direction of bidirectional sync).
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
// Collect all dirty config keys
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;
// Generate a client fingerprint
const fingerprint = DEVICE_ID;
const syncRequest = {
client_fingerprint: fingerprint,
action: 'merge' as const,
config_keys: dirtyKeys,
client_values: dirtyValues,
};
// Compute diff first (dry run)
const diff = await saasClient.computeConfigDiff(syncRequest as SyncConfigRequest);
if (diff.conflicts > 0) {
log.warn(`Config sync has ${diff.conflicts} conflicts, using merge strategy`);
}
// Perform actual sync
const result = await saasClient.syncConfig(syncRequest);
log.info(`Config push result: ${result.updated} updated, ${result.created} created, ${result.skipped} skipped`);
// Clear dirty flags for successfully synced keys
for (const key of dirtyKeys) {
localStorage.removeItem(`zclaw-config-dirty.${key}`);
}
} catch (err: unknown) {
log.warn('Failed to push config to SaaS:', err);
}
},
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
// Read last sync timestamp from localStorage
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;
}
// Apply SaaS config values to localStorage
// Each config is stored as zclaw-config.{category}.{key}
for (const config of result.configs) {
if (config.value === null) continue;
const storageKey = `zclaw-config.${config.category}.${config.key}`;
const existing = localStorage.getItem(storageKey);
// Diff check: skip if local was modified since last pull
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') {
// Local was modified since last pull → keep local, skip overwrite
log.warn(`Config conflict, keeping local: ${config.key}`);
continue;
}
// If existing value differs from what we last pulled AND differs from SaaS, local was modified
if (existing !== null && lastPulledValue !== null && existing !== lastPulledValue && existing !== config.value) {
log.warn(`Config conflict (local modified), keeping local: ${config.key}`);
continue;
}
// Only update if the value has actually changed
if (existing !== config.value) {
localStorage.setItem(storageKey, config.value);
// Record the pulled value for future diff checks
localStorage.setItem(`zclaw-config-pulled.${config.category}.${config.key}`, config.value);
log.info(`Config synced: ${config.key}`);
}
}
// Update last sync timestamp
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);
}
},
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; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (!state.isLoggedIn) {
window.clearInterval(timer);
return;
}
try {
await saasClient.deviceHeartbeat(DEVICE_ID);
// Reset failure count on success
if (state._consecutiveFailures > 0) {
log.info(`Heartbeat recovered after ${state._consecutiveFailures} failures`);
}
set({ _consecutiveFailures: 0, saasReachable: true } as unknown as Partial<SaaSStore>);
} catch (err) {
const failures = state._consecutiveFailures + 1;
log.warn(`Heartbeat failed (${failures}/${DEGRADE_AFTER_FAILURES}): ${err}`);
set({ _consecutiveFailures: failures } as unknown as Partial<SaaSStore>);
// Auto-degrade to local mode after threshold
if (failures >= DEGRADE_AFTER_FAILURES && state.connectionMode === 'saas') {
log.warn(`SaaS unreachable after ${failures} attempts — degrading to local mode`);
set({
saasReachable: false,
connectionMode: 'tauri',
} as unknown as Partial<SaaSStore>);
saveConnectionMode('tauri');
// Start recovery probe with exponential backoff
startRecoveryProbe();
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial<SaaSStore>);
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
}
},
fetchAvailableTemplates: async () => {
try {
const templates = await saasClient.fetchAvailableTemplates();
set({ availableTemplates: templates });
} catch {
// Graceful degradation - don't block login
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);
// Don't throw — let wizard continue with fallback flow
}
},
fetchAssignedTemplate: async () => {
try {
const template = await saasClient.getAssignedTemplate();
set({ assignedTemplate: template });
} catch {
// Not critical — null is fine
set({ assignedTemplate: null });
}
},
unassignTemplate: async () => {
try {
await saasClient.unassignTemplate();
set({ assignedTemplate: null });
} catch (err) {
log.warn('Failed to unassign template:', err);
}
},
clearError: () => {
set({ error: null });
},
restoreSession: async () => {
const restored = await loadSaaSSession();
if (!restored) return;
saasClient.setBaseUrl(restored.saasUrl);
// Strategy: access token → refresh token → cookie auth → fail
let account: SaaSAccountInfo | null = null;
let newToken: string | null = restored.token;
if (restored.token) {
// Token from secure storage — use as Bearer
saasClient.setToken(restored.token);
try {
account = await saasClient.me();
} catch {
// Access token expired — clear and try refresh
saasClient.setToken(null);
newToken = null;
}
}
if (!account && restored.refreshToken) {
// Try refresh token from secure storage
saasClient.setRefreshToken(restored.refreshToken);
try {
const refreshed = await saasClient.refreshMutex();
newToken = refreshed;
saasClient.setToken(refreshed);
account = await saasClient.me();
// Persist the new tokens back to secure storage
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 {
// Refresh token also expired or invalid
saasClient.setRefreshToken(null);
saasClient.setToken(null);
newToken = null;
}
}
if (!account) {
// Try cookie-based auth (works for same-origin, e.g. admin panel)
account = await saasClient.restoreFromCookie();
}
if (!account) {
// All methods failed — user needs to re-login
set({
isLoggedIn: false,
account: null,
saasUrl: restored.saasUrl,
authToken: null,
});
return;
}
set({
isLoggedIn: true,
account,
saasUrl: restored.saasUrl,
authToken: newToken,
// If token refresh succeeded, always restore to 'saas' mode
// regardless of what was persisted (heartbeat may have degraded to 'tauri')
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);
},
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 })
); // Token in saasClient memory only
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 });
},
// === Billing Actions ===
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') {
// Refresh subscription after successful payment
await get().fetchSubscription();
}
return status;
} catch (err: unknown) {
log.warn('Failed to poll payment status:', err);
return null;
}
},
clearBillingError: () => {
set({ billingError: null });
},
};
});