//! Agent 配置模板业务逻辑 use sqlx::{PgPool, Row}; use crate::error::{SaasError, SaasResult}; use super::types::*; /// Shared SELECT column list. const SELECT_COLUMNS: &str = "\ id, name, description, category, source, model, system_prompt, \ tools, capabilities, temperature, max_tokens, visibility, status, \ current_version, created_at, updated_at, \ soul_content, scenarios, welcome_message, quick_commands, \ personality, communication_style, emoji, version, source_id"; fn row_to_template(row: &sqlx::postgres::PgRow) -> AgentTemplateInfo { AgentTemplateInfo { id: row.get("id"), name: row.get("name"), description: row.get("description"), category: row.get("category"), source: row.get("source"), model: row.get("model"), system_prompt: row.get("system_prompt"), tools: serde_json::from_str(&row.get::("tools")).unwrap_or_default(), capabilities: serde_json::from_str(&row.get::("capabilities")).unwrap_or_default(), temperature: row.get("temperature"), max_tokens: row.get("max_tokens"), visibility: row.get("visibility"), status: row.get("status"), current_version: row.get("current_version"), created_at: row.get("created_at"), updated_at: row.get("updated_at"), // Extended fields soul_content: row.get("soul_content"), scenarios: serde_json::from_str(&row.get::("scenarios")).unwrap_or_default(), welcome_message: row.get("welcome_message"), quick_commands: serde_json::from_str(&row.get::("quick_commands")).unwrap_or_default(), personality: row.get("personality"), communication_style: row.get("communication_style"), emoji: row.get("emoji"), version: row.get("version"), source_id: row.get("source_id"), } } /// 创建 Agent 模板 #[allow(clippy::too_many_arguments)] pub async fn create_template( db: &PgPool, name: &str, description: Option<&str>, category: &str, source: &str, model: Option<&str>, system_prompt: Option<&str>, tools: &[String], capabilities: &[String], temperature: Option, max_tokens: Option, visibility: &str, // Extended fields soul_content: Option<&str>, scenarios: Option<&[String]>, welcome_message: Option<&str>, quick_commands: Option<&[serde_json::Value]>, personality: Option<&str>, communication_style: Option<&str>, emoji: Option<&str>, source_id: Option<&str>, ) -> SaasResult { let id = uuid::Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); let tools_json = serde_json::to_string(tools).unwrap_or_else(|_| "[]".to_string()); let caps_json = serde_json::to_string(capabilities).unwrap_or_else(|_| "[]".to_string()); let scenarios_json = serde_json::to_string(&scenarios.unwrap_or(&[])).unwrap_or_else(|_| "[]".to_string()); let quick_commands_json = serde_json::to_string(&quick_commands.unwrap_or(&[])).unwrap_or_else(|_| "[]".to_string()); sqlx::query( &format!("INSERT INTO agent_templates ({}) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'active',1,$13,$13,$14,$15,$16,$17,$18,$19,$20,1,$21)", SELECT_COLUMNS) ) .bind(&id) // $1 id .bind(name) // $2 name .bind(description) // $3 description .bind(category) // $4 category .bind(source) // $5 source .bind(model) // $6 model .bind(system_prompt) // $7 system_prompt .bind(&tools_json) // $8 tools .bind(&caps_json) // $9 capabilities .bind(temperature) // $10 temperature .bind(max_tokens) // $11 max_tokens .bind(visibility) // $12 visibility .bind(&now) // $13 created_at / updated_at .bind(soul_content) // $14 soul_content .bind(&scenarios_json) // $15 scenarios .bind(welcome_message) // $16 welcome_message .bind(&quick_commands_json) // $17 quick_commands .bind(personality) // $18 personality .bind(communication_style) // $19 communication_style .bind(emoji) // $20 emoji .bind(source_id) // $21 source_id .execute(db).await.map_err(|e| { if e.to_string().contains("unique") { SaasError::AlreadyExists(format!("Agent 模板 '{}' 已存在", name)) } else { SaasError::Database(e) } })?; get_template(db, &id).await } /// 获取单个模板 pub async fn get_template(db: &PgPool, id: &str) -> SaasResult { let row = sqlx::query( &format!("SELECT {} FROM agent_templates WHERE id = $1", SELECT_COLUMNS) ).bind(id).fetch_optional(db).await?; row.as_ref() .map(row_to_template) .ok_or_else(|| SaasError::NotFound(format!("Agent 模板 {} 不存在", id))) } /// 列出模板(分页 + 过滤) /// Static SQL + conditional filter pattern: ($N IS NULL OR col = $N). /// When the parameter is NULL the whole OR evaluates to TRUE (no filter). pub async fn list_templates( db: &PgPool, query: &AgentTemplateListQuery, ) -> SaasResult> { let page = query.page.unwrap_or(1).max(1); let page_size = query.page_size.unwrap_or(20).min(100); let offset = ((page - 1) * page_size) as i64; let where_clause = "WHERE ($1 IS NULL OR category = $1) AND ($2 IS NULL OR source = $2) AND ($3 IS NULL OR visibility = $3) AND ($4 IS NULL OR status = $4)"; let count_sql = format!("SELECT COUNT(*) FROM agent_templates {}", where_clause); let data_sql = format!( "SELECT {} FROM agent_templates {} ORDER BY created_at DESC LIMIT $5 OFFSET $6", SELECT_COLUMNS, where_clause ); let total: i64 = sqlx::query_scalar(&count_sql) .bind(&query.category) .bind(&query.source) .bind(&query.visibility) .bind(&query.status) .fetch_one(db).await?; let rows = sqlx::query(&data_sql) .bind(&query.category) .bind(&query.source) .bind(&query.visibility) .bind(&query.status) .bind(page_size as i64) .bind(offset) .fetch_all(db).await?; let items = rows.iter().map(|r| row_to_template(r)).collect(); Ok(crate::common::PaginatedResponse { items, total, page, page_size }) } /// 列出可用模板 (status='active' AND visibility='public', 轻量级) pub async fn list_available(db: &PgPool) -> SaasResult> { let rows = sqlx::query( "SELECT id, name, category, emoji, description, source_id \ FROM agent_templates \ WHERE status = 'active' AND visibility = 'public' \ ORDER BY category, name" ).fetch_all(db).await?; Ok(rows.iter().map(|r| { AvailableAgentTemplateInfo { id: r.get("id"), name: r.get("name"), category: r.get("category"), emoji: r.get("emoji"), description: r.get("description"), source_id: r.get("source_id"), } }).collect()) } /// 更新模板 /// COALESCE pattern: all updatable fields in a single static SQL. /// NULL parameters leave the column unchanged. /// source_id and version are immutable. #[allow(clippy::too_many_arguments)] pub async fn update_template( db: &PgPool, id: &str, description: Option<&str>, model: Option<&str>, system_prompt: Option<&str>, tools: Option<&[String]>, capabilities: Option<&[String]>, temperature: Option, max_tokens: Option, visibility: Option<&str>, status: Option<&str>, // Extended fields soul_content: Option<&str>, scenarios: Option<&[String]>, welcome_message: Option<&str>, quick_commands: Option<&[serde_json::Value]>, personality: Option<&str>, communication_style: Option<&str>, emoji: Option<&str>, ) -> SaasResult { // Confirm existence get_template(db, id).await?; let now = chrono::Utc::now().to_rfc3339(); // Serialize JSON fields upfront so we can bind Option<&str> consistently let tools_json = tools.map(|t| serde_json::to_string(t).unwrap_or_else(|_| "[]".to_string())); let caps_json = capabilities.map(|c| serde_json::to_string(c).unwrap_or_else(|_| "[]".to_string())); let scenarios_json = scenarios.map(|s| serde_json::to_string(s).unwrap_or_else(|_| "[]".to_string())); let quick_commands_json = quick_commands.map(|qc| serde_json::to_string(qc).unwrap_or_else(|_| "[]".to_string())); sqlx::query( "UPDATE agent_templates SET description = COALESCE($1, description), model = COALESCE($2, model), system_prompt = COALESCE($3, system_prompt), tools = COALESCE($4, tools), capabilities = COALESCE($5, capabilities), temperature = COALESCE($6, temperature), max_tokens = COALESCE($7, max_tokens), visibility = COALESCE($8, visibility), status = COALESCE($9, status), updated_at = $10, soul_content = COALESCE($11, soul_content), scenarios = COALESCE($12, scenarios), welcome_message = COALESCE($13, welcome_message), quick_commands = COALESCE($14, quick_commands), personality = COALESCE($15, personality), communication_style = COALESCE($16, communication_style), emoji = COALESCE($17, emoji) WHERE id = $18" ) .bind(description) // $1 .bind(model) // $2 .bind(system_prompt) // $3 .bind(tools_json.as_deref()) // $4 .bind(caps_json.as_deref()) // $5 .bind(temperature) // $6 .bind(max_tokens) // $7 .bind(visibility) // $8 .bind(status) // $9 .bind(&now) // $10 .bind(soul_content) // $11 .bind(scenarios_json.as_deref()) // $12 .bind(welcome_message) // $13 .bind(quick_commands_json.as_deref()) // $14 .bind(personality) // $15 .bind(communication_style) // $16 .bind(emoji) // $17 .bind(id) // $18 .execute(db).await?; get_template(db, id).await } /// 归档模板 pub async fn archive_template(db: &PgPool, id: &str) -> SaasResult { update_template(db, id, None, None, None, None, None, None, None, None, Some("archived"), None, None, None, None, None, None, None).await } // --- Template Assignment --- /// Assign a template to the current account. /// Updates accounts.assigned_template_id and returns the template info. pub async fn assign_template_to_account( db: &PgPool, account_id: &str, template_id: &str, ) -> 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. Model is passed through as-is (None if not set); /// the frontend resolves it from SaaS admin's available models list. 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, // No hardcoded fallback — frontend resolves from available models model: t.model, 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, }) }