feat(desktop): integrate SaaS llm_routing, template API, and onboarding template selection

- Add AgentTemplateAvailable/AgentTemplateFull types and fetchAvailableTemplates/fetchTemplateFull API methods to saas-client
- Add llm_routing field to SaaSAccountInfo for admin-configured routing priority
- Add availableTemplates state and fetchAvailableTemplates action to saasStore with background fetch on login
- Add admin llm_routing priority check in connectionStore connect() to force relay or local mode
- Add createFromTemplate action to agentStore with SOUL.md persistence
- Add Step 0 template selection to AgentOnboardingWizard with grid layout for template browsing
This commit is contained in:
iven
2026-03-31 03:15:45 +08:00
parent 9fb9c3204c
commit c9b9c5231b
6 changed files with 927 additions and 1025 deletions

View File

@@ -6,6 +6,7 @@
*/
import { create } from 'zustand';
import type { GatewayClient } from '../lib/gateway-client';
import type { AgentTemplateFull } from '../lib/saas-client';
import { useChatStore } from './chatStore';
// === Types ===
@@ -33,6 +34,7 @@ export interface Clone {
communicationStyle?: string; // 沟通风格描述
notes?: string; // 用户备注
onboardingCompleted?: boolean; // 是否完成首次引导
source_template_id?: string; // 模板来源 ID
}
export interface UsageStats {
@@ -83,6 +85,7 @@ export interface AgentStateSlice {
export interface AgentActionsSlice {
loadClones: () => Promise<void>;
createClone: (opts: CloneCreateOptions) => Promise<Clone | undefined>;
createFromTemplate: (template: AgentTemplateFull) => Promise<Clone | undefined>;
updateClone: (id: string, updates: Partial<Clone>) => Promise<Clone | undefined>;
deleteClone: (id: string) => Promise<void>;
loadUsageStats: () => Promise<void>;
@@ -173,6 +176,43 @@ export const useAgentStore = create<AgentStore>((set, get) => ({
}
},
createFromTemplate: async (template: AgentTemplateFull) => {
const client = getClient();
if (!client) return undefined;
set({ isLoading: true, error: null });
try {
const result = await client.createClone({
name: template.name,
emoji: template.emoji,
personality: template.personality,
scenarios: template.scenarios,
communicationStyle: template.communication_style,
model: template.model,
});
const cloneId = result?.clone?.id;
// Persist SOUL.md via identity system
if (cloneId && template.soul_content) {
try {
const { intelligenceClient } = await import('../lib/intelligence-client');
await intelligenceClient.identity.updateFile(cloneId, 'soul', template.soul_content);
} catch (e) {
console.warn('Failed to persist soul_content:', e);
}
}
await get().loadClones();
return result?.clone;
} catch (error) {
set({ error: String(error) });
return undefined;
} finally {
set({ isLoading: false });
}
},
updateClone: async (id: string, updates: Partial<Clone>) => {
const client = getClient();
if (!client) {

View File

@@ -350,6 +350,70 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
try {
set({ error: null });
// === Admin Routing Priority ===
// Admin-configured llm_routing takes priority over localStorage connectionMode.
// This allows admins to force all clients to use relay or local mode.
let adminForceLocal = false;
try {
const storedAccount = JSON.parse(localStorage.getItem('zclaw-saas-account') || '{}');
const adminRouting = storedAccount?.account?.llm_routing;
if (adminRouting === 'relay') {
// Force SaaS Relay mode — admin override
// Set connection mode to 'saas' so the SaaS relay section below activates
localStorage.setItem('zclaw-connection-mode', 'saas');
log.debug('Admin llm_routing=relay: forcing SaaS relay mode');
} else if (adminRouting === 'local' && isTauriRuntime()) {
// Force local Kernel mode — skip SaaS relay entirely
adminForceLocal = true;
localStorage.setItem('zclaw-connection-mode', 'tauri');
log.debug('Admin llm_routing=local: forcing local Kernel mode');
}
} catch { /* ignore parse errors, fall through to default logic */ }
// === Internal Kernel Mode: Admin forced local ===
// If admin forced local mode, skip directly to Tauri Kernel section
if (adminForceLocal) {
const kernelClient = getKernelClient();
const modelConfig = await getDefaultModelConfigAsync();
if (!modelConfig) {
throw new Error('请先在"模型与 API"设置页面添加自定义模型配置');
}
if (!modelConfig.apiKey) {
throw new Error(`模型 ${modelConfig.model} 未配置 API Key请在"模型与 API"设置页面配置`);
}
kernelClient.setConfig({
provider: modelConfig.provider,
model: modelConfig.model,
apiKey: modelConfig.apiKey,
baseUrl: modelConfig.baseUrl,
apiProtocol: modelConfig.apiProtocol,
});
kernelClient.onStateChange = (state: ConnectionState) => {
set({ connectionState: state });
};
kernelClient.onLog = (level, message) => {
set((s) => ({
logs: [...s.logs.slice(-99), { timestamp: Date.now(), level, message }],
}));
};
set({ client: kernelClient });
const { initializeStores } = await import('./index');
initializeStores();
await kernelClient.connect();
set({ gatewayVersion: '0.1.0-internal' });
log.debug('Connected to internal ZCLAW Kernel (admin forced local)');
return;
}
// === SaaS Relay Mode ===
// Check connection mode from localStorage (set by saasStore).
// When SaaS is unreachable, gracefully degrade to local kernel mode

View File

@@ -26,6 +26,7 @@ import {
type SaaSLoginResponse,
type TotpSetupResponse,
type SyncConfigRequest,
type AgentTemplateAvailable,
} from '../lib/saas-client';
import { createLogger } from '../lib/logger';
import {
@@ -70,6 +71,8 @@ export interface SaaSStateSlice {
totpSetupData: TotpSetupResponse | null;
/** Whether SaaS backend is currently reachable */
saasReachable: boolean;
/** Agent templates available for onboarding */
availableTemplates: AgentTemplateAvailable[];
/** Consecutive heartbeat/health-check failures */
_consecutiveFailures: number;
_heartbeatTimer?: ReturnType<typeof setInterval>;
@@ -86,6 +89,7 @@ export interface SaaSActionsSlice {
syncConfigFromSaaS: () => Promise<void>;
pushConfigToSaaS: () => Promise<void>;
registerCurrentDevice: () => Promise<void>;
fetchAvailableTemplates: () => Promise<void>;
clearError: () => void;
restoreSession: () => void;
setupTotp: () => Promise<TotpSetupResponse>;
@@ -140,6 +144,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
totpRequired: false,
totpSetupData: null,
saasReachable: true,
availableTemplates: [],
_consecutiveFailures: 0,
// === Actions ===
@@ -189,6 +194,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
log.warn('Failed to register device:', err);
});
// Fetch available templates in background (non-blocking)
get().fetchAvailableTemplates().catch((err: unknown) => {
log.warn('Failed to fetch templates after login:', err);
});
// Fetch available models in background (non-blocking)
get().fetchAvailableModels().catch((err: unknown) => {
log.warn('Failed to fetch models after login:', err);
@@ -587,6 +597,16 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
fetchAvailableTemplates: async () => {
try {
const templates = await saasClient.fetchAvailableTemplates();
set({ availableTemplates: templates });
} catch {
// Graceful degradation - don't block login
set({ availableTemplates: [] });
}
},
clearError: () => {
set({ error: null });
},