支持单Agent真实对话与并行执行

新增单 Agent 真实 LLM 对话命令并按 agentLlm.<agentId> 路由

开发聊天窗口切换 Agent 时自动读取历史并持久化真实回复

Agent loop 按 dependencyWaves 并行执行同一 wave 内角色 brief

补充并行执行、单 Agent 对话和界面回归测试

更新 AI 游戏创作 App 技术方案文档
This commit is contained in:
AIGameCreator App
2026-07-08 20:54:05 +08:00
parent 788c085684
commit c0ff9edf97
7 changed files with 667 additions and 102 deletions
+322 -78
View File
@@ -117,10 +117,102 @@ pub(crate) async fn chat_with_game_creator_agent_at(
Ok(GameCreatorChatAgentReply { reply_text })
}
pub(crate) async fn chat_with_game_creator_role_agent_at(
root: &Path,
agent_id: &str,
prompt: &str,
) -> Result<GameCreatorChatAgentReply, String> {
let agent_id = agent_id.trim();
let prompt = prompt.trim();
if agent_id.is_empty() {
return Err("Agent ID 不能为空".to_string());
}
if prompt.is_empty() {
return Err("聊天内容不能为空".to_string());
}
validate_project_root(root)?;
let (group_definition, role_definition) = game_creator_agent_role_definition(agent_id)
.ok_or_else(|| format!("未知 Agent{agent_id}"))?;
let short_memory = read_optional_text(&root.join("memory/session.md"))?;
let long_memory = read_optional_text(&root.join("memory/project.md"))?;
let project_blackboard = read_optional_text(&root.join(PROJECT_BLACKBOARD_MEMORY_PATH))?;
let agent_memory = read_local_agent_memory_at(root, agent_id)?.content;
let asset_context = render_local_asset_prompt_context(root)?;
let conversation_context = render_local_conversation_prompt_context(root, Some(agent_id))?;
let identity = format!(
"你当前是 {} / {}taskId={},角色代号={}。请只以这个专业 Agent 的身份回应。",
group_definition.label, role_definition.role, role_definition.task_id, role_definition.id
);
let context = [
("Agent 身份", identity.as_str()),
("Agent 私有记忆", agent_memory.as_str()),
("短期记忆", short_memory.as_str()),
("长期记忆", long_memory.as_str()),
("项目黑板", project_blackboard.as_str()),
("资产上下文", asset_context.as_str()),
("最近项目与本 Agent 对话", conversation_context.as_str()),
]
.into_iter()
.filter_map(|(title, content)| {
let content = truncate_prompt_context(content);
if content.trim().is_empty() {
None
} else {
Some(format!("# {title}\n\n{content}"))
}
})
.collect::<Vec<_>>()
.join("\n\n");
let app_config = load_game_creator_app_config()?;
let llm = resolve_game_creator_llm_config_for_agent(&app_config, agent_id);
let config_path = format!("agentLlm.{agent_id}");
let client = build_game_creator_llm_client_from_llm_config(&llm, &config_path)?;
let user_prompt = if context.trim().is_empty() {
format!("用户这轮输入:\n{prompt}")
} else {
format!("项目上下文如下。请只把它当作背景,不要逐字复述。\n\n{context}\n\n用户这轮输入:\n{prompt}")
};
let request = LlmRunRequest::new(vec![
LlmMessage::system(game_creator_role_agent_chat_system_prompt()),
LlmMessage::user(user_prompt),
])
.with_api_kind(parse_game_creator_llm_api_kind(&llm.api_kind)?)
.with_max_output_tokens(GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS);
let response = request_game_creator_llm_text(&client, &llm, request)
.await
.map_err(|error| format!("{config_path} 单 Agent 聊天调用 LLM 失败:{error}"))?;
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
if reply_text.is_empty() {
return Err(format!("{config_path} 单 Agent 聊天未返回内容"));
}
Ok(GameCreatorChatAgentReply { reply_text })
}
pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str {
"你是 Genarrative AI 游戏创作桌面 App 的主聊天 Agent。你要像正常协作型聊天助手一样回应用户,理解需求、澄清不确定点、给出下一步建议,并在需要执行生成、运行、预览、读取文件、写记忆或生成美术时建议用户使用现有 slash 命令。普通聊天中不要假装已经写入文件、生成游戏、调用画板或执行工具;不要输出 JSON;不要泄露密钥;回复保持简洁、具体、中文优先。"
}
pub(crate) fn game_creator_role_agent_chat_system_prompt() -> &'static str {
"你是 Genarrative AI 游戏创作多智能体中的一个专业角色 Agent。你正在开发专用单 Agent 聊天窗口中和开发者对话,需要围绕自己的专业职责直接回应、澄清问题、给出可执行建议,并说明哪些信息会影响后续生成。不要假装已经写入文件、生成游戏、调用画板或执行工具;不要泄露密钥;不要输出 JSON;不要包裹代码块;回复保持简洁、具体、中文优先。"
}
pub(crate) fn game_creator_agent_role_definition(
agent_id: &str,
) -> Option<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> {
GAME_CREATOR_AGENT_GROUP_DEFINITIONS
.iter()
.find_map(|group_definition| {
group_definition
.roles
.iter()
.find(|role_definition| role_definition.task_id == agent_id)
.map(|role_definition| (group_definition, role_definition))
})
}
pub(crate) fn write_local_game_draft_at(
root: &Path,
prompt: &str,
@@ -787,25 +879,53 @@ pub(crate) async fn request_agent_group_briefs_with_client(
agenda: &AgentPassAgenda,
pass: u8,
) -> Result<Vec<AgentGroupBrief>, String> {
let mut briefs = Vec::new();
let mut role_briefs_by_task = BTreeMap::<String, AgentRoleBrief>::new();
let mut completed_group_context = String::new();
let mut completed_role_context = String::new();
let agenda_markdown = read_optional_text(&root.join(&agenda.relative_path))?;
for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
let mut role_briefs = Vec::new();
let mut completed_role_context = String::new();
for role_definition in definition.roles {
let ordered_roles = ordered_game_creator_agent_roles();
let mut waves = if agenda.dependency_waves.is_empty() {
vec![ordered_roles
.iter()
.map(|(_, role)| role.task_id.to_string())
.collect::<Vec<_>>()]
} else {
agenda.dependency_waves.clone()
};
let known_wave_task_ids = waves
.iter()
.flat_map(|wave| wave.iter())
.cloned()
.collect::<std::collections::BTreeSet<_>>();
let missing_task_ids = ordered_roles
.iter()
.map(|(_, role)| role.task_id.to_string())
.filter(|task_id| !known_wave_task_ids.contains(task_id))
.collect::<Vec<_>>();
if !missing_task_ids.is_empty() {
waves.push(missing_task_ids);
}
for wave in waves {
let mut role_brief_jobs = tokio::task::JoinSet::new();
for task_id in wave {
let Some((definition, role_definition)) = game_creator_agent_role_definition(&task_id)
else {
continue;
};
let agent_memory_relative_path =
agent_role_memory_relative_path(definition, *role_definition);
let mut should_run = agenda
agent_role_memory_relative_path(*definition, *role_definition);
let should_run = agenda
.active_task_ids
.iter()
.any(|task_id| task_id == role_definition.task_id);
.any(|active_task_id| active_task_id == role_definition.task_id);
if !should_run {
if let Some((source_path, source_markdown)) =
read_previous_agent_role_brief(root, pass, definition, *role_definition)?
read_previous_agent_role_brief(root, pass, *definition, *role_definition)?
{
let markdown = render_carryover_role_brief(
definition,
*definition,
*role_definition,
&source_path,
&source_markdown,
@@ -813,12 +933,12 @@ pub(crate) async fn request_agent_group_briefs_with_client(
let relative_path = write_agent_role_brief(
root,
pass,
definition,
*definition,
*role_definition,
&markdown,
)?;
let role_brief = AgentRoleBrief {
group_definition: definition,
group_definition: *definition,
role_definition: *role_definition,
markdown,
relative_path,
@@ -834,83 +954,85 @@ pub(crate) async fn request_agent_group_briefs_with_client(
),
};
completed_role_context.push_str(&render_agent_role_brief_context(&role_brief));
role_briefs.push(role_brief);
role_briefs_by_task.insert(role_definition.task_id.to_string(), role_brief);
continue;
}
should_run = true;
}
if !should_run {
continue;
}
let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?;
let agent_conversation_context =
render_local_conversation_prompt_context(root, Some(role_definition.task_id))?;
let role_short_memory =
append_prompt_context(&agent_conversation_context, short_memory);
let local_markdown = render_local_agent_role_brief(
definition,
*role_definition,
prompt,
&role_short_memory,
long_memory,
project_blackboard,
&agent_memory,
spec_markdown,
findings_markdown,
&agenda_markdown,
&completed_group_context,
&completed_role_context,
let root = root.to_path_buf();
let app_config = app_config.clone();
let prompt = prompt.to_string();
let short_memory = short_memory.to_string();
let long_memory = long_memory.to_string();
let project_blackboard = project_blackboard.to_string();
let spec_markdown = spec_markdown.to_string();
let findings_markdown = findings_markdown.to_string();
let agenda_markdown = agenda_markdown.clone();
let completed_group_context = completed_group_context.clone();
let completed_role_context = completed_role_context.clone();
role_brief_jobs.spawn(async move {
build_agent_role_brief_draft(
root,
app_config,
*definition,
*role_definition,
prompt,
short_memory,
long_memory,
project_blackboard,
spec_markdown,
findings_markdown,
agenda_markdown,
completed_group_context,
completed_role_context,
pass,
)
.await
});
}
let mut role_brief_drafts = Vec::new();
while let Some(result) = role_brief_jobs.join_next().await {
let draft = result.map_err(|error| format!("角色 Agent 并行任务失败:{error}"))??;
role_brief_drafts.push(draft);
}
role_brief_drafts.sort_by_key(|draft| {
ordered_roles
.iter()
.position(|(_, role)| role.task_id == draft.role_definition.task_id)
.unwrap_or(usize::MAX)
});
for draft in role_brief_drafts {
let relative_path = write_agent_role_brief(
root,
pass,
);
let (markdown, tool_id, summary) =
if has_game_creator_agent_llm_override(app_config, role_definition.task_id) {
let markdown = request_agent_role_brief_with_config(
app_config,
role_definition.task_id,
&local_markdown,
)
.await?;
(
markdown,
format!("llm.chat.{}", role_definition.task_id),
format!(
"{} / {} 使用 agentLlm.{} 生成 brief",
definition.label, role_definition.role, role_definition.task_id
),
)
} else {
(
local_markdown,
role_definition.tool_id.to_string(),
format!(
"本地编排生成 {} / {} brief",
definition.label, role_definition.role
),
)
};
let relative_path =
write_agent_role_brief(root, pass, definition, *role_definition, &markdown)?;
draft.group_definition,
draft.role_definition,
&draft.markdown,
)?;
let role_brief = AgentRoleBrief {
group_definition: definition,
role_definition: *role_definition,
markdown,
group_definition: draft.group_definition,
role_definition: draft.role_definition,
markdown: draft.markdown,
relative_path,
memory_relative_path: agent_memory_relative_path,
status: "completed".to_string(),
tool_id,
summary,
memory_relative_path: draft.memory_relative_path,
status: draft.status,
tool_id: draft.tool_id,
summary: draft.summary,
};
completed_role_context.push_str(&render_agent_role_brief_context(&role_brief));
role_briefs.push(role_brief);
role_briefs_by_task.insert(role_brief.role_definition.task_id.to_string(), role_brief);
}
completed_group_context = render_completed_agent_group_context(&role_briefs_by_task);
}
let mut briefs = Vec::new();
for definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
let role_briefs = definition
.roles
.iter()
.filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned())
.collect::<Vec<_>>();
let markdown = render_agent_group_brief_markdown(&role_briefs);
let relative_path = write_agent_group_brief(root, pass, definition, &markdown)?;
completed_group_context.push_str(&format!(
"## {} / {}\n\n{}\n\n",
definition.label,
definition.role,
markdown.trim()
));
briefs.push(AgentGroupBrief {
definition,
markdown,
@@ -921,6 +1043,128 @@ pub(crate) async fn request_agent_group_briefs_with_client(
Ok(briefs)
}
#[derive(Debug)]
struct AgentRoleBriefDraft {
group_definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
markdown: String,
memory_relative_path: String,
status: String,
tool_id: String,
summary: String,
}
fn ordered_game_creator_agent_roles(
) -> Vec<(&'static AgentGroupDefinition, &'static AgentRoleDefinition)> {
GAME_CREATOR_AGENT_GROUP_DEFINITIONS
.iter()
.flat_map(|group_definition| {
group_definition
.roles
.iter()
.map(move |role_definition| (group_definition, role_definition))
})
.collect()
}
fn render_completed_agent_group_context(
role_briefs_by_task: &BTreeMap<String, AgentRoleBrief>,
) -> String {
let mut output = String::new();
for group_definition in GAME_CREATOR_AGENT_GROUP_DEFINITIONS {
let role_briefs = group_definition
.roles
.iter()
.filter_map(|role_definition| role_briefs_by_task.get(role_definition.task_id).cloned())
.collect::<Vec<_>>();
if role_briefs.is_empty() {
continue;
}
output.push_str(&format!(
"## {} / {}\n\n{}\n\n",
group_definition.label,
group_definition.role,
render_agent_group_brief_markdown(&role_briefs).trim()
));
}
output
}
#[allow(clippy::too_many_arguments)]
async fn build_agent_role_brief_draft(
root: PathBuf,
app_config: GameCreatorAppConfig,
definition: AgentGroupDefinition,
role_definition: AgentRoleDefinition,
prompt: String,
short_memory: String,
long_memory: String,
project_blackboard: String,
spec_markdown: String,
findings_markdown: String,
agenda_markdown: String,
completed_group_context: String,
completed_role_context: String,
pass: u8,
) -> Result<AgentRoleBriefDraft, String> {
let agent_memory_relative_path = agent_role_memory_relative_path(definition, role_definition);
let agent_memory = read_optional_text(&root.join(&agent_memory_relative_path))?;
let agent_conversation_context =
render_local_conversation_prompt_context(&root, Some(role_definition.task_id))?;
let role_short_memory = append_prompt_context(&agent_conversation_context, &short_memory);
let local_markdown = render_local_agent_role_brief(
definition,
role_definition,
&prompt,
&role_short_memory,
&long_memory,
&project_blackboard,
&agent_memory,
&spec_markdown,
&findings_markdown,
&agenda_markdown,
&completed_group_context,
&completed_role_context,
pass,
);
let (markdown, tool_id, summary) =
if has_game_creator_agent_llm_override(&app_config, role_definition.task_id) {
let markdown = request_agent_role_brief_with_config(
&app_config,
role_definition.task_id,
&local_markdown,
)
.await?;
(
markdown,
format!("llm.chat.{}", role_definition.task_id),
format!(
"{} / {} 使用 agentLlm.{} 生成 brief",
definition.label, role_definition.role, role_definition.task_id
),
)
} else {
(
local_markdown,
role_definition.tool_id.to_string(),
format!(
"本地编排生成 {} / {} brief",
definition.label, role_definition.role
),
)
};
Ok(AgentRoleBriefDraft {
group_definition: definition,
role_definition,
markdown,
memory_relative_path: agent_memory_relative_path,
status: "completed".to_string(),
tool_id,
summary,
})
}
pub(crate) fn has_game_creator_agent_llm_override(
config: &GameCreatorAppConfig,
agent_id: &str,
@@ -237,6 +237,17 @@ pub(crate) async fn chat_with_game_creator_agent(
chat_with_game_creator_agent_at(root, prompt.trim()).await
}
#[tauri::command]
pub(crate) async fn chat_with_game_creator_role_agent(
project_path: String,
agent_id: String,
prompt: String,
) -> Result<GameCreatorChatAgentReply, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "conversation.read")?;
chat_with_game_creator_role_agent_at(root, agent_id.trim(), prompt.trim()).await
}
#[tauri::command]
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
check_game_creator_llm_config_from_config()
@@ -927,6 +927,7 @@ fn main() {
control_agent_run,
generate_local_game_draft,
chat_with_game_creator_agent,
chat_with_game_creator_role_agent,
check_game_creator_llm_config,
read_game_creator_app_config,
write_game_creator_app_config,
@@ -2,7 +2,7 @@ use super::*;
use serde_json::Value;
use std::io::{Read, Write};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex as StdMutex, MutexGuard as StdMutexGuard};
use std::sync::{Arc, Condvar, Mutex as StdMutex, MutexGuard as StdMutexGuard};
use std::time::{SystemTime, UNIX_EPOCH};
use zip::write::SimpleFileOptions;
@@ -589,6 +589,63 @@ fn spawn_mock_llm_server_responses_with_capture(
base_url
}
fn spawn_barrier_mock_llm_server(
response_content: String,
barrier: Arc<(StdMutex<usize>, Condvar)>,
expected_requests: usize,
request_sender: mpsc::Sender<String>,
) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock llm bind");
let base_url = format!("http://{}", listener.local_addr().expect("mock llm addr"));
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().expect("mock llm accept");
let mut request_buffer = [0_u8; 8192];
let read_len = stream.read(&mut request_buffer).unwrap_or(0);
let _ =
request_sender.send(String::from_utf8_lossy(&request_buffer[..read_len]).into_owned());
let (lock, cvar) = &*barrier;
let mut count = lock.lock().expect("barrier lock");
*count += 1;
cvar.notify_all();
while *count < expected_requests {
let wait_result = cvar
.wait_timeout(count, Duration::from_secs(2))
.expect("barrier wait");
count = wait_result.0;
if wait_result.1.timed_out() && *count < expected_requests {
let body = r#"{"error":"parallel barrier timeout"}"#;
let response = format!(
"HTTP/1.1 500 Internal Server Error\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm timeout response");
return;
}
}
drop(count);
let body = serde_json::json!({
"id": "resp_game_creator_mock",
"model": "mock-game-model",
"output_text": response_content,
"status": "completed",
"usage": { "input_tokens": 11, "output_tokens": 22, "total_tokens": 33 }
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
body.len(),
body
);
stream
.write_all(response.as_bytes())
.expect("mock llm response");
});
base_url
}
fn spawn_mock_external_canvas_api_server() -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).expect("mock canvas api bind");
let base_url = format!(
@@ -848,10 +905,16 @@ async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_route()
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
fs::write(root.join("memory/session.md"), "短期记忆:用户偏好轻快节奏")
.expect("write session memory");
fs::write(root.join("memory/project.md"), "长期记忆:项目核心是月光厨房")
.expect("write project memory");
fs::write(root.join(PROJECT_BLACKBOARD_MEMORY_PATH), "黑板:角色要先有规范图")
.expect("write blackboard memory");
fs::write(
root.join("memory/project.md"),
"长期记忆:项目核心是月光厨房",
)
.expect("write project memory");
fs::write(
root.join(PROJECT_BLACKBOARD_MEMORY_PATH),
"黑板:角色要先有规范图",
)
.expect("write blackboard memory");
upload_local_asset_at(&root, "hero.png", "image/png", b"fake-png").expect("asset upload");
append_local_conversation_message_at(
&root,
@@ -911,6 +974,81 @@ async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_route()
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn chat_with_game_creator_role_agent_uses_agent_context_and_route() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "月光厨房").expect("project init");
fs::write(root.join("memory/session.md"), "短期记忆:用户偏好轻快节奏")
.expect("write session memory");
fs::write(
root.join("memory/project.md"),
"长期记忆:项目核心是月光厨房",
)
.expect("write project memory");
fs::write(
root.join(PROJECT_BLACKBOARD_MEMORY_PATH),
"黑板:角色要先有规范图",
)
.expect("write blackboard memory");
write_local_agent_memory_at(&root, "art-director", "私有记忆:偏好厚涂剪影")
.expect("write art memory");
append_local_conversation_message_at(
&root,
Some("art-director"),
LocalConversationMessage {
role: "user".to_string(),
content: "上一轮:美术要强化开罗风轮廓".to_string(),
agent_id: None,
},
)
.expect("append agent conversation");
let (sender, receiver) = mpsc::channel();
let base_url = spawn_mock_llm_server_responses_with_capture(
vec!["可以,我会先收敛角色规范图的视觉方向。".to_string()],
Some(sender),
);
let _config_guard = write_test_local_config(format!(
r#"{{
"llm": {{
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"apiKind": "openai_responses"
}},
"agentLlm": {{
"art-director": {{
"apiKey": "art-key",
"baseUrl": {base_url:?},
"model": "art-chat-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
let reply =
chat_with_game_creator_role_agent_at(&root, "art-director", "我要生成一个开罗风格的 dota")
.await
.expect("role chat reply");
assert_eq!(reply.reply_text, "可以,我会先收敛角色规范图的视觉方向。");
let request = receiver
.recv_timeout(Duration::from_secs(1))
.expect("captured role chat llm request");
assert!(request.contains("POST /responses HTTP/1.1"));
assert!(request.contains("art-chat-model"));
assert!(request.contains("美术组 / Director"));
assert!(request.contains("taskId=art-director"));
assert!(request.contains("私有记忆:偏好厚涂剪影"));
assert!(request.contains("上一轮:美术要强化开罗风轮廓"));
assert!(request.contains("我要生成一个开罗风格的 dota"));
assert!(!request.contains("global-model"));
assert!(!request.contains("global-key"));
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn agent_loop_uses_per_agent_llm_overrides() {
let root = unique_project_path();
@@ -998,6 +1136,107 @@ async fn agent_loop_uses_per_agent_llm_overrides() {
fs::remove_dir_all(root).ok();
}
#[tokio::test]
async fn agent_role_briefs_run_same_wave_llm_agents_in_parallel() {
let root = unique_project_path();
init_local_game_project_at(&root, "local-project-draft", "未命名游戏原型")
.expect("init project");
let agenda_path = root.join(".agent/passes/pass-1/agenda.md");
fs::create_dir_all(agenda_path.parent().expect("agenda parent")).expect("agenda dir");
fs::write(&agenda_path, "# Test Agenda\n").expect("write agenda");
let barrier = Arc::new((StdMutex::new(0_usize), Condvar::new()));
let (sender, receiver) = mpsc::channel();
let design_director_base_url = spawn_barrier_mock_llm_server(
"Director 并行 brief".to_string(),
Arc::clone(&barrier),
2,
sender.clone(),
);
let design_foundation_base_url = spawn_barrier_mock_llm_server(
"Gameplay 并行 brief".to_string(),
Arc::clone(&barrier),
2,
sender,
);
let _config_guard = write_test_local_config(format!(
r#"{{
"llm": {{
"apiKey": "global-key",
"baseUrl": "https://global.example.test/v1",
"model": "global-model",
"apiKind": "openai_responses"
}},
"agentLlm": {{
"design-director": {{
"apiKey": "design-director-key",
"baseUrl": {design_director_base_url:?},
"model": "design-director-model",
"apiKind": "openai_responses"
}},
"design-foundation": {{
"apiKey": "design-foundation-key",
"baseUrl": {design_foundation_base_url:?},
"model": "design-foundation-model",
"apiKind": "openai_responses"
}}
}}
}}"#
));
let app_config = load_game_creator_app_config().expect("load app config");
let agenda = AgentPassAgenda {
relative_path: ".agent/passes/pass-1/agenda.md".to_string(),
task_graph_relative_path: ".agent/passes/pass-1/task-graph.json".to_string(),
active_task_ids: vec![
"design-director".to_string(),
"design-foundation".to_string(),
],
carried_task_ids: vec![],
dependency_waves: vec![vec![
"design-director".to_string(),
"design-foundation".to_string(),
]],
repair_focus: vec![],
repair_routes: vec![],
summary: "并行测试".to_string(),
};
let briefs = request_agent_group_briefs_with_client(
&root,
&app_config,
"做一个开罗风格动作游戏",
"短期记忆",
"长期记忆",
"项目黑板",
"Planner 规格",
"Evaluator 反馈",
&agenda,
1,
)
.await
.expect("role briefs");
let captured_requests = receiver
.try_iter()
.filter(|request| request.contains("POST /responses HTTP/1.1"))
.collect::<Vec<_>>();
assert_eq!(captured_requests.len(), 2);
assert!(captured_requests
.iter()
.any(|request| request.contains("design-director-model")));
assert!(captured_requests
.iter()
.any(|request| request.contains("design-foundation-model")));
let design_group = briefs
.iter()
.find(|brief| brief.definition.id == "design")
.expect("design group");
assert!(design_group.markdown.contains("Director 并行 brief"));
assert!(design_group.markdown.contains("Gameplay 并行 brief"));
fs::remove_dir_all(root).ok();
}
#[test]
fn llm_config_check_reports_status_without_leaking_key() {
let missing = check_game_creator_llm_config_values(
+29 -12
View File
@@ -2774,8 +2774,8 @@ export function WorkspaceLauncher({
setRecentWorkspaceStatuses({});
}
function validateAgentChatProjectPath() {
const trimmedProjectPath = agentChatProjectPath.trim();
function validateAgentChatProjectPath(projectPath = agentChatProjectPath) {
const trimmedProjectPath = projectPath.trim();
if (!trimmedProjectPath || !isAbsoluteProjectPath(trimmedProjectPath)) {
setAgentChatStatus('请提供项目绝对路径');
return null;
@@ -2787,10 +2787,12 @@ export function WorkspaceLauncher({
return trimmedProjectPath;
}
function selectedLauncherAgentChatAgent(): LauncherAgentChatAgent | null {
function selectedLauncherAgentChatAgent(
agentId = agentChatSelectedAgentId,
): LauncherAgentChatAgent | null {
return (
launcherAgentChatAgents.find(
(agent) => agent.id === agentChatSelectedAgentId,
(agent) => agent.id === agentId,
) ??
launcherAgentChatAgents[0] ??
null
@@ -2812,16 +2814,20 @@ export function WorkspaceLauncher({
setAgentChatStatus('已取消');
return;
}
setAgentChatProjectPath(selectedPath);
setAgentChatStatus('已选择项目目录');
setAgentChatProjectPath(selectedPath);
void loadAgentChatConversation(agentChatSelectedAgentId, selectedPath);
} catch (error) {
setAgentChatStatus(error instanceof Error ? error.message : String(error));
}
}
async function loadAgentChatConversation() {
const projectPathForChat = validateAgentChatProjectPath();
const agent = selectedLauncherAgentChatAgent();
async function loadAgentChatConversation(
agentId = agentChatSelectedAgentId,
projectPath = agentChatProjectPath,
) {
const projectPathForChat = validateAgentChatProjectPath(projectPath);
const agent = selectedLauncherAgentChatAgent(agentId);
if (!projectPathForChat || !agent) {
return;
}
@@ -2896,6 +2902,18 @@ export function WorkspaceLauncher({
return;
}
setAgentChatMessages(savedUserResult.messages);
setAgentChatStatus('Agent 正在思考');
const reply = await invoke<GameCreatorChatAgentReply>(
'chat_with_game_creator_role_agent',
{
projectPath: projectPathForChat,
agentId: agent.id,
prompt: content,
},
);
if (agentChatLoadVersionRef.current !== saveVersion) {
return;
}
const assistantResult = await invoke<LocalConversationResult>(
'append_local_conversation_message',
{
@@ -2903,7 +2921,7 @@ export function WorkspaceLauncher({
agentId: agent.id,
message: {
role: 'assistant',
content: localAgentConversationReceipt(agent),
content: reply.replyText,
agentId: null,
},
},
@@ -2922,7 +2940,7 @@ export function WorkspaceLauncher({
if (savedUserResult) {
setAgentChatMessages(savedUserResult.messages);
setAgentChatStatus(
`已保存用户消息;Agent 回失败:${
`已保存用户消息;Agent 回失败:${
error instanceof Error ? error.message : String(error)
}`,
);
@@ -3548,8 +3566,7 @@ export function WorkspaceLauncher({
}
onClick={() => {
setAgentChatSelectedAgentId(agent.id);
setAgentChatMessages([]);
setAgentChatStatus('已切换 Agent,请读取历史');
void loadAgentChatConversation(agent.id);
}}
>
<strong>{agent.title}</strong>
@@ -925,6 +925,11 @@ describe('AI 游戏创作 App 界面边界', () => {
})),
};
}
if (command === 'chat_with_game_creator_role_agent') {
return {
replyText: `Agent 回复:${String(args?.prompt ?? '')}`,
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
@@ -954,7 +959,7 @@ describe('AI 游戏创作 App 界面边界', () => {
).not.toBeNull();
expect(
await screen.findByText(
'已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。',
'Agent 回复:请单独评估这个角色设定流程',
),
).not.toBeNull();
expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', {
@@ -971,11 +976,59 @@ describe('AI 游戏创作 App 界面边界', () => {
agentId: 'design-director',
message: {
role: 'assistant',
content:
'已记录给 拆解创作方向。下一次生成会把这条对话作为该 agent 的上下文读取。',
content: 'Agent 回复:请单独评估这个角色设定流程',
agentId: null,
},
});
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_role_agent', {
projectPath: '/tmp/authorized-game',
agentId: 'design-director',
prompt: '请单独评估这个角色设定流程',
});
});
it('loads selected developer agent history immediately after switching agents', async () => {
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'read_local_conversation') {
if (args?.agentId === 'art-director') {
return {
path: '/tmp/authorized-game/.agent/conversations/agents/art-director.jsonl',
agentId: 'art-director',
messages: [
{
schemaVersion: '1',
role: 'assistant',
content: '美术 Agent 历史已加载',
agentId: null,
updatedAt: 1000,
},
],
};
}
return {
path: '/tmp/authorized-game/.agent/conversations/agents/design-director.jsonl',
agentId: args?.agentId,
messages: [],
};
}
throw new Error(`unexpected invoke ${command}`);
},
);
window.__TAURI__ = { core: { invoke } };
renderLauncherAgentChatAt('/?agent-chat');
fireEvent.change(screen.getByLabelText('Agent 聊天项目目录'), {
target: { value: '/tmp/authorized-game' },
});
fireEvent.click(screen.getByRole('button', { name: /确定视觉方向/ }));
expect(await screen.findByText('美术 Agent 历史已加载')).not.toBeNull();
expect(screen.queryByText('暂无对话')).toBeNull();
expect(invoke).toHaveBeenCalledWith('read_local_conversation', {
projectPath: '/tmp/authorized-game',
agentId: 'art-director',
});
});
it('refreshes recent project status before entering the project placeholder', async () => {