支持单Agent真实对话与并行执行
新增单 Agent 真实 LLM 对话命令并按 agentLlm.<agentId> 路由 开发聊天窗口切换 Agent 时自动读取历史并持久化真实回复 Agent loop 按 dependencyWaves 并行执行同一 wave 内角色 brief 补充并行执行、单 Agent 对话和界面回归测试 更新 AI 游戏创作 App 技术方案文档
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user