7afec12958
拆出 CLI、命令、配置、Agent、素材、项目、预览和窗口模块 拆出 Rust 单测并保留 main.rs 作为 Tauri 薄入口 扩展壳配置与原生壳门禁的 Rust 源码扫描范围 同步 AI 游戏创作 App 技术方案和决策记录
543 lines
18 KiB
Rust
543 lines
18 KiB
Rust
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())
|
||
}
|