feat(saas): Phase 3 桌面端 SaaS 集成 — 客户端、Store、UI、LLM 适配器
- saas-client.ts: SaaS HTTP 客户端 (登录/注册/Token/模型列表/Chat Relay/配置同步) - saasStore.ts: Zustand 状态管理 (登录态、连接模式、可用模型、localStorage 持久化) - connectionStore.ts: 集成 SaaS 模式分支 (connect() 优先检查 SaaS 连接模式) - llm-service.ts: SaasLLMAdapter 实现 (通过 SaaS Relay 代理 LLM 调用) - SaaSLogin.tsx: 登录/注册表单 (服务器地址、用户名、密码、邮箱) - SaaSStatus.tsx: 连接状态展示 (账号信息、健康检查、可用模型列表) - SaaSSettings.tsx: SaaS 设置页面入口 (登录态切换、功能列表) - SettingsLayout.tsx: 添加 SaaS 平台菜单项 - store/index.ts: 导出 useSaaSStore
This commit is contained in:
293
desktop/src/store/saasStore.ts
Normal file
293
desktop/src/store/saasStore.ts
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* SaaS Store - SaaS Platform Connection State Management
|
||||
*
|
||||
* Manages SaaS login state, account info, connection mode,
|
||||
* and available models. Persists auth state to localStorage
|
||||
* via saas-client helpers.
|
||||
*
|
||||
* Connection modes:
|
||||
* - 'tauri': Local Kernel via Tauri (default)
|
||||
* - 'gateway': External Gateway via WebSocket
|
||||
* - 'saas': SaaS backend relay
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import {
|
||||
saasClient,
|
||||
SaaSApiError,
|
||||
loadSaaSSession,
|
||||
saveSaaSSession,
|
||||
clearSaaSSession,
|
||||
saveConnectionMode,
|
||||
loadConnectionMode,
|
||||
type SaaSAccountInfo,
|
||||
type SaaSModelInfo,
|
||||
type SaaSLoginResponse,
|
||||
} from '../lib/saas-client';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('SaaSStore');
|
||||
|
||||
// === Types ===
|
||||
|
||||
export type ConnectionMode = 'tauri' | 'gateway' | 'saas';
|
||||
|
||||
export interface SaaSStateSlice {
|
||||
isLoggedIn: boolean;
|
||||
account: SaaSAccountInfo | null;
|
||||
saasUrl: string;
|
||||
authToken: string | null;
|
||||
connectionMode: ConnectionMode;
|
||||
availableModels: SaaSModelInfo[];
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface SaaSActionsSlice {
|
||||
login: (saasUrl: string, username: string, password: string) => Promise<void>;
|
||||
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
|
||||
logout: () => void;
|
||||
setConnectionMode: (mode: ConnectionMode) => void;
|
||||
fetchAvailableModels: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
restoreSession: () => void;
|
||||
}
|
||||
|
||||
export type SaaSStore = SaaSStateSlice & SaaSActionsSlice;
|
||||
|
||||
// === Constants ===
|
||||
|
||||
const DEFAULT_SAAS_URL = 'https://saas.zclaw.com';
|
||||
|
||||
// === Helpers ===
|
||||
|
||||
/** Determine the initial connection mode from persisted state */
|
||||
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
|
||||
const persistedMode = loadConnectionMode();
|
||||
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
|
||||
return persistedMode;
|
||||
}
|
||||
return session ? 'saas' : 'tauri';
|
||||
}
|
||||
|
||||
// === Store Implementation ===
|
||||
|
||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||
// Restore session from localStorage on init
|
||||
const session = loadSaaSSession();
|
||||
const initialMode = resolveInitialMode(session);
|
||||
|
||||
// If session exists, configure the singleton client
|
||||
if (session) {
|
||||
saasClient.setBaseUrl(session.saasUrl);
|
||||
saasClient.setToken(session.token);
|
||||
}
|
||||
|
||||
return {
|
||||
// === Initial State ===
|
||||
isLoggedIn: session !== null,
|
||||
account: session?.account ?? null,
|
||||
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
|
||||
authToken: session?.token ?? null,
|
||||
connectionMode: initialMode,
|
||||
availableModels: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
// === Actions ===
|
||||
|
||||
login: async (saasUrl: string, username: string, password: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const trimmedUrl = saasUrl.trim();
|
||||
const trimmedUsername = username.trim();
|
||||
|
||||
if (!trimmedUrl) {
|
||||
throw new Error('请输入服务器地址');
|
||||
}
|
||||
if (!trimmedUsername) {
|
||||
throw new Error('请输入用户名');
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('请输入密码');
|
||||
}
|
||||
|
||||
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||
|
||||
// Configure singleton client and attempt login
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
|
||||
|
||||
// Persist session
|
||||
const sessionData = {
|
||||
token: loginData.token,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: loginData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: loginData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Fetch available models in background (non-blocking)
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after login:', err);
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof SaaSApiError
|
||||
? err.message
|
||||
: err instanceof Error
|
||||
? err.message
|
||||
: String(err);
|
||||
|
||||
const isNetworkError = message.includes('Failed to fetch')
|
||||
|| message.includes('NetworkError')
|
||||
|| message.includes('ECONNREFUSED')
|
||||
|| message.includes('timeout');
|
||||
|
||||
const userMessage = isNetworkError
|
||||
? `无法连接到 SaaS 服务器: ${get().saasUrl}`
|
||||
: message;
|
||||
|
||||
set({ isLoading: false, error: userMessage });
|
||||
throw new Error(userMessage);
|
||||
}
|
||||
},
|
||||
|
||||
register: async (saasUrl: string, username: string, email: string, password: string, displayName?: string) => {
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const trimmedUrl = saasUrl.trim();
|
||||
if (!trimmedUrl) {
|
||||
throw new Error('请输入服务器地址');
|
||||
}
|
||||
if (!username.trim()) {
|
||||
throw new Error('请输入用户名');
|
||||
}
|
||||
if (!email.trim()) {
|
||||
throw new Error('请输入邮箱');
|
||||
}
|
||||
if (!password) {
|
||||
throw new Error('请输入密码');
|
||||
}
|
||||
|
||||
const normalizedUrl = trimmedUrl.replace(/\/+$/, '');
|
||||
|
||||
saasClient.setBaseUrl(normalizedUrl);
|
||||
const registerData: SaaSLoginResponse = await saasClient.register({
|
||||
username: username.trim(),
|
||||
email: email.trim(),
|
||||
password,
|
||||
display_name: displayName,
|
||||
});
|
||||
|
||||
const sessionData = {
|
||||
token: registerData.token,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
};
|
||||
saveSaaSSession(sessionData);
|
||||
saveConnectionMode('saas');
|
||||
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: registerData.account,
|
||||
saasUrl: normalizedUrl,
|
||||
authToken: registerData.token,
|
||||
connectionMode: 'saas',
|
||||
isLoading: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
get().fetchAvailableModels().catch((err: unknown) => {
|
||||
log.warn('Failed to fetch models after register:', err);
|
||||
});
|
||||
} 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: () => {
|
||||
saasClient.setToken(null);
|
||||
clearSaaSSession();
|
||||
saveConnectionMode('tauri');
|
||||
|
||||
set({
|
||||
isLoggedIn: false,
|
||||
account: null,
|
||||
authToken: null,
|
||||
connectionMode: 'tauri',
|
||||
availableModels: [],
|
||||
error: null,
|
||||
});
|
||||
},
|
||||
|
||||
setConnectionMode: (mode: ConnectionMode) => {
|
||||
const { isLoggedIn } = get();
|
||||
|
||||
// Cannot switch to SaaS mode if not logged in
|
||||
if (mode === 'saas' && !isLoggedIn) {
|
||||
return;
|
||||
}
|
||||
|
||||
saveConnectionMode(mode);
|
||||
set({ connectionMode: mode });
|
||||
},
|
||||
|
||||
fetchAvailableModels: async () => {
|
||||
const { isLoggedIn, authToken, saasUrl } = get();
|
||||
|
||||
if (!isLoggedIn || !authToken) {
|
||||
set({ availableModels: [] });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
saasClient.setBaseUrl(saasUrl);
|
||||
saasClient.setToken(authToken);
|
||||
const models = await saasClient.listModels();
|
||||
set({ availableModels: models });
|
||||
} catch (err: unknown) {
|
||||
log.warn('Failed to fetch available models:', err);
|
||||
// Do not set error state - model fetch failure is non-critical
|
||||
set({ availableModels: [] });
|
||||
}
|
||||
},
|
||||
|
||||
clearError: () => {
|
||||
set({ error: null });
|
||||
},
|
||||
|
||||
restoreSession: () => {
|
||||
const restored = loadSaaSSession();
|
||||
if (restored) {
|
||||
saasClient.setBaseUrl(restored.saasUrl);
|
||||
saasClient.setToken(restored.token);
|
||||
set({
|
||||
isLoggedIn: true,
|
||||
account: restored.account,
|
||||
saasUrl: restored.saasUrl,
|
||||
authToken: restored.token,
|
||||
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user