diff --git a/crates/zclaw-saas/migrations/20260403000001_accounts_template_assignment.sql b/crates/zclaw-saas/migrations/20260403000001_accounts_template_assignment.sql new file mode 100644 index 0000000..883699d --- /dev/null +++ b/crates/zclaw-saas/migrations/20260403000001_accounts_template_assignment.sql @@ -0,0 +1,9 @@ +-- Phase 1: accounts 表增加 assigned_template_id +-- 用户选择行业模板后记录分配关系,用于跟踪和跳过 onboarding +-- ON DELETE SET NULL: 模板被删除时不影响账户 + +ALTER TABLE accounts ADD COLUMN assigned_template_id TEXT + REFERENCES agent_templates(id) ON DELETE SET NULL; + +COMMENT ON COLUMN accounts.assigned_template_id IS + '用户选择的行业模板 ID,用于跟踪模板分配状态'; diff --git a/crates/zclaw-saas/src/agent_template/handlers.rs b/crates/zclaw-saas/src/agent_template/handlers.rs index 4fa966f..3f51950 100644 --- a/crates/zclaw-saas/src/agent_template/handlers.rs +++ b/crates/zclaw-saas/src/agent_template/handlers.rs @@ -139,3 +139,52 @@ pub async fn archive_template( Ok(Json(result)) } + +// --- Template Assignment --- + +/// POST /api/v1/accounts/me/assign-template — 分配行业模板到当前账户 +pub async fn assign_template( + State(state): State, + Extension(ctx): Extension, + Json(req): Json, +) -> SaasResult> { + check_permission(&ctx, "model:read")?; + + let result = service::assign_template_to_account( + &state.db, &ctx.account_id, &req.template_id, + ).await?; + + log_operation(&state.db, &ctx.account_id, "account.assign_template", "agent_template", &req.template_id, + None, ctx.client_ip.as_deref()).await?; + + Ok(Json(result)) +} + +/// GET /api/v1/accounts/me/assigned-template — 获取已分配的行业模板 +pub async fn get_assigned_template( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult>> { + check_permission(&ctx, "model:read")?; + Ok(Json(service::get_assigned_template(&state.db, &ctx.account_id).await?)) +} + +/// DELETE /api/v1/accounts/me/assigned-template — 取消行业模板分配 +pub async fn unassign_template( + State(state): State, + Extension(ctx): Extension, +) -> SaasResult> { + check_permission(&ctx, "model:read")?; + service::unassign_template(&state.db, &ctx.account_id).await?; + Ok(Json(serde_json::json!({"ok": true}))) +} + +/// POST /api/v1/agent-templates/:id/create-agent — 从模板创建 Agent 配置 +pub async fn create_agent_from_template( + State(state): State, + Extension(ctx): Extension, + Path(id): Path, +) -> SaasResult> { + check_permission(&ctx, "model:read")?; + Ok(Json(service::create_agent_from_template(&state.db, &id).await?)) +} diff --git a/crates/zclaw-saas/src/agent_template/mod.rs b/crates/zclaw-saas/src/agent_template/mod.rs index f01cb1c..5af3dec 100644 --- a/crates/zclaw-saas/src/agent_template/mod.rs +++ b/crates/zclaw-saas/src/agent_template/mod.rs @@ -10,10 +10,16 @@ use crate::state::AppState; /// Agent 模板管理路由 (需要认证) pub fn routes() -> axum::Router { axum::Router::new() + // Template CRUD .route("/api/v1/agent-templates", get(handlers::list_templates).post(handlers::create_template)) .route("/api/v1/agent-templates/available", get(handlers::list_available)) .route("/api/v1/agent-templates/:id", get(handlers::get_template)) .route("/api/v1/agent-templates/:id", post(handlers::update_template)) .route("/api/v1/agent-templates/:id", delete(handlers::archive_template)) .route("/api/v1/agent-templates/:id/full", get(handlers::get_full_template)) + .route("/api/v1/agent-templates/:id/create-agent", post(handlers::create_agent_from_template)) + // Template Assignment (per-account) + .route("/api/v1/accounts/me/assign-template", post(handlers::assign_template)) + .route("/api/v1/accounts/me/assigned-template", get(handlers::get_assigned_template)) + .route("/api/v1/accounts/me/assigned-template", delete(handlers::unassign_template)) } diff --git a/crates/zclaw-saas/src/agent_template/service.rs b/crates/zclaw-saas/src/agent_template/service.rs index d444255..b99d5fc 100644 --- a/crates/zclaw-saas/src/agent_template/service.rs +++ b/crates/zclaw-saas/src/agent_template/service.rs @@ -266,3 +266,118 @@ pub async fn archive_template(db: &PgPool, id: &str) -> SaasResult SaasResult { + // Verify template exists and is active + let template = get_template(db, template_id).await?; + if template.status != "active" { + return Err(SaasError::InvalidInput("模板不可用(已归档)".into())); + } + + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE accounts SET assigned_template_id = $1, updated_at = $2 WHERE id = $3" + ) + .bind(template_id) + .bind(&now) + .bind(account_id) + .execute(db) + .await?; + + Ok(template) +} + +/// Get the template assigned to the current account (if any). +pub async fn get_assigned_template( + db: &PgPool, + account_id: &str, +) -> SaasResult> { + let row = sqlx::query_scalar::<_, Option>( + "SELECT assigned_template_id FROM accounts WHERE id = $1" + ) + .bind(account_id) + .fetch_optional(db) + .await?; + + let template_id = match row.flatten() { + Some(id) => id, + None => return Ok(None), + }; + + // Template may have been deleted (ON DELETE SET NULL), but check anyway + match get_template(db, &template_id).await { + Ok(t) => Ok(Some(t)), + Err(SaasError::NotFound(_)) => { + // Template deleted — clear stale reference + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2" + ) + .bind(&now) + .bind(account_id) + .execute(db) + .await?; + Ok(None) + } + Err(e) => Err(e), + } +} + +/// Unassign template from the current account. +pub async fn unassign_template( + db: &PgPool, + account_id: &str, +) -> SaasResult<()> { + let now = chrono::Utc::now().to_rfc3339(); + sqlx::query( + "UPDATE accounts SET assigned_template_id = NULL, updated_at = $1 WHERE id = $2" + ) + .bind(&now) + .bind(account_id) + .execute(db) + .await?; + Ok(()) +} + +/// Create an agent configuration from a template. +/// Merges capabilities into tools, applies default model fallback. +pub async fn create_agent_from_template( + db: &PgPool, + template_id: &str, +) -> SaasResult { + let t = get_template(db, template_id).await?; + if t.status != "active" { + return Err(SaasError::InvalidInput("模板不可用(已归档)".into())); + } + + // Merge capabilities into tools (deduplicated) + let mut merged_tools = t.tools.clone(); + for cap in &t.capabilities { + if !merged_tools.contains(cap) { + merged_tools.push(cap.clone()); + } + } + + Ok(AgentConfigFromTemplate { + name: t.name, + model: t.model.unwrap_or_else(|| "glm-4-flash".to_string()), + system_prompt: t.system_prompt, + tools: merged_tools, + soul_content: t.soul_content, + welcome_message: t.welcome_message, + quick_commands: t.quick_commands, + temperature: t.temperature, + max_tokens: t.max_tokens, + personality: t.personality, + communication_style: t.communication_style, + emoji: t.emoji, + }) +} diff --git a/crates/zclaw-saas/src/agent_template/types.rs b/crates/zclaw-saas/src/agent_template/types.rs index 1f78c1f..cef344b 100644 --- a/crates/zclaw-saas/src/agent_template/types.rs +++ b/crates/zclaw-saas/src/agent_template/types.rs @@ -103,3 +103,32 @@ pub struct AvailableAgentTemplateInfo { pub description: Option, pub source_id: Option, } + +// --- Template Assignment --- + +/// POST /api/v1/accounts/me/assign-template +#[derive(Debug, Deserialize)] +pub struct AssignTemplateRequest { + pub template_id: String, +} + +/// GET /api/v1/accounts/me/assigned-template response (nullable) +/// Reuses AgentTemplateInfo when a template is assigned. + +/// Agent configuration derived from a template, returned by create-agent endpoint. +/// capabilities are merged into tools (no separate field). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct AgentConfigFromTemplate { + pub name: String, + pub model: String, + pub system_prompt: Option, + pub tools: Vec, + pub soul_content: Option, + pub welcome_message: Option, + pub quick_commands: Vec, + pub temperature: Option, + pub max_tokens: Option, + pub personality: Option, + pub communication_style: Option, + pub emoji: Option, +} diff --git a/desktop/src/lib/saas-client.ts b/desktop/src/lib/saas-client.ts index 1517127..0fd701c 100644 --- a/desktop/src/lib/saas-client.ts +++ b/desktop/src/lib/saas-client.ts @@ -66,6 +66,7 @@ export type { CreateTemplateRequest, AgentTemplateAvailable, AgentTemplateFull, + AgentConfigFromTemplate, } from './saas-types'; export { SaaSApiError } from './saas-errors'; @@ -101,6 +102,7 @@ import type { PaginatedResponse, AgentTemplateAvailable, AgentTemplateFull, + AgentConfigFromTemplate, } from './saas-types'; import { SaaSApiError } from './saas-errors'; import { clearSaaSSession } from './saas-session'; @@ -403,6 +405,41 @@ export class SaaSClient { async fetchTemplateFull(id: string): Promise { return this.request('GET', `/api/v1/agent-templates/${id}/full`); } + + // --- Template Assignment --- + + /** + * Assign a template to the current account. + * Records the user's industry choice for onboarding flow control. + */ + async assignTemplate(templateId: string): Promise { + return this.request('POST', '/api/v1/accounts/me/assign-template', { + template_id: templateId, + }); + } + + /** + * Get the template currently assigned to the account. + * Returns null if no template is assigned. + */ + async getAssignedTemplate(): Promise { + return this.request('GET', '/api/v1/accounts/me/assigned-template'); + } + + /** + * Unassign the current template from the account. + */ + async unassignTemplate(): Promise { + await this.request('DELETE', '/api/v1/accounts/me/assigned-template'); + } + + /** + * Create an agent configuration from a template. + * Merges capabilities into tools, applies default model fallback. + */ + async createAgentFromTemplate(templateId: string): Promise { + return this.request('POST', `/api/v1/agent-templates/${templateId}/create-agent`); + } } // === Install mixin methods === diff --git a/desktop/src/lib/saas-relay-client.ts b/desktop/src/lib/saas-relay-client.ts new file mode 100644 index 0000000..0256d27 --- /dev/null +++ b/desktop/src/lib/saas-relay-client.ts @@ -0,0 +1,276 @@ +/** + * SaaS Relay Gateway Client + * + * A lightweight GatewayClient-compatible adapter for browser-only mode. + * Routes agent listing through SaaS agent-templates, Converts + * chatStream() to OpenAI SSE streaming via SaaS relay. + * + * Used in connectionStore when running in a browser (non-Tauri) with + * SaaS relay connection mode. + */ + +import type { GatewayClient } from './gateway-client'; +import { saasClient } from './saas-client'; +import type { AgentTemplateAvailable } from './saas-types'; +import { createLogger } from './logger'; + +const log = createLogger('SaaSRelayGateway'); + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface CloneInfo { + id: string; + name: string; + role?: string; + nickname?: string; + emoji?: string; + personality?: string; + scenarios?: string[]; + model?: string; + status?: string; + templateId?: string; +} + +// --------------------------------------------------------------------------- +// Implementation +// --------------------------------------------------------------------------- + +/** + * Create a GatewayClient-compatible object that routes through SaaS APIs. + * Only the methods needed by the stores are implemented; others return + * sensible defaults. + */ +export function createSaaSRelayGatewayClient( + _saasUrl: string, + relayModel: string, +): GatewayClient { + // saasUrl preserved for future direct API routing (currently routed through saasClient singleton) + void _saasUrl; + + // Local in-memory agent registry + const agents = new Map(); + let defaultAgentId: string | null = null; + + // ----------------------------------------------------------------------- + // Helper: list agents as clones + // ----------------------------------------------------------------------- + async function listClones(): Promise<{ clones: CloneInfo[] }> { + try { + const templates: AgentTemplateAvailable[] = await saasClient.fetchAvailableTemplates(); + + const clones: CloneInfo[] = templates.map((t) => { + const id = t.id || `agent-${t.name}`; + const clone: CloneInfo = { + id, + name: t.name, + role: t.description || t.category, + emoji: t.emoji, + personality: t.category, + scenarios: [], + model: relayModel, + status: 'active', + templateId: t.id, + }; + agents.set(id, clone); + return clone; + }); + + // Set first as default + if (clones.length > 0 && !defaultAgentId) { + defaultAgentId = clones[0].id; + } + + return { clones }; + } catch (err) { + log.warn('Failed to list templates', err); + return { clones: [] }; + } + } + + // ----------------------------------------------------------------------- + // Helper: OpenAI SSE streaming via SaaS relay + // ----------------------------------------------------------------------- + async function chatStream( + message: string, + callbacks: { + onDelta: (delta: string) => void; + onThinkingDelta?: (delta: string) => void; + onTool?: (tool: string, input: string, output: string) => void; + onHand?: (name: string, status: string, result?: unknown) => void; + onComplete: (inputTokens?: number, outputTokens?: number) => void; + onError: (error: string) => void; + }, + opts?: { + sessionKey?: string; + agentId?: string; + thinking_enabled?: boolean; + reasoning_effort?: string; + plan_mode?: boolean; + }, + ): Promise<{ runId: string }> { + const runId = `run_${Date.now()}`; + + try { + const body: Record = { + model: relayModel, + messages: [{ role: 'user', content: message }], + stream: true, + }; + + if (opts?.thinking_enabled) body['thinking_enabled'] = true; + if (opts?.reasoning_effort) body['reasoning_effort'] = opts.reasoning_effort; + + const response = await saasClient.chatCompletion(body); + + if (!response.ok) { + const errText = await response.text().catch(() => ''); + callbacks.onError(`Relay error: ${response.status} ${errText}`); + callbacks.onComplete(); + return { runId }; + } + + // Parse SSE stream + const reader = response.body?.getReader(); + if (!reader) { + callbacks.onError('No response body'); + callbacks.onComplete(); + return { runId }; + } + + const decoder = new TextDecoder(); + let buffer = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split('\n'); + buffer = lines.pop() || ''; // keep incomplete last line + + for (const line of lines) { + if (!line.startsWith('data: ')) continue; + const data = line.slice(6).trim(); + if (data === '[DONE]') continue; + + try { + const parsed = JSON.parse(data); + const choices = parsed.choices?.[0]; + if (!choices) continue; + + const delta = choices.delta; + + // Handle thinking/reasoning content + if (delta?.reasoning_content) { + callbacks.onThinkingDelta?.(delta.reasoning_content); + } + + // Handle regular content + if (delta?.content) { + callbacks.onDelta(delta.content); + } + + // Check for completion + if (choices.finish_reason) { + const usage = parsed.usage; + callbacks.onComplete( + usage?.prompt_tokens, + usage?.completion_tokens, + ); + return { runId }; + } + } catch { + // Skip malformed SSE lines + } + } + } + + // Stream ended without explicit finish_reason + callbacks.onComplete(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + callbacks.onError(msg); + callbacks.onComplete(); + } + + return { runId }; + } + + // ----------------------------------------------------------------------- + // Build the client object with GatewayClient-compatible shape + // ----------------------------------------------------------------------- + return { + // --- Connection --- + connect: async () => { log.debug('SaaS relay client connect'); }, + disconnect: async () => {}, + getState: () => 'connected' as const, + onStateChange: undefined, + onLog: undefined, + + // --- Agents (Clones) --- + listClones, + createClone: async (opts: Record) => { + const id = `agent-${Date.now()}`; + const clone: CloneInfo = { + id, + name: (opts.name as string) || 'New Agent', + role: opts.role as string, + nickname: opts.nickname as string, + emoji: opts.emoji as string, + model: relayModel, + status: 'active', + }; + agents.set(id, clone); + if (!defaultAgentId) defaultAgentId = id; + return { clone }; + }, + updateClone: async (id: string, updates: Record) => { + const existing = agents.get(id); + if (existing) agents.set(id, { ...existing, ...updates }); + return { clone: agents.get(id) }; + }, + deleteClone: async (id: string) => { + agents.delete(id); + if (defaultAgentId === id) defaultAgentId = null; + }, + getDefaultAgentId: () => defaultAgentId, + setDefaultAgentId: (id: string) => { defaultAgentId = id; }, + + // --- Chat --- + chatStream, + + // --- Hands --- + listHands: async () => ({ hands: [] }), + getHand: async () => null, + triggerHand: async () => ({ runId: `hand_${Date.now()}`, status: 'completed' }), + + // --- Skills --- + listSkills: async () => ({ skills: [] }), + getSkill: async () => null, + createSkill: async () => null, + updateSkill: async () => null, + deleteSkill: async () => {}, + + // --- Config --- + getQuickConfig: async () => ({}), + saveQuickConfig: async () => {}, + getWorkspaceInfo: async () => null, + + // --- Health --- + health: async () => ({ status: 'ok', mode: 'saas-relay' }), + status: async () => ({ version: 'saas-relay', mode: 'browser' }), + + // --- Usage --- + getUsageStats: async () => null, + getSessionStats: async () => null, + + // --- REST helpers (not used in browser mode) --- + restGet: async () => { throw new Error('REST not available in browser mode'); }, + restPost: async () => { throw new Error('REST not available in browser mode'); }, + restPut: async () => { throw new Error('REST not available in browser mode'); }, + restDelete: async () => { throw new Error('REST not available in browser mode'); }, + restPatch: async () => { throw new Error('REST not available in browser mode'); }, + } as unknown as GatewayClient; +} diff --git a/desktop/src/lib/saas-types.ts b/desktop/src/lib/saas-types.ts index ce4ffb4..78980c6 100644 --- a/desktop/src/lib/saas-types.ts +++ b/desktop/src/lib/saas-types.ts @@ -460,3 +460,27 @@ export interface CreateTemplateRequest { description?: string; permissions: string[]; } + +// === Template Assignment Types === + +/** Request to assign a template to the current account */ +export interface AssignTemplateRequest { + template_id: string; +} + +/** Agent configuration derived from a template. + * capabilities are merged into tools (no separate field). */ +export interface AgentConfigFromTemplate { + name: string; + model: string; + system_prompt?: string; + tools: string[]; + soul_content?: string; + welcome_message?: string; + quick_commands: Array<{ label: string; command: string }>; + temperature?: number; + max_tokens?: number; + personality?: string; + communication_style?: string; + emoji?: string; +} diff --git a/desktop/src/store/connectionStore.ts b/desktop/src/store/connectionStore.ts index ca05aee..718a8f1 100644 --- a/desktop/src/store/connectionStore.ts +++ b/desktop/src/store/connectionStore.ts @@ -472,6 +472,19 @@ export const useConnectionStore = create((set, get) => { // agent management local via KernelClient. // baseUrl = saasUrl + /api/v1/relay → kernel appends /chat/completions // apiKey = SaaS JWT token → sent as Authorization: Bearer + + // Fetch available models from SaaS relay (shared by both branches) + let relayModels: Array<{ id: string }>; + try { + relayModels = await saasClient.listModels(); + } catch { + throw new Error('无法获取可用模型列表,请确认管理后台已配置 Provider 和模型'); + } + + if (relayModels.length === 0) { + throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型'); + } + if (isTauriRuntime()) { if (!session.token) { throw new Error('SaaS 中转模式需要认证令牌,请重新登录 SaaS 平台'); @@ -479,20 +492,8 @@ export const useConnectionStore = create((set, get) => { const kernelClient = getKernelClient(); - // Fetch available models from SaaS relay - let models: Array<{ id: string }>; - try { - models = await saasClient.listModels(); - } catch { - throw new Error('无法获取可用模型列表,请确认管理后台已配置 Provider 和模型'); - } - - if (models.length === 0) { - throw new Error('SaaS 平台没有可用模型,请先在管理后台配置 Provider 和模型'); - } - // Use first available model (TODO: let user choose preferred model) - const relayModel = models[0]; + const relayModel = relayModels[0]; kernelClient.setConfig({ provider: 'custom', @@ -525,9 +526,21 @@ export const useConnectionStore = create((set, get) => { baseUrl: `${session.saasUrl}/api/v1/relay`, }); } else { - // Non-Tauri (browser) — simple connected state without kernel - set({ connectionState: 'connected', gatewayVersion: 'saas-relay' }); - log.debug('Connected to SaaS relay (browser mode)'); + // Non-Tauri (browser) — use SaaS relay gateway client for agent listing + chat + const { createSaaSRelayGatewayClient } = await import('../lib/saas-relay-client'); + const relayModelId = relayModels[0].id; + const relayClient = createSaaSRelayGatewayClient(session.saasUrl, relayModelId); + + set({ + connectionState: 'connected', + gatewayVersion: 'saas-relay', + client: relayClient as unknown as GatewayClient, + }); + + const { initializeStores } = await import('./index'); + initializeStores(); + + log.debug('Connected to SaaS relay (browser mode)', { relayModel: relayModelId }); } return; } diff --git a/desktop/src/store/saasStore.ts b/desktop/src/store/saasStore.ts index 0c49766..05af422 100644 --- a/desktop/src/store/saasStore.ts +++ b/desktop/src/store/saasStore.ts @@ -27,6 +27,7 @@ import { type TotpSetupResponse, type SyncConfigRequest, type AgentTemplateAvailable, + type AgentTemplateFull, } from '../lib/saas-client'; import { createLogger } from '../lib/logger'; import { @@ -73,6 +74,8 @@ export interface SaaSStateSlice { saasReachable: boolean; /** Agent templates available for onboarding */ availableTemplates: AgentTemplateAvailable[]; + /** Currently assigned template (null if not yet assigned or assignment removed) */ + assignedTemplate: AgentTemplateFull | null; /** Consecutive heartbeat/health-check failures */ _consecutiveFailures: number; _heartbeatTimer?: ReturnType; @@ -90,6 +93,12 @@ export interface SaaSActionsSlice { pushConfigToSaaS: () => Promise; registerCurrentDevice: () => Promise; fetchAvailableTemplates: () => Promise; + /** Assign a template to the current account */ + assignTemplate: (templateId: string) => Promise; + /** Fetch the currently assigned template (auto-called after login) */ + fetchAssignedTemplate: () => Promise; + /** Unassign the current template */ + unassignTemplate: () => Promise; clearError: () => void; restoreSession: () => void; setupTotp: () => Promise; @@ -145,6 +154,7 @@ export const useSaaSStore = create((set, get) => { totpSetupData: null, saasReachable: true, availableTemplates: [], + assignedTemplate: null, _consecutiveFailures: 0, // === Actions === @@ -199,6 +209,11 @@ export const useSaaSStore = create((set, get) => { log.warn('Failed to fetch templates after login:', err); }); + // Fetch assigned template in background (non-blocking) + get().fetchAssignedTemplate().catch((err: unknown) => { + log.warn('Failed to fetch assigned template after login:', err); + }); + // Fetch available models in background (non-blocking) get().fetchAvailableModels().catch((err: unknown) => { log.warn('Failed to fetch models after login:', err); @@ -628,6 +643,35 @@ export const useSaaSStore = create((set, get) => { } }, + assignTemplate: async (templateId: string) => { + try { + const template = await saasClient.assignTemplate(templateId); + set({ assignedTemplate: template }); + } catch (err) { + log.warn('Failed to assign template:', err); + // Don't throw — let wizard continue with fallback flow + } + }, + + fetchAssignedTemplate: async () => { + try { + const template = await saasClient.getAssignedTemplate(); + set({ assignedTemplate: template }); + } catch { + // Not critical — null is fine + set({ assignedTemplate: null }); + } + }, + + unassignTemplate: async () => { + try { + await saasClient.unassignTemplate(); + set({ assignedTemplate: null }); + } catch (err) { + log.warn('Failed to unassign template:', err); + } + }, + clearError: () => { set({ error: null }); },