chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、 文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
@@ -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