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:
iven
2026-03-31 02:58:09 +08:00
parent eb956d0dce
commit 3e57fadfc9
7 changed files with 340 additions and 59 deletions

View File

@@ -34,6 +34,8 @@ pub async fn create_template(
let visibility = req.visibility.as_deref().unwrap_or("public");
let tools = req.tools.as_deref().unwrap_or(&[]);
let capabilities = req.capabilities.as_deref().unwrap_or(&[]);
let scenarios = req.scenarios.as_deref().unwrap_or(&[]);
let quick_commands = req.quick_commands.as_deref().unwrap_or(&[]);
let result = service::create_template(
&state.db, &req.name, req.description.as_deref(),
@@ -41,6 +43,14 @@ pub async fn create_template(
req.system_prompt.as_deref(),
tools, capabilities,
req.temperature, req.max_tokens, visibility,
req.soul_content.as_deref(),
Some(scenarios),
req.welcome_message.as_deref(),
Some(quick_commands),
req.personality.as_deref(),
req.communication_style.as_deref(),
req.emoji.as_deref(),
req.source_id.as_deref(),
).await?;
log_operation(&state.db, &ctx.account_id, "agent_template.create", "agent_template", &result.id,
@@ -59,6 +69,26 @@ pub async fn get_template(
Ok(Json(service::get_template(&state.db, &id).await?))
}
/// GET /api/v1/agent-templates/:id/full — 获取完整 Agent 模板(含扩展字段)
pub async fn get_full_template(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<AgentTemplateInfo>> {
check_permission(&ctx, "model:read")?;
// Reuses the same get_template service which already returns all fields
Ok(Json(service::get_template(&state.db, &id).await?))
}
/// GET /api/v1/agent-templates/available — 列出公开可用模板(轻量级)
pub async fn list_available(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<AvailableAgentTemplateInfo>>> {
check_permission(&ctx, "model:read")?;
Ok(Json(service::list_available(&state.db).await?))
}
/// POST /api/v1/agent-templates/:id — 更新 Agent 模板
pub async fn update_template(
State(state): State<AppState>,
@@ -79,6 +109,13 @@ pub async fn update_template(
req.max_tokens,
req.visibility.as_deref(),
req.status.as_deref(),
req.soul_content.as_deref(),
req.scenarios.as_deref(),
req.welcome_message.as_deref(),
req.quick_commands.as_deref(),
req.personality.as_deref(),
req.communication_style.as_deref(),
req.emoji.as_deref(),
).await?;
log_operation(&state.db, &ctx.account_id, "agent_template.update", "agent_template", &id,

View File

@@ -11,7 +11,9 @@ use crate::state::AppState;
pub fn routes() -> axum::Router<AppState> {
axum::Router::new()
.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))
}

View File

@@ -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
}

View File

@@ -22,6 +22,16 @@ pub struct AgentTemplateInfo {
pub current_version: i32,
pub created_at: String,
pub updated_at: String,
// Extended fields (migration 20260331000002)
pub soul_content: Option<String>,
pub scenarios: Vec<String>,
pub welcome_message: Option<String>,
pub quick_commands: Vec<serde_json::Value>,
pub personality: Option<String>,
pub communication_style: Option<String>,
pub emoji: Option<String>,
pub version: i32,
pub source_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -37,6 +47,15 @@ pub struct CreateAgentTemplateRequest {
pub temperature: Option<f64>,
pub max_tokens: Option<i32>,
pub visibility: Option<String>,
// Extended fields
pub soul_content: Option<String>,
pub scenarios: Option<Vec<String>>,
pub welcome_message: Option<String>,
pub quick_commands: Option<Vec<serde_json::Value>>,
pub personality: Option<String>,
pub communication_style: Option<String>,
pub emoji: Option<String>,
pub source_id: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -50,6 +69,14 @@ pub struct UpdateAgentTemplateRequest {
pub max_tokens: Option<i32>,
pub visibility: Option<String>,
pub status: Option<String>,
// Extended fields (source_id and version are immutable)
pub soul_content: Option<String>,
pub scenarios: Option<Vec<String>>,
pub welcome_message: Option<String>,
pub quick_commands: Option<Vec<serde_json::Value>>,
pub personality: Option<String>,
pub communication_style: Option<String>,
pub emoji: Option<String>,
}
// --- List ---
@@ -63,3 +90,16 @@ pub struct AgentTemplateListQuery {
pub page: Option<u32>,
pub page_size: Option<u32>,
}
// --- Available (lightweight) ---
/// Lightweight template summary for the /available endpoint
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AvailableAgentTemplateInfo {
pub id: String,
pub name: String,
pub category: String,
pub emoji: Option<String>,
pub description: Option<String>,
pub source_id: Option<String>,
}