Files
zclaw_openfang/crates/zclaw-saas/src/agent_template/service.rs
iven 2ceeeaba3d
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
fix(production-readiness): 3-batch production readiness cleanup — 12 tasks
Batch 1 — User-facing fixes:
- B1-1: Pipeline verified end-to-end (14 Rust commands, 8 frontend invoke, fully connected)
- B1-2: MessageSearch restored to ChatArea with search button in DeerFlow header
- B1-3: Viking cleanup — removed 5 orphan invokes (no Rust impl), added addWithMetadata + storeWithSummaries methods + summary generation UI
- B1-4: api-fallbacks transparency — added _isFallback markers + console.warn to all 6 fallback functions

Batch 2 — System health:
- B2-1: Document drift calibration — TRUTH.md/README.md numbers verified and updated
- B2-2: @reserved annotations on 15 SaaS handler functions with no frontend callers
- B2-3: Scheduled Task Admin V2 — new service + page + route + sidebar navigation
- B2-4: TRUTH.md Pipeline/Viking/ScheduledTask records corrected

Batch 3 — Long-term quality:
- B3-1: hand_run_status/hand_run_list verified as fully implemented (not stubs)
- B3-2: Identity snapshot rollback UI added to RightPanel
- B3-3: P2 code quality — 4 fixes (TODO comments, fire-and-forget notes, design notes, table name validation), 2 verified N/A, 1 upstream
- B3-4: Config PATCH→PUT alignment (admin-v2 config.ts matched to SaaS backend)
2026-04-03 21:34:56 +08:00

387 lines
14 KiB
Rust

//! 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::<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"),
}
}
/// 创建 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<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(
&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<AgentTemplateInfo> {
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<crate::common::PaginatedResponse<AgentTemplateInfo>> {
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<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,
description: Option<&str>,
model: Option<&str>,
system_prompt: Option<&str>,
tools: Option<&[String]>,
capabilities: Option<&[String]>,
temperature: Option<f64>,
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?;
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<AgentTemplateInfo> {
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<AgentTemplateInfo> {
// 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<Option<AgentTemplateInfo>> {
let row = sqlx::query_scalar::<_, Option<String>>(
"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<AgentConfigFromTemplate> {
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,
})
}