Files
Genarrative/apps/ai-game-creator-shell/src-tauri/src/config.rs
T
AIGameCreator App 7afec12958 拆分游戏创作壳 Tauri 入口
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块
拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口
扩展壳配置与原生壳门禁的 Rust 源码扫描范围
同步 AI 游戏创作 App 技术方案和决策记录
2026-07-04 16:25:45 +08:00

543 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use super::*;
pub(crate) fn build_game_creator_llm_client_from_llm_config(
llm: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<LlmClient, String> {
let api_key =
trim_config_string(&llm.api_key).ok_or_else(|| llm_api_key_config_error(config_path))?;
let base_url =
trim_config_string(&llm.base_url).ok_or_else(|| llm_base_url_config_error(config_path))?;
let model =
trim_config_string(&llm.model).ok_or_else(|| llm_model_config_error(config_path))?;
validate_game_creator_llm_timing_config(llm, config_path)?;
let config = LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url,
api_key,
model,
llm.request_timeout_ms,
llm.max_retries,
llm.retry_backoff_ms,
)
.map_err(|error| format!("LLM 配置无效:{error}"))?;
LlmClient::new(config).map_err(|error| format!("LLM client 初始化失败:{error}"))
}
pub(crate) fn build_game_creator_llm_client_from_config() -> Result<LlmClient, String> {
let app_config = load_game_creator_app_config()?;
build_game_creator_llm_client_from_llm_config(&app_config.llm, "llm")
}
pub(crate) fn parse_game_creator_llm_api_kind(value: &str) -> Result<LlmApiKind, String> {
let normalized = value.trim().to_ascii_lowercase().replace('-', "_");
let normalized = if normalized.is_empty() {
DEFAULT_GAME_CREATOR_LLM_API_KIND
} else {
normalized.as_str()
};
match normalized {
"openai_responses" => Ok(LlmApiKind::OpenAiResponses),
"openai_chat" => Ok(LlmApiKind::OpenAiChat),
"anthropic" => Ok(LlmApiKind::Anthropic),
value => Err(format!(
"LLM api_kind 无效:{value},请使用 openai_responses、openai_chat 或 anthropic"
)),
}
}
pub(crate) fn check_game_creator_llm_config_from_config() -> GameCreatorLlmConfigStatus {
let app_config = match load_game_creator_app_config() {
Ok(config) => config,
Err(error) => {
return GameCreatorLlmConfigStatus {
configured: false,
api_key_present: false,
base_url: None,
model: None,
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
stream: false,
error: Some(error),
agents: Vec::new(),
}
}
};
let mut status = check_game_creator_llm_config_values(&app_config.llm, "llm");
status.api_kind = parse_game_creator_llm_api_kind(&app_config.llm.api_kind)
.map(game_creator_llm_api_kind_name)
.unwrap_or_else(|error| {
status.configured = false;
status.error = Some(error);
DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()
});
if status.configured {
if let Err(error) = build_game_creator_llm_client_from_config() {
status.configured = false;
status.error = Some(error);
}
}
status.agents = game_creator_llm_agent_status_definitions()
.iter()
.map(|definition| {
let llm = resolve_game_creator_llm_config_for_agent(&app_config, &definition.agent_id);
check_game_creator_agent_llm_config_values(
&definition.agent_id,
&definition.label,
&llm,
)
})
.collect();
let agent_errors = status
.agents
.iter()
.filter(|agent| GAME_CREATOR_REQUIRED_LLM_AGENT_IDS.contains(&agent.agent_id.as_str()))
.filter(|agent| !agent.configured)
.map(|agent| {
format!(
"{}:{}",
agent.label,
agent.error.as_deref().unwrap_or("配置不完整")
)
})
.collect::<Vec<_>>();
status.configured = agent_errors.is_empty();
status.error = if agent_errors.is_empty() {
None
} else {
Some(agent_errors.join(";"))
};
status
}
pub(crate) fn check_game_creator_llm_config_values(
config: &GameCreatorLlmConfig,
config_path: &str,
) -> GameCreatorLlmConfigStatus {
let api_key = trim_config_string(&config.api_key);
let base_url = trim_config_string(&config.base_url);
let model = trim_config_string(&config.model);
let api_key_present = api_key
.as_ref()
.is_some_and(|value| !value.trim().is_empty());
let error = match (api_key.as_deref(), base_url.as_deref(), model.as_deref()) {
(None, _, _) => Some(llm_api_key_config_error(config_path)),
(_, None, _) => Some(llm_base_url_config_error(config_path)),
(_, _, None) => Some(llm_model_config_error(config_path)),
(Some(api_key), Some(base_url), Some(model)) => {
validate_game_creator_llm_timing_config(config, config_path)
.err()
.or_else(|| {
LlmConfig::new(
LlmProvider::OpenAiCompatible,
base_url.to_string(),
api_key.to_string(),
model.to_string(),
config.request_timeout_ms,
config.max_retries,
config.retry_backoff_ms,
)
.and_then(LlmClient::new)
.err()
.map(|error| format!("LLM 配置无效:{error}"))
})
}
};
GameCreatorLlmConfigStatus {
configured: error.is_none(),
api_key_present,
base_url,
model,
api_kind: DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string(),
stream: config.stream,
error,
agents: Vec::new(),
}
}
pub(crate) fn check_game_creator_agent_llm_config_values(
agent_id: &str,
label: &str,
config: &GameCreatorLlmConfig,
) -> GameCreatorAgentLlmConfigStatus {
let config_path = format!("agentLlm.{agent_id}");
let mut status = check_game_creator_llm_config_values(config, &config_path);
status.api_kind = parse_game_creator_llm_api_kind(&config.api_kind)
.map(game_creator_llm_api_kind_name)
.unwrap_or_else(|error| {
status.configured = false;
status.error = Some(error);
DEFAULT_GAME_CREATOR_LLM_API_KIND.to_string()
});
if status.configured {
if let Err(error) = build_game_creator_llm_client_from_llm_config(config, &config_path) {
status.configured = false;
status.error = Some(error);
}
}
GameCreatorAgentLlmConfigStatus {
agent_id: agent_id.to_string(),
label: label.to_string(),
configured: status.configured,
api_key_present: status.api_key_present,
base_url: status.base_url,
model: status.model,
api_kind: status.api_kind,
stream: config.stream,
error: status.error,
}
}
pub(crate) fn validate_game_creator_llm_timing_config(
config: &GameCreatorLlmConfig,
config_path: &str,
) -> Result<(), String> {
if config.request_timeout_ms < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS {
return Err(format!(
"配置项 {config_path}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
));
}
if config.retry_backoff_ms == 0 {
return Err(format!("配置项 {config_path}.retryBackoffMs 必须大于 0"));
}
Ok(())
}
pub(crate) fn game_creator_llm_api_kind_name(api_kind: LlmApiKind) -> String {
match api_kind {
LlmApiKind::OpenAiChat => "openai_chat",
LlmApiKind::OpenAiResponses => "openai_responses",
LlmApiKind::Anthropic => "anthropic",
}
.to_string()
}
pub(crate) fn configure_game_creator_runtime_config_dir(
app: &tauri::AppHandle,
) -> Result<(), Box<dyn std::error::Error>> {
let config_dir = app.path().app_config_dir()?;
fs::create_dir_all(&config_dir)?;
let config_path = config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME);
if !config_path.exists() {
fs::write(&config_path, DEFAULT_GAME_CREATOR_APP_CONFIG_JSON)?;
}
set_game_creator_runtime_config_dir(config_dir);
Ok(())
}
pub(crate) fn game_creator_runtime_config_dir_lock() -> &'static Mutex<Option<PathBuf>> {
GAME_CREATOR_RUNTIME_CONFIG_DIR.get_or_init(|| Mutex::new(None))
}
pub(crate) fn set_game_creator_runtime_config_dir(path: PathBuf) {
*game_creator_runtime_config_dir_lock()
.lock()
.expect("runtime config dir lock") = Some(path);
}
pub(crate) fn game_creator_runtime_config_dir() -> Option<PathBuf> {
game_creator_runtime_config_dir_lock()
.lock()
.expect("runtime config dir lock")
.clone()
}
pub(crate) fn load_game_creator_app_config() -> Result<GameCreatorAppConfig, String> {
let mut config = GameCreatorAppConfig::default();
for path in game_creator_config_paths() {
merge_game_creator_config_file(&mut config, &path)?;
}
Ok(config)
}
pub(crate) fn game_creator_app_config_view(
config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfigView, String> {
Ok(GameCreatorAppConfigView {
path: writable_game_creator_config_path()?.display().to_string(),
config,
})
}
pub(crate) fn writable_game_creator_config_path() -> Result<PathBuf, String> {
if let Some(config_dir) = game_creator_runtime_config_dir() {
return Ok(config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME));
}
std::env::current_dir()
.map(|directory| directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME))
.map_err(|error| format!("读取当前目录失败:{error}"))
}
pub(crate) fn game_creator_config_paths() -> Vec<PathBuf> {
if let Some(config_dir) = game_creator_runtime_config_dir() {
return vec![
config_dir.join(GAME_CREATOR_CONFIG_FILE_NAME),
config_dir.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
];
}
let mut roots = Vec::new();
if let Ok(cwd) = std::env::current_dir() {
roots.push(cwd);
}
if let Ok(exe) = std::env::current_exe() {
if let Some(parent) = exe.parent() {
roots.push(parent.to_path_buf());
}
}
let mut default_paths = Vec::new();
let mut local_paths = Vec::new();
for root in roots {
let ancestors = root.ancestors().take(8).collect::<Vec<_>>();
for directory in ancestors.into_iter().rev() {
push_unique_path(
&mut default_paths,
directory.join(GAME_CREATOR_CONFIG_FILE_NAME),
);
push_unique_path(
&mut default_paths,
directory
.join("apps")
.join("ai-game-creator-shell")
.join(GAME_CREATOR_CONFIG_FILE_NAME),
);
push_unique_path(
&mut local_paths,
directory.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
);
push_unique_path(
&mut local_paths,
directory
.join("apps")
.join("ai-game-creator-shell")
.join(GAME_CREATOR_LOCAL_CONFIG_FILE_NAME),
);
}
}
default_paths.extend(local_paths);
default_paths
}
pub(crate) fn push_unique_path(paths: &mut Vec<PathBuf>, path: PathBuf) {
if !paths.iter().any(|existing| existing == &path) {
paths.push(path);
}
}
pub(crate) fn merge_game_creator_config_file(
config: &mut GameCreatorAppConfig,
path: &Path,
) -> Result<(), String> {
if !path.is_file() {
return Ok(());
}
let content = fs::read_to_string(path)
.map_err(|error| format!("读取客户端配置失败:{}: {error}", path.display()))?;
let file_config = serde_json::from_str::<GameCreatorAppConfigFile>(&content)
.map_err(|error| format!("解析客户端配置失败:{}: {error}", path.display()))?;
if let Some(llm) = file_config.llm {
merge_game_creator_llm_config(&mut config.llm, llm);
}
if let Some(agent_llm) = file_config.agent_llm {
for (agent_id, patch) in agent_llm {
let entry = config.agent_llm.entry(agent_id).or_default();
merge_game_creator_llm_patch(entry, patch);
}
}
if let Some(editor_api) = file_config.editor_api {
merge_game_creator_editor_api_config(&mut config.editor_api, editor_api);
}
Ok(())
}
pub(crate) fn merge_game_creator_llm_config(
config: &mut GameCreatorLlmConfig,
patch: GameCreatorLlmConfigFile,
) {
if let Some(value) = patch.api_key {
config.api_key = value;
}
if let Some(value) = patch.base_url {
config.base_url = value;
}
if let Some(value) = patch.model {
config.model = value;
}
if let Some(value) = patch.api_kind {
config.api_kind = value;
}
if let Some(value) = patch.stream {
config.stream = value;
}
if let Some(value) = patch.request_timeout_ms {
config.request_timeout_ms = value;
}
if let Some(value) = patch.max_retries {
config.max_retries = value;
}
if let Some(value) = patch.retry_backoff_ms {
config.retry_backoff_ms = value;
}
}
pub(crate) fn merge_game_creator_llm_patch(
config: &mut GameCreatorLlmConfigFile,
patch: GameCreatorLlmConfigFile,
) {
if let Some(value) = patch.api_key {
config.api_key = Some(value);
}
if let Some(value) = patch.base_url {
config.base_url = Some(value);
}
if let Some(value) = patch.model {
config.model = Some(value);
}
if let Some(value) = patch.api_kind {
config.api_kind = Some(value);
}
if let Some(value) = patch.stream {
config.stream = Some(value);
}
if let Some(value) = patch.request_timeout_ms {
config.request_timeout_ms = Some(value);
}
if let Some(value) = patch.max_retries {
config.max_retries = Some(value);
}
if let Some(value) = patch.retry_backoff_ms {
config.retry_backoff_ms = Some(value);
}
}
pub(crate) fn resolve_game_creator_llm_config_for_agent(
config: &GameCreatorAppConfig,
agent_id: &str,
) -> GameCreatorLlmConfig {
let mut llm = config.llm.clone();
if let Some(patch) = config.agent_llm.get(agent_id) {
merge_game_creator_llm_config(&mut llm, patch.clone());
}
llm
}
pub(crate) fn merge_game_creator_editor_api_config(
config: &mut GameCreatorEditorApiConfig,
patch: GameCreatorEditorApiConfigFile,
) {
if let Some(value) = patch.base_url {
config.base_url = value;
}
if let Some(value) = patch.api_key {
config.api_key = value;
}
}
pub(crate) fn trim_config_string(value: &str) -> Option<String> {
let value = value.trim();
if value.is_empty() {
None
} else {
Some(value.to_string())
}
}
pub(crate) fn normalize_game_creator_app_config(
mut config: GameCreatorAppConfig,
) -> Result<GameCreatorAppConfig, String> {
config.llm.api_key = config.llm.api_key.trim().to_string();
config.llm.base_url =
trim_config_string(&config.llm.base_url).ok_or_else(|| llm_base_url_config_error("llm"))?;
config.llm.model =
trim_config_string(&config.llm.model).ok_or_else(|| llm_model_config_error("llm"))?;
config.llm.api_kind =
game_creator_llm_api_kind_name(parse_game_creator_llm_api_kind(&config.llm.api_kind)?);
validate_game_creator_llm_timing_config(&config.llm, "llm")?;
let mut agent_llm = BTreeMap::new();
for (agent_id, patch) in config.agent_llm {
let agent_id = match trim_config_string(&agent_id) {
Some(value) => value,
None => continue,
};
let patch = normalize_game_creator_llm_patch_config(&agent_id, patch)?;
if !is_empty_game_creator_llm_patch(&patch) {
agent_llm.insert(agent_id, patch);
}
}
config.agent_llm = agent_llm;
config.editor_api.base_url = trim_config_string(&config.editor_api.base_url)
.ok_or_else(|| "配置项 editorApi.baseUrl 不能为空".to_string())?;
config.editor_api.api_key = config.editor_api.api_key.trim().to_string();
Ok(config)
}
pub(crate) fn normalize_game_creator_llm_patch_config(
agent_id: &str,
mut patch: GameCreatorLlmConfigFile,
) -> Result<GameCreatorLlmConfigFile, String> {
patch.api_key = patch.api_key.and_then(|value| trim_config_string(&value));
patch.base_url = patch.base_url.and_then(|value| trim_config_string(&value));
patch.model = patch.model.and_then(|value| trim_config_string(&value));
patch.api_kind = match patch.api_kind {
Some(value) => Some(game_creator_llm_api_kind_name(
parse_game_creator_llm_api_kind(&value)
.map_err(|error| format!("配置项 agentLlm.{agent_id}.apiKind 无效:{error}"))?,
)),
None => None,
};
if patch
.request_timeout_ms
.is_some_and(|value| value < MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS)
{
return Err(format!(
"配置项 agentLlm.{agent_id}.requestTimeoutMs 必须至少为 {MIN_GAME_CREATOR_LLM_REQUEST_TIMEOUT_MS}"
));
}
if patch.retry_backoff_ms.is_some_and(|value| value == 0) {
return Err(format!(
"配置项 agentLlm.{agent_id}.retryBackoffMs 必须大于 0"
));
}
Ok(patch)
}
pub(crate) fn is_empty_game_creator_llm_patch(patch: &GameCreatorLlmConfigFile) -> bool {
patch.api_key.is_none()
&& patch.base_url.is_none()
&& patch.model.is_none()
&& patch.api_kind.is_none()
&& patch.stream.is_none()
&& patch.request_timeout_ms.is_none()
&& patch.max_retries.is_none()
&& patch.retry_backoff_ms.is_none()
}
pub(crate) fn llm_api_key_config_error(config_path: &str) -> String {
format!(
"LLM 未配置:请在 {} 的 {config_path}.apiKey 中设置 API Key",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
}
pub(crate) fn llm_base_url_config_error(config_path: &str) -> String {
format!(
"LLM base_url 未配置:请在 {} 的 {config_path}.baseUrl 中设置",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
}
pub(crate) fn llm_model_config_error(config_path: &str) -> String {
format!(
"LLM model 未配置:请在 {} 的 {config_path}.model 中设置",
game_creator_config_file_label(GAME_CREATOR_CONFIG_FILE_NAME)
)
}
pub(crate) fn game_creator_config_file_label(file_name: &str) -> String {
game_creator_runtime_config_dir()
.map(|directory| directory.join(file_name).display().to_string())
.unwrap_or_else(|| file_name.to_string())
}