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:
362
desktop/src/store/saas/auth.ts
Normal file
362
desktop/src/store/saas/auth.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
84
desktop/src/store/saas/billing.ts
Normal file
84
desktop/src/store/saas/billing.ts
Normal 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 }),
|
||||
};
|
||||
}
|
||||
309
desktop/src/store/saas/index.ts
Normal file
309
desktop/src/store/saas/index.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
93
desktop/src/store/saas/shared.ts
Normal file
93
desktop/src/store/saas/shared.ts
Normal 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';
|
||||
}
|
||||
103
desktop/src/store/saas/types.ts
Normal file
103
desktop/src/store/saas/types.ts
Normal 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
Reference in New Issue
Block a user