Files
suzmii 4f3f0f24ff 接入AGC后台模型目录与对话模型选择
新增后台 AGC 模型目录、别名、启停和默认项管理

客户端设置页恢复原状,对话框右下角按别名选择模型

服务端按稳定模型标识映射并校验实际模型白名单

修复 AGC 配套后端端口漂移、启动等待和 SpacetimeDB 版本检查

补充迁移、文档、启动与模型选择测试
2026-09-05 19:10:49 +08:00

119 lines
3.9 KiB
Rust

use serde::{Deserialize, Serialize};
use std::collections::HashSet;
pub const AGC_MODEL_CATALOG_CONFLICT: &str = "AGC_MODEL_CATALOG_CONFLICT";
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModel {
pub id: String,
pub alias: String,
pub model_id: String,
pub enabled: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct AgcModelCatalog {
pub revision: u64,
pub default_model_id: String,
pub models: Vec<AgcModel>,
}
impl Default for AgcModelCatalog {
fn default() -> Self {
Self {
revision: 0,
default_model_id: "quality".into(),
models: vec![
AgcModel {
id: "quality".into(),
alias: "高质量".into(),
model_id: "gpt-6-astra".into(),
enabled: true,
},
AgcModel {
id: "fast".into(),
alias: "快速".into(),
model_id: "gpt-5.6-luna".into(),
enabled: true,
},
],
}
}
}
impl AgcModelCatalog {
pub fn validate(&self) -> Result<(), String> {
if self.models.is_empty() || self.models.len() > 32 {
return Err("模型列表必须包含 1 至 32 项".into());
}
let mut ids = HashSet::new();
let mut aliases = HashSet::new();
for model in &self.models {
if model.id.is_empty()
|| model.id == "platform-default"
|| model.id.len() > 64
|| !model
.id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || c == b'-' || c == b'_')
|| !ids.insert(&model.id)
{
return Err("模型标识须唯一且只含字母、数字、连字符或下划线".into());
}
if model.alias.trim().is_empty()
|| model.alias != model.alias.trim()
|| model.alias.chars().count() > 40
|| model.alias.chars().any(char::is_control)
|| !aliases.insert(&model.alias)
{
return Err("模型别名须唯一,长度为 1 至 40 字符".into());
}
if model.model_id.trim().is_empty()
|| model.model_id != model.model_id.trim()
|| model.model_id.len() > 200
|| model.model_id.chars().any(char::is_control)
{
return Err("实际模型名不能为空,长度不能超过 200 字节".into());
}
}
self.resolve(&self.default_model_id).map(|_| ())
}
pub fn resolve(&self, id: &str) -> Result<&str, String> {
self.models
.iter()
.find(|m| m.enabled && m.id == id)
.map(|m| m.model_id.as_str())
.ok_or_else(|| "所选模型不可用,请刷新模型列表".into())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn catalog_maps_only_enabled_ids() {
let mut catalog = AgcModelCatalog::default();
assert!(catalog.validate().is_ok());
assert_eq!(catalog.resolve("quality").unwrap(), "gpt-6-astra");
assert!(catalog.resolve("gpt-6-astra").is_err());
assert!(catalog.resolve("unknown").is_err());
catalog.models[0].enabled = false;
assert!(catalog.resolve("quality").is_err());
assert!(catalog.validate().is_err());
}
#[test]
fn catalog_rejects_duplicate_aliases_and_ids() {
let mut catalog = AgcModelCatalog::default();
catalog.models[1].alias = catalog.models[0].alias.clone();
assert!(catalog.validate().is_err());
catalog.models[1].alias = "快速".into();
catalog.models[1].id = catalog.models[0].id.clone();
assert!(catalog.validate().is_err());
}
}