chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -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 });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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"设置页面添加自定义模型配置');
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
Reference in New Issue
Block a user