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:
iven
2026-03-27 14:21:23 +08:00
parent a66b675675
commit 15450ca895
9 changed files with 1407 additions and 1 deletions

View 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',
});
}
},
};
});