feat(saas): extend agent templates with soul_content, add /available endpoint, key pool cleanup, and industry seed templates
- Add 9 extended fields to AgentTemplateInfo: soul_content, scenarios, welcome_message, quick_commands, personality, communication_style, emoji, version, source_id - Refactor service.rs to use sqlx::Row (manual column extraction) to avoid the 16-element tuple FromRow limit - Add /api/v1/agent-templates/available (lightweight public listing) and /api/v1/agent-templates/:id/full endpoints - Add 24h key_usage_window cleanup task in scheduler - Update seed data with extended fields for all 5 existing templates plus new Medical Assistant template (healthcare category)
This commit is contained in:
@@ -1,26 +1,50 @@
|
||||
//! Agent 配置模板业务逻辑
|
||||
|
||||
use sqlx::PgPool;
|
||||
use sqlx::{PgPool, Row};
|
||||
use crate::error::{SaasError, SaasResult};
|
||||
use super::types::*;
|
||||
|
||||
fn row_to_template(
|
||||
row: (String, String, Option<String>, String, String, Option<String>, Option<String>,
|
||||
String, String, Option<f64>, Option<i32>, String, String, i32, String, String),
|
||||
) -> AgentTemplateInfo {
|
||||
/// 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.0, name: row.1, description: row.2, category: row.3, source: row.4,
|
||||
model: row.5, system_prompt: row.6, tools: serde_json::from_str(&row.7).unwrap_or_default(),
|
||||
capabilities: serde_json::from_str(&row.8).unwrap_or_default(),
|
||||
temperature: row.9, max_tokens: row.10, visibility: row.11, status: row.12,
|
||||
current_version: row.13, created_at: row.14, updated_at: row.15,
|
||||
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::<String, _>("tools")).unwrap_or_default(),
|
||||
capabilities: serde_json::from_str(&row.get::<String, _>("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::<String, _>("scenarios")).unwrap_or_default(),
|
||||
welcome_message: row.get("welcome_message"),
|
||||
quick_commands: serde_json::from_str(&row.get::<String, _>("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"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Row type for agent_template queries (avoids multi-line turbofish parsing issues)
|
||||
type AgentTemplateRow = (String, String, Option<String>, String, String, Option<String>, Option<String>, String, String, Option<f64>, Option<i32>, String, String, i32, String, String);
|
||||
|
||||
/// 创建 Agent 模板
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_template(
|
||||
db: &PgPool,
|
||||
name: &str,
|
||||
@@ -34,20 +58,47 @@ pub async fn create_template(
|
||||
temperature: Option<f64>,
|
||||
max_tokens: Option<i32>,
|
||||
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<AgentTemplateInfo> {
|
||||
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(
|
||||
"INSERT INTO agent_templates (id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status, current_version, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, 'active', 1, $13, $13)"
|
||||
&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).bind(name).bind(description).bind(category).bind(source)
|
||||
.bind(model).bind(system_prompt).bind(&tools_json).bind(&caps_json)
|
||||
.bind(temperature).bind(max_tokens).bind(visibility).bind(&now)
|
||||
.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))
|
||||
@@ -61,14 +112,12 @@ pub async fn create_template(
|
||||
|
||||
/// 获取单个模板
|
||||
pub async fn get_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo> {
|
||||
let row: Option<AgentTemplateRow> = sqlx::query_as(
|
||||
"SELECT id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status,
|
||||
current_version, created_at, updated_at
|
||||
FROM agent_templates WHERE id = $1"
|
||||
let row = sqlx::query(
|
||||
&format!("SELECT {} FROM agent_templates WHERE id = $1", SELECT_COLUMNS)
|
||||
).bind(id).fetch_optional(db).await?;
|
||||
|
||||
row.map(row_to_template)
|
||||
row.as_ref()
|
||||
.map(row_to_template)
|
||||
.ok_or_else(|| SaasError::NotFound(format!("Agent 模板 {} 不存在", id)))
|
||||
}
|
||||
|
||||
@@ -83,20 +132,21 @@ pub async fn list_templates(
|
||||
let page_size = query.page_size.unwrap_or(20).min(100);
|
||||
let offset = ((page - 1) * page_size) as i64;
|
||||
|
||||
let count_sql = "SELECT COUNT(*) FROM agent_templates 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 data_sql = "SELECT id, name, description, category, source, model, system_prompt,
|
||||
tools, capabilities, temperature, max_tokens, visibility, status,
|
||||
current_version, created_at, updated_at
|
||||
FROM agent_templates 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) ORDER BY created_at DESC LIMIT $5 OFFSET $6";
|
||||
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)
|
||||
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: Vec<AgentTemplateRow> = sqlx::query_as(data_sql)
|
||||
let rows = sqlx::query(&data_sql)
|
||||
.bind(&query.category)
|
||||
.bind(&query.source)
|
||||
.bind(&query.visibility)
|
||||
@@ -104,14 +154,37 @@ pub async fn list_templates(
|
||||
.bind(page_size as i64)
|
||||
.bind(offset)
|
||||
.fetch_all(db).await?;
|
||||
let items = rows.into_iter().map(row_to_template).collect();
|
||||
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<Vec<AvailableAgentTemplateInfo>> {
|
||||
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,
|
||||
@@ -124,6 +197,14 @@ pub async fn update_template(
|
||||
max_tokens: Option<i32>,
|
||||
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<AgentTemplateInfo> {
|
||||
// Confirm existence
|
||||
get_template(db, id).await?;
|
||||
@@ -133,6 +214,8 @@ pub async fn update_template(
|
||||
// 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
|
||||
@@ -145,20 +228,34 @@ pub async fn update_template(
|
||||
max_tokens = COALESCE($7, max_tokens),
|
||||
visibility = COALESCE($8, visibility),
|
||||
status = COALESCE($9, status),
|
||||
updated_at = $10
|
||||
WHERE id = $11"
|
||||
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)
|
||||
.bind(model)
|
||||
.bind(system_prompt)
|
||||
.bind(tools_json.as_deref())
|
||||
.bind(caps_json.as_deref())
|
||||
.bind(temperature)
|
||||
.bind(max_tokens)
|
||||
.bind(visibility)
|
||||
.bind(status)
|
||||
.bind(&now)
|
||||
.bind(id)
|
||||
.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
|
||||
@@ -166,5 +263,6 @@ pub async fn update_template(
|
||||
|
||||
/// 归档模板
|
||||
pub async fn archive_template(db: &PgPool, id: &str) -> SaasResult<AgentTemplateInfo> {
|
||||
update_template(db, id, None, None, None, None, None, None, None, None, Some("archived")).await
|
||||
update_template(db, id, None, None, None, None, None, None, None, None, Some("archived"),
|
||||
None, None, None, None, None, None, None).await
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user