chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -8,6 +8,7 @@ import { getSkillDiscovery } from '../lib/skill-discovery';
import { useOfflineStore, isOffline } from './offlineStore';
import { useConnectionStore } from './connectionStore';
import { createLogger } from '../lib/logger';
import { generateRandomString } from '../lib/crypto-utils';
const log = createLogger('ChatStore');
@@ -106,7 +107,7 @@ interface ChatState {
}
function generateConvId(): string {
return `conv_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
return `conv_${Date.now()}_${generateRandomString(4)}`;
}
function deriveTitle(messages: Message[]): string {
@@ -224,7 +225,12 @@ export const useChatStore = create<ChatState>()(
const conversations = upsertActiveConversation([...state.conversations], state);
// Try to find existing conversation for this agent
const agentConversation = conversations.find(c => c.agentId === agent.id);
// DEFAULT_AGENT conversations are stored with agentId: null (via resolveConversationAgentId),
// so we need to match both the agent's ID and null for default agent lookups.
const agentConversation = conversations.find(c =>
c.agentId === agent.id ||
(agent.id === DEFAULT_AGENT.id && c.agentId === null)
);
if (agentConversation) {
// Restore the agent's previous conversation
@@ -251,7 +257,11 @@ export const useChatStore = create<ChatState>()(
syncAgents: (profiles) =>
set((state) => {
const agents = profiles.length > 0 ? profiles.map(toChatAgent) : [DEFAULT_AGENT];
const cloneAgents = profiles.length > 0 ? profiles.map(toChatAgent) : [];
// Always include DEFAULT_AGENT so users can switch back to default conversations
const agents = cloneAgents.length > 0
? [DEFAULT_AGENT, ...cloneAgents]
: [DEFAULT_AGENT];
const currentAgent = state.currentConversationId
? resolveAgentForConversation(
state.conversations.find((conversation) => conversation.id === state.currentConversationId)?.agentId || null,
@@ -260,7 +270,20 @@ export const useChatStore = create<ChatState>()(
: state.currentAgent
? agents.find((agent) => agent.id === state.currentAgent?.id) || agents[0]
: agents[0];
return { agents, currentAgent };
// Safety net: if rehydration failed to restore messages (onRehydrateStorage
// direct mutation doesn't trigger re-renders), restore them here via set().
let messages = state.messages;
let sessionKey = state.sessionKey;
if (messages.length === 0 && state.currentConversationId && state.conversations.length > 0) {
const conv = state.conversations.find(c => c.id === state.currentConversationId);
if (conv && conv.messages.length > 0) {
messages = conv.messages.map(m => ({ ...m }));
sessionKey = conv.sessionKey;
}
}
return { agents, currentAgent, messages, sessionKey };
}),
setCurrentModel: (model) => set({ currentModel: model }),
@@ -307,7 +330,7 @@ export const useChatStore = create<ChatState>()(
sendMessage: async (content: string) => {
const { addMessage, currentAgent, sessionKey } = get();
const effectiveSessionKey = sessionKey || `session_${Date.now()}`;
const effectiveSessionKey = sessionKey || crypto.randomUUID();
const effectiveAgentId = resolveGatewayAgentId(currentAgent);
const agentId = currentAgent?.id || 'zclaw-main';
@@ -413,7 +436,7 @@ export const useChatStore = create<ChatState>()(
},
onTool: (tool: string, input: string, output: string) => {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `tool_${Date.now()}_${generateRandomString(4)}`,
role: 'tool',
content: output || input,
timestamp: new Date(),
@@ -426,7 +449,7 @@ export const useChatStore = create<ChatState>()(
},
onHand: (name: string, status: string, result?: unknown) => {
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `hand_${Date.now()}_${generateRandomString(4)}`,
role: 'hand',
content: result
? (typeof result === 'string' ? result : JSON.stringify(result, null, 2))
@@ -588,7 +611,7 @@ export const useChatStore = create<ChatState>()(
}));
} else if (delta.stream === 'tool') {
const toolMsg: Message = {
id: `tool_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `tool_${Date.now()}_${generateRandomString(4)}`,
role: 'tool',
content: delta.toolOutput || '',
timestamp: new Date(),
@@ -616,7 +639,7 @@ export const useChatStore = create<ChatState>()(
} else if (delta.stream === 'hand') {
// Handle Hand trigger events from ZCLAW
const handMsg: Message = {
id: `hand_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `hand_${Date.now()}_${generateRandomString(4)}`,
role: 'hand',
content: delta.handResult
? (typeof delta.handResult === 'string' ? delta.handResult : JSON.stringify(delta.handResult, null, 2))
@@ -631,7 +654,7 @@ export const useChatStore = create<ChatState>()(
} else if (delta.stream === 'workflow') {
// Handle Workflow execution events from ZCLAW
const workflowMsg: Message = {
id: `workflow_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`,
id: `workflow_${Date.now()}_${generateRandomString(4)}`,
role: 'workflow',
content: delta.workflowResult
? (typeof delta.workflowResult === 'string' ? delta.workflowResult : JSON.stringify(delta.workflowResult, null, 2))
@@ -671,12 +694,18 @@ export const useChatStore = create<ChatState>()(
}
}
// Restore messages from current conversation if exists
// Restore messages from current conversation via setState() to properly
// trigger subscriber re-renders. Direct mutation (state.messages = ...)
// does NOT notify zustand subscribers, leaving the UI stuck on [].
// Safe to reference useChatStore here because zustand persist runs
// hydration via setTimeout(1ms), so the store is fully created by
// the time this callback executes.
if (state?.currentConversationId && state.conversations) {
const currentConv = state.conversations.find(c => c.id === state.currentConversationId);
if (currentConv) {
state.messages = [...currentConv.messages];
state.sessionKey = currentConv.sessionKey;
if (currentConv && currentConv.messages.length > 0) {
const messages = currentConv.messages.map(m => ({ ...m }));
const sessionKey = currentConv.sessionKey;
useChatStore.setState({ messages, sessionKey });
}
}
},

View File

@@ -28,6 +28,7 @@ import {
} from '../lib/health-check';
import { useConfigStore } from './configStore';
import { createLogger } from '../lib/logger';
import { secureStorage } from '../lib/secure-storage';
const log = createLogger('ConnectionStore');
@@ -38,6 +39,7 @@ const log = createLogger('ConnectionStore');
// === Custom Models Helpers ===
const CUSTOM_MODELS_STORAGE_KEY = 'zclaw-custom-models';
const MODEL_KEY_SECURE_PREFIX = 'zclaw-secure-model-key:';
interface CustomModel {
id: string;
@@ -51,7 +53,8 @@ interface CustomModel {
}
/**
* Get custom models from localStorage
* Get custom models from localStorage.
* NOTE: apiKeys are stripped from localStorage. Use getCustomModelApiKey() to retrieve them.
*/
function loadCustomModels(): CustomModel[] {
try {
@@ -66,13 +69,147 @@ function loadCustomModels(): CustomModel[] {
}
/**
* Get the default model configuration
* Save custom models to localStorage. API keys are stripped before saving.
* Use saveCustomModelApiKey() separately to persist the key securely.
*/
function saveCustomModels(models: CustomModel[]): void {
try {
// Strip apiKeys before persisting to localStorage
const sanitized = models.map(m => {
const { apiKey: _, ...rest } = m;
return rest;
});
localStorage.setItem(CUSTOM_MODELS_STORAGE_KEY, JSON.stringify(sanitized));
} catch (err) {
log.error('Failed to save models:', err);
}
}
/**
* Save an API key for a custom model to secure storage.
*/
export async function saveCustomModelApiKey(modelId: string, apiKey: string): Promise<void> {
if (!apiKey.trim()) {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
return;
}
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + modelId, apiKey.trim());
}
/**
* Retrieve an API key for a custom model from secure storage.
* Falls back to localStorage if secure storage is empty (migration path).
*/
export async function getCustomModelApiKey(modelId: string): Promise<string | null> {
const secureKey = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + modelId);
if (secureKey) {
return secureKey;
}
return null;
}
/**
* Delete an API key for a custom model from secure storage.
*/
export async function deleteCustomModelApiKey(modelId: string): Promise<void> {
await secureStorage.delete(MODEL_KEY_SECURE_PREFIX + modelId);
}
/**
* Migrate all plaintext API keys from localStorage custom models to secure storage.
* This is idempotent -- running it multiple times is safe.
* After migration, apiKeys are stripped from localStorage.
*/
export async function migrateModelApiKeysToSecureStorage(): Promise<void> {
try {
const stored = localStorage.getItem(CUSTOM_MODELS_STORAGE_KEY);
if (!stored) return;
const models: CustomModel[] = JSON.parse(stored);
let hasPlaintextKeys = false;
for (const model of models) {
if (model.apiKey && model.apiKey.trim()) {
hasPlaintextKeys = true;
// Check if secure storage already has this key (skip if migrated)
const existing = await secureStorage.get(MODEL_KEY_SECURE_PREFIX + model.id);
if (!existing) {
await secureStorage.set(MODEL_KEY_SECURE_PREFIX + model.id, model.apiKey.trim());
log.debug('Migrated API key for model:', model.id);
}
}
}
if (hasPlaintextKeys) {
// Re-save without apiKeys to clear them from localStorage
saveCustomModels(models);
log.info('Migrated', models.length, 'model API keys to secure storage');
}
} catch (err) {
log.warn('Failed to migrate model API keys:', err);
}
}
/**
* Get the default model configuration (async version).
* Retrieves apiKey from secure storage.
*
* Priority:
* 1. Model with isDefault: true
* 2. Model matching chatStore's currentModel
* 3. First model in the list
*/
export async function getDefaultModelConfigAsync(): Promise<{ provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null> {
const models = loadCustomModels();
// Priority 1: Find model with isDefault: true
let defaultModel = models.find(m => m.isDefault === true);
// Priority 2: Find model matching chatStore's currentModel
if (!defaultModel) {
try {
const chatStoreData = localStorage.getItem('zclaw-chat-storage');
if (chatStoreData) {
const parsed = JSON.parse(chatStoreData);
const currentModelId = parsed?.state?.currentModel;
if (currentModelId) {
defaultModel = models.find(m => m.id === currentModelId);
}
}
} catch (err) {
log.warn('Failed to read chatStore:', err);
}
}
// Priority 3: First model
if (!defaultModel) {
defaultModel = models[0];
}
if (defaultModel) {
// Retrieve apiKey from secure storage
const apiKey = await getCustomModelApiKey(defaultModel.id);
return {
provider: defaultModel.provider,
model: defaultModel.id,
apiKey: apiKey || '',
baseUrl: defaultModel.baseUrl || '',
apiProtocol: defaultModel.apiProtocol || 'openai',
};
}
return null;
}
/**
* Get the default model configuration (sync fallback).
* NOTE: This version cannot retrieve apiKeys from secure storage.
* Use getDefaultModelConfigAsync() when possible.
*
* @deprecated Use getDefaultModelConfigAsync() instead. This sync version
* is kept only for backward compatibility and will NOT return apiKeys that
* were stored via secure storage.
*/
export function getDefaultModelConfig(): { provider: string; model: string; apiKey: string; baseUrl: string; apiProtocol: string } | null {
const models = loadCustomModels();
@@ -234,7 +371,14 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
// Health check via GET /api/v1/relay/models
try {
await saasClient.listModels();
} catch (err) {
} catch (err: unknown) {
// Handle expired session — clear auth and trigger re-login
const status = (err as { status?: number })?.status;
if (status === 401) {
const { useSaaSStore } = await import('./saasStore');
useSaaSStore.getState().logout();
throw new Error('SaaS 会话已过期,请重新登录');
}
const errMsg = err instanceof Error ? err.message : String(err);
throw new Error(`SaaS 平台连接失败: ${errMsg}`);
}
@@ -253,8 +397,8 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
log.debug('Using internal ZCLAW Kernel (no external process needed)');
const kernelClient = getKernelClient();
// Get model config from custom models settings
const modelConfig = getDefaultModelConfig();
// Get model config from custom models settings (async for secure key retrieval)
const modelConfig = await getDefaultModelConfigAsync();
if (!modelConfig) {
throw new Error('请先在"模型与 API"设置页面添加自定义模型配置');

View File

@@ -8,6 +8,7 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { useConnectionStore, getConnectionState } from './connectionStore';
import { generateRandomString } from '../lib/crypto-utils';
// === Types ===
@@ -75,7 +76,7 @@ function calculateNextDelay(currentDelay: number): number {
}
function generateMessageId(): string {
return `queued_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
return `queued_${Date.now()}_${generateRandomString(6)}`;
}
// === Store Implementation ===

View File

@@ -24,8 +24,17 @@ import {
type SaaSModelInfo,
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
} 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');
@@ -58,6 +67,12 @@ export interface SaaSStateSlice {
error: string | null;
totpRequired: boolean;
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
_healthCheckTimer?: ReturnType<typeof setInterval>;
}
export interface SaaSActionsSlice {
@@ -67,6 +82,8 @@ export interface SaaSActionsSlice {
logout: () => void;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
@@ -118,19 +135,21 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
error: null,
totpRequired: false,
totpSetupData: null,
saasReachable: true,
_consecutiveFailures: 0,
// === Actions ===
login: async (saasUrl: string, username: string, password: string) => {
set({ isLoading: true, error: null });
try {
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const trimmedUrl = saasUrl.trim();
const trimmedUsername = username.trim();
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
const requestUrl = normalizedUrl || window.location.origin;
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
try {
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!trimmedUsername) {
throw new Error('请输入用户名');
}
@@ -138,8 +157,6 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
throw new Error('请输入密码');
}
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
// Configure singleton client and attempt login
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
@@ -172,6 +189,22 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
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) {
@@ -191,7 +224,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|| message.includes('timeout');
const userMessage = isNetworkError
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
? `无法连接到 SaaS 服务器: ${requestUrl}`
: message;
set({ isLoading: false, error: userMessage });
@@ -232,6 +265,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
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);
@@ -245,9 +284,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
const trimmedUrl = saasUrl.trim();
if (!trimmedUrl) {
throw new Error('请输入服务器地址');
}
// 空 trimmedUrl 表示走 Vite proxy开发模式允许通过
if (!username.trim()) {
throw new Error('请输入用户名');
}
@@ -293,6 +330,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
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
@@ -309,6 +352,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setToken(null);
clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
set({
isLoggedIn: false,
@@ -354,6 +399,131 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
/**
* 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, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// 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, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// 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 lastPullKey = `zclaw-config-pull-ts.${config.category}.${config.key}`;
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`);
} catch (err: unknown) {
log.warn('Failed to sync config from SaaS:', err);
}
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
@@ -368,21 +538,43 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
platform: navigator.platform,
app_version: __APP_VERSION__ || 'unknown',
app_version: (typeof __APP_VERSION__ !== 'undefined' ? __APP_VERSION__ : 'unknown'),
});
log.info('Device registered successfully');
// Start periodic heartbeat (every 5 minutes)
// Start periodic heartbeat (every 5 minutes) with failure tracking
if (typeof window !== 'undefined' && !get()._heartbeatTimer) {
const timer = window.setInterval(() => {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (state.isLoggedIn && state.authToken) {
saasClient.deviceHeartbeat(DEVICE_ID).catch(() => {});
} else {
if (!state.isLoggedIn || !state.authToken) {
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');
}
}
}, 5 * 60 * 1000);
set({ _heartbeatTimer: timer } as unknown as Partial<SaaSStore>);
set({ _heartbeatTimer: timer, _consecutiveFailures: 0 } as unknown as Partial<SaaSStore>);
}
} catch (err: unknown) {
log.warn('Failed to register device:', err);
@@ -406,6 +598,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
}
},