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
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)
387 lines
14 KiB
Rust
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,
|
|
})
|
|
}
|