改造主聊天为正常Agent交互
普通聊天改为调用主聊天 Agent 并读取项目上下文 新增 /generate 和 /draft 显式生成入口 补齐主聊天 agentLlm.chat 路由、测试和文档记忆
This commit is contained in:
@@ -58,6 +58,69 @@ pub(crate) async fn generate_local_game_draft_at(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub(crate) async fn chat_with_game_creator_agent_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
) -> Result<GameCreatorChatAgentReply, String> {
|
||||
let prompt = prompt.trim();
|
||||
if prompt.is_empty() {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
validate_project_root(root)?;
|
||||
|
||||
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 asset_context = render_local_asset_prompt_context(root)?;
|
||||
let conversation_context = render_local_conversation_prompt_context(root, None)?;
|
||||
let context = [
|
||||
("短期记忆", short_memory.as_str()),
|
||||
("长期记忆", long_memory.as_str()),
|
||||
("项目黑板", project_blackboard.as_str()),
|
||||
("资产上下文", asset_context.as_str()),
|
||||
("最近项目对话", 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, "chat");
|
||||
let client = build_game_creator_llm_client_from_llm_config(&llm, "agentLlm.chat")?;
|
||||
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_chat_agent_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!("主聊天 Agent 调用 LLM 失败:{error}"))?;
|
||||
let reply_text = strip_llm_thinking_blocks(response.text.as_str());
|
||||
if reply_text.is_empty() {
|
||||
return Err("主聊天 Agent 未返回内容".to_string());
|
||||
}
|
||||
|
||||
Ok(GameCreatorChatAgentReply { reply_text })
|
||||
}
|
||||
|
||||
pub(crate) fn game_creator_chat_agent_system_prompt() -> &'static str {
|
||||
"你是 Genarrative AI 游戏创作桌面 App 的主聊天 Agent。你要像正常协作型聊天助手一样回应用户,理解需求、澄清不确定点、给出下一步建议,并在需要执行生成、运行、预览、读取文件、写记忆或生成美术时建议用户使用现有 slash 命令。普通聊天中不要假装已经写入文件、生成游戏、调用画板或执行工具;不要输出 JSON;不要泄露密钥;回复保持简洁、具体、中文优先。"
|
||||
}
|
||||
|
||||
pub(crate) fn write_local_game_draft_at(
|
||||
root: &Path,
|
||||
prompt: &str,
|
||||
|
||||
@@ -227,6 +227,16 @@ pub(crate) async fn generate_local_game_draft(
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_agent(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
) -> Result<GameCreatorChatAgentReply, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "conversation.read")?;
|
||||
chat_with_game_creator_agent_at(root, prompt.trim()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn check_game_creator_llm_config() -> GameCreatorLlmConfigStatus {
|
||||
check_game_creator_llm_config_from_config()
|
||||
|
||||
@@ -109,6 +109,12 @@ struct GenerateLocalGameDraftResult {
|
||||
manifest: GameCreationAppManifest,
|
||||
}
|
||||
|
||||
#[derive(Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorChatAgentReply {
|
||||
reply_text: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct GameCreatorAgentProgressEvent {
|
||||
@@ -498,6 +504,7 @@ const DEFAULT_GAME_CREATOR_LLM_API_KIND: &str = "openai_responses";
|
||||
const DEFAULT_CANVAS_SYNC_API_BASE_URL: &str = "http://127.0.0.1:8082";
|
||||
const DEFAULT_GAME_CREATOR_APP_CONFIG_JSON: &str = include_str!("../../game-creator.config.json");
|
||||
const GAME_CREATOR_LLM_MAX_OUTPUT_TOKENS: u32 = 320000;
|
||||
const GAME_CREATOR_CHAT_AGENT_MAX_OUTPUT_TOKENS: u32 = 1800;
|
||||
const GAME_CREATOR_PLANNER_MAX_OUTPUT_TOKENS: u32 = 900;
|
||||
const GAME_CREATOR_ROLE_AGENT_MAX_OUTPUT_TOKENS: u32 = 1200;
|
||||
const GAME_CREATOR_REQUIRED_LLM_AGENT_IDS: [&str; 2] = ["planner", "generator"];
|
||||
@@ -770,6 +777,10 @@ struct GameCreatorLlmAgentStatusDefinition {
|
||||
|
||||
fn game_creator_llm_agent_status_definitions() -> Vec<GameCreatorLlmAgentStatusDefinition> {
|
||||
let mut agents = vec![
|
||||
GameCreatorLlmAgentStatusDefinition {
|
||||
agent_id: "chat".to_string(),
|
||||
label: "主聊天 Agent".to_string(),
|
||||
},
|
||||
GameCreatorLlmAgentStatusDefinition {
|
||||
agent_id: "planner".to_string(),
|
||||
label: "Planner".to_string(),
|
||||
@@ -913,6 +924,7 @@ fn main() {
|
||||
open_local_project_directory,
|
||||
control_agent_run,
|
||||
generate_local_game_draft,
|
||||
chat_with_game_creator_agent,
|
||||
check_game_creator_llm_config,
|
||||
read_game_creator_app_config,
|
||||
write_game_creator_app_config,
|
||||
|
||||
@@ -842,6 +842,75 @@ async fn generate_local_game_draft_sends_asset_context_to_llm() {
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn chat_with_game_creator_agent_uses_project_context_and_chat_llm_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");
|
||||
upload_local_asset_at(&root, "hero.png", "image/png", b"fake-png").expect("asset upload");
|
||||
append_local_conversation_message_at(
|
||||
&root,
|
||||
None,
|
||||
LocalConversationMessage {
|
||||
role: "user".to_string(),
|
||||
content: "上一轮:主角必须挥舞月光锅铲".to_string(),
|
||||
agent_id: None,
|
||||
},
|
||||
)
|
||||
.expect("append project 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": {{
|
||||
"chat": {{
|
||||
"apiKey": "chat-key",
|
||||
"baseUrl": {base_url:?},
|
||||
"model": "chat-model",
|
||||
"apiKind": "openai_responses"
|
||||
}}
|
||||
}}
|
||||
}}"#
|
||||
));
|
||||
|
||||
let reply = chat_with_game_creator_agent_at(&root, "我要生成一个月光厨师角色图")
|
||||
.await
|
||||
.expect("chat reply");
|
||||
|
||||
assert_eq!(reply.reply_text, "可以,我们先把角色规范图定清楚。");
|
||||
let request = receiver
|
||||
.recv_timeout(Duration::from_secs(1))
|
||||
.expect("captured chat llm request");
|
||||
assert!(request.contains("POST /responses HTTP/1.1"));
|
||||
assert!(request.contains("chat-model"));
|
||||
assert!(request.contains("我要生成一个月光厨师角色图"));
|
||||
assert!(request.contains("短期记忆:用户偏好轻快节奏"));
|
||||
assert!(request.contains("长期记忆:项目核心是月光厨房"));
|
||||
assert!(request.contains("黑板:角色要先有规范图"));
|
||||
assert!(request.contains("上一轮:主角必须挥舞月光锅铲"));
|
||||
assert!(request.contains("# 本地项目资产"));
|
||||
assert!(request.contains("hero.png"));
|
||||
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();
|
||||
|
||||
@@ -89,6 +89,10 @@ interface GenerateLocalGameDraftResult {
|
||||
manifest: GameCreationAppManifest;
|
||||
}
|
||||
|
||||
interface GameCreatorChatAgentReply {
|
||||
replyText: string;
|
||||
}
|
||||
|
||||
interface GameCreatorLlmConfigStatus {
|
||||
configured: boolean;
|
||||
apiKeyPresent: boolean;
|
||||
@@ -1861,6 +1865,8 @@ const capabilityAreaLabels: Record<
|
||||
};
|
||||
|
||||
const chatCommandHelp = [
|
||||
'直接输入普通文本:和主聊天 Agent 对话',
|
||||
'/generate 创作想法:生成本地游戏草案',
|
||||
'/project /绝对路径:设置本地项目目录',
|
||||
'/config:打开运行时配置',
|
||||
'/llm-status:检查 LLM 配置',
|
||||
@@ -1984,6 +1990,9 @@ function missingChatCommandArgumentMessage(prompt: string) {
|
||||
switch (prompt) {
|
||||
case '/project':
|
||||
return '格式:/project /绝对路径';
|
||||
case '/generate':
|
||||
case '/draft':
|
||||
return '格式:/generate 创作想法';
|
||||
case '/diff':
|
||||
return '格式:/diff checkpoint-id';
|
||||
case '/restore':
|
||||
@@ -8901,6 +8910,7 @@ export function App() {
|
||||
const [preview, setPreview] = useState<LocalPreviewResult | null>(null);
|
||||
const [previewStatus, setPreviewStatus] = useState('未启动');
|
||||
const [chatInput, setChatInput] = useState('');
|
||||
const [chatAgentBusy, setChatAgentBusy] = useState(false);
|
||||
const chatInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [assetStatus, setAssetStatus] = useState('未上传');
|
||||
const [uploadedAssets, setUploadedAssets] = useState<
|
||||
@@ -10435,6 +10445,34 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (prompt.startsWith('/generate ') || prompt.startsWith('/draft ')) {
|
||||
if (!requireChatProjectForUserAction()) {
|
||||
return;
|
||||
}
|
||||
const generationPrompt = prompt
|
||||
.slice(prompt.startsWith('/generate ') ? '/generate '.length : '/draft '.length)
|
||||
.trim();
|
||||
if (!generationPrompt) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: '格式:/generate 创作想法' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
queuePendingCommand({
|
||||
id: 'game.generate_draft',
|
||||
prompt: generationPrompt,
|
||||
});
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
text: `准备生成本地游戏草案:${generationPrompt}`,
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (prompt === '/status') {
|
||||
void executeProjectStatus(true);
|
||||
return;
|
||||
@@ -12346,14 +12384,7 @@ export function App() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!requireChatProjectForUserAction()) {
|
||||
return;
|
||||
}
|
||||
queuePendingCommand({ id: 'game.generate_draft', prompt });
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: `准备生成本地游戏草案:${prompt}` },
|
||||
]);
|
||||
void executeChatAgentReply(prompt);
|
||||
}
|
||||
|
||||
async function executeLlmConfigStatus() {
|
||||
@@ -12464,6 +12495,64 @@ export function App() {
|
||||
]);
|
||||
}
|
||||
|
||||
async function executeChatAgentReply(
|
||||
prompt: string,
|
||||
confirmedPolicyCommands: GameCreationAppCommandDescriptor['id'][] = [],
|
||||
) {
|
||||
const nextProjectPath = requireChatProjectForUserAction();
|
||||
if (!nextProjectPath) {
|
||||
return;
|
||||
}
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: '需要在 Tauri App 内运行。' },
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
!confirmedPolicyCommands.includes('conversation.read') &&
|
||||
(await queueProjectPolicyConfirmationIfNeeded(
|
||||
invoke,
|
||||
'conversation.read',
|
||||
nextProjectPath,
|
||||
`读取 ${nextProjectPath} 的项目上下文并发送给主聊天 Agent`,
|
||||
'准备发送给主聊天 Agent。',
|
||||
() => void executeChatAgentReply(prompt, ['conversation.read']),
|
||||
))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setChatAgentBusy(true);
|
||||
setCommandLog((current) => [...current, 'agent.chat']);
|
||||
const result = await invoke<GameCreatorChatAgentReply>(
|
||||
'chat_with_game_creator_agent',
|
||||
{ projectPath: nextProjectPath, prompt },
|
||||
);
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{ role: 'assistant', text: result.replyText },
|
||||
]);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
if (isRuntimeConfigMissingError(message)) {
|
||||
setRuntimeConfigOpen(true);
|
||||
}
|
||||
setMessages((current) => [
|
||||
...current,
|
||||
{
|
||||
role: 'assistant',
|
||||
text: message,
|
||||
},
|
||||
]);
|
||||
} finally {
|
||||
setChatAgentBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function executeGameDraft(prompt: string) {
|
||||
const invoke = resolveTauriInvoke();
|
||||
if (!invoke) {
|
||||
@@ -17057,7 +17146,9 @@ export function App() {
|
||||
placeholder="例如:像素风横版动作小游戏"
|
||||
onChange={(event) => setChatInput(event.currentTarget.value)}
|
||||
/>
|
||||
<button type="submit">发送</button>
|
||||
<button type="submit" disabled={chatAgentBusy}>
|
||||
{chatAgentBusy ? '思考中' : '发送'}
|
||||
</button>
|
||||
</form>
|
||||
<section className="agent-status-pane" aria-label="Agent 状态">
|
||||
<header className="panel-header">
|
||||
|
||||
@@ -1574,6 +1574,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -8363,6 +8371,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -8901,6 +8914,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -9511,6 +9529,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -9613,9 +9636,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
submitChat('/project /tmp/authorized-game');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
|
||||
).not.toBeNull();
|
||||
expect(await screen.findByText('已打开:/tmp/authorized-game')).not.toBeNull();
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('read_project_permission_policy', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
@@ -9904,6 +9925,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -9964,9 +9993,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await waitFor(() => {
|
||||
expect(savedContents).toEqual([
|
||||
'第一条创作需求',
|
||||
'准备生成本地游戏草案:第一条创作需求',
|
||||
'主聊天回复:第一条创作需求',
|
||||
'第二条创作需求',
|
||||
'准备生成本地游戏草案:第二条创作需求',
|
||||
'主聊天回复:第二条创作需求',
|
||||
]);
|
||||
});
|
||||
expect(
|
||||
@@ -10003,6 +10032,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -10053,7 +10087,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await waitFor(() => {
|
||||
expect(savedContents).toEqual([
|
||||
'需要确认保存的需求',
|
||||
'准备生成本地游戏草案:需要确认保存的需求',
|
||||
'主聊天回复:需要确认保存的需求',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10086,6 +10120,11 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return {
|
||||
path: '/tmp/authorized-game/.agent/conversations/project.jsonl',
|
||||
@@ -10149,9 +10188,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await waitFor(() => {
|
||||
expect(savedContents).toEqual([
|
||||
'先不保存的需求',
|
||||
'准备生成本地游戏草案:先不保存的需求',
|
||||
'主聊天回复:先不保存的需求',
|
||||
'继续补充一条',
|
||||
'准备生成本地游戏草案:继续补充一条',
|
||||
'主聊天回复:继续补充一条',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -10181,6 +10220,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
manifest,
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command === 'read_local_conversation') {
|
||||
return makeConversationResult();
|
||||
}
|
||||
@@ -10223,9 +10270,9 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await waitFor(() => {
|
||||
expect(savedContents).toEqual([
|
||||
'第一条创作需求',
|
||||
'准备生成本地游戏草案:第一条创作需求',
|
||||
'主聊天回复:第一条创作需求',
|
||||
'第二条创作需求',
|
||||
'准备生成本地游戏草案:第二条创作需求',
|
||||
'主聊天回复:第二条创作需求',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -18373,7 +18420,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
expect(invoke).toHaveBeenCalledWith('check_game_creator_llm_config');
|
||||
});
|
||||
|
||||
it('uses the authorized local project path for chat generation confirmation', async () => {
|
||||
it('uses the authorized local project path for ordinary chat agent replies', async () => {
|
||||
const manifest = createGameCreationAppManifest(
|
||||
'local-project-draft',
|
||||
'未命名游戏原型',
|
||||
@@ -18397,6 +18444,14 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
messages: [],
|
||||
};
|
||||
}
|
||||
if (command === 'read_project_permission_policy') {
|
||||
return emptyProjectPolicy();
|
||||
}
|
||||
if (command === 'chat_with_game_creator_agent') {
|
||||
return {
|
||||
replyText: `主聊天回复:${String(args?.prompt ?? '')}`,
|
||||
};
|
||||
}
|
||||
if (command !== 'init_local_game_project') {
|
||||
throw new Error(`unexpected invoke ${command}`);
|
||||
}
|
||||
@@ -18414,19 +18469,23 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
submitChat('/project /tmp/authorized-game');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
|
||||
).not.toBeNull();
|
||||
expect(await screen.findByText('已打开:/tmp/authorized-game')).not.toBeNull();
|
||||
await act(async () => {
|
||||
submitChat('做一个反弹弹幕厨房游戏');
|
||||
});
|
||||
|
||||
expect(screen.getByText('game.generate_draft')).not.toBeNull();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'调用 LLM Planner / Generator,编排 6 组角色 brief,写入 /tmp/authorized-game/game、assets、memory、exports,通过 Evaluator 和自检后启动本地 HTTP 预览并交给外部浏览器',
|
||||
),
|
||||
await screen.findByText('主聊天回复:做一个反弹弹幕厨房游戏'),
|
||||
).not.toBeNull();
|
||||
expect(screen.queryByText('game.generate_draft')).toBeNull();
|
||||
expect(invoke).toHaveBeenCalledWith('chat_with_game_creator_agent', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
prompt: '做一个反弹弹幕厨房游戏',
|
||||
});
|
||||
expect(invoke).not.toHaveBeenCalledWith(
|
||||
'generate_local_game_draft',
|
||||
expect.anything(),
|
||||
);
|
||||
await waitFor(() => {
|
||||
expect(invoke).toHaveBeenCalledWith('append_local_conversation_message', {
|
||||
projectPath: '/tmp/authorized-game',
|
||||
@@ -18841,7 +18900,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
|
||||
).not.toBeNull();
|
||||
|
||||
submitChat('做一个反弹弹幕厨房游戏');
|
||||
submitChat('/generate 做一个反弹弹幕厨房游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
@@ -18953,7 +19012,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
).not.toBeNull();
|
||||
invoke.mockClear();
|
||||
|
||||
submitChat('做一个厨房弹幕游戏');
|
||||
submitChat('/generate 做一个厨房弹幕游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(await screen.findByText('生成结果项目路径无效')).not.toBeNull();
|
||||
@@ -19034,7 +19093,7 @@ describe('AI 游戏创作 App 界面边界', () => {
|
||||
await screen.findByText('已设置本地项目:/tmp/authorized-game'),
|
||||
).not.toBeNull();
|
||||
|
||||
submitChat('做一个厨房弹幕游戏');
|
||||
submitChat('/generate 做一个厨房弹幕游戏');
|
||||
fireEvent.click(screen.getByRole('button', { name: '确认' }));
|
||||
|
||||
expect(
|
||||
|
||||
@@ -3996,6 +3996,7 @@
|
||||
- 2026-06-25 调整,2026-06-30 更新:美术组 `Asset` 和音乐组 `SFX` 角色在 loop 中读取 `.agent/manifest.json`;当本地项目还没有对应类型的 `canvas` 来源资产时,角色 step 会追加 `agent.tool.suggest.canvas.project_sync` toolCall:美术组需要 `image/*` 或 `application/vnd.genarrative.image-sequence`,音乐组需要 `audio/*`。美术组在未配置 `editorApi.apiKey` 或平台生图失败时仍只给出同步建议;音乐组不调用图片生成接口,只建议同步已有音频资源。
|
||||
- 2026-06-24 调整:同一本地项目多次 `game.generate_draft` 必须追加 `memory/session.md` 与 `memory/project.md`,不得覆盖历史对话和创作目标记录。
|
||||
- 2026-07-01 调整:AI 游戏创作 App 在 `memory/session.md` 与 `memory/project.md` 之外新增项目级黑板 `memory/blackboard.md`,只记录重要跨 agent 决策、依赖和风险摘要;每个角色 agent 拥有私有记忆 `memory/agents/<group>/<role>.md`。角色 brief 必须读取自己的私有记忆和项目黑板;`game.generate_draft` 通过 Evaluator 与 `game.static_smoke` 后,追加项目黑板摘要和各角色成功产出摘要,不得覆盖既有记忆。失败 run 仍只保留 trace 和 pass 快照,不写最终记忆摘要。
|
||||
- 2026-07-06 调整:AI 游戏创作 App 主聊天普通文本改为进入主聊天 Agent,而不是直接排队 `game.generate_draft`;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,支持 `agentLlm.chat` 单独 provider 配置,但只做自然语言交互、澄清和 slash 命令建议,不写项目、不运行工具、不伪装生成结果。显式 `/generate <创作想法>` 或 `/draft <创作想法>` 才进入 `game.generate_draft` 待确认流。
|
||||
- 2026-07-01 调整:AI 游戏创作 App 借鉴 Godcoder 的本地工程护栏,但只收敛到五项本地机制:`ArtifactWriter` 写入前 checkpoint、写入后 diff、用户确认 restore;进入 LLM 前过滤密钥和本机配置痕迹;`.agent/agent.db` 继续作为轻量 JSONL 项目索引,`/index` 额外刷新 `.agent/project.index.json`;同一项目写入通过 `.agent/project.lock` 串行化;`.agent/policy.json` 记录项目级命令拒绝 / 确认策略。v1 不引入通用 IDE 插件、云工作区、SQLite 或任意 shell 代理。
|
||||
- 2026-07-03 调整:主窗口最近 checkpoint 列表必须直接展示 checkpoint id、文件数、大小和创建时间,并提供直接对比、填入 `/diff`、确认回滚和填入 `/restore` 的轻量操作;回滚仍走 `project.restore` 确认卡,不在列表按钮中直接写项目文件。
|
||||
- 2026-06-24 调整:普通用户通过聊天输入 `/help` 发现可用内置命令;命令发现必须留在聊天消息里,不得因此暴露开发面板。
|
||||
|
||||
@@ -251,7 +251,7 @@ game-project/
|
||||
- 主窗口的 agent 状态列表以 manifest 角色任务为底表,再合并最近 run trace 中 `taskGraph.tasks` 的任务状态、同 taskId / group / role 的最新 step 状态、输入输出路径、错误摘要、lifecycleStatus 和 `activeTaskIds` / `carriedTaskIds` / `readyTaskIds` 编排标记;如果 trace 缺失或过期,只展示 manifest 的静态任务状态和“暂无最近运行证据”。
|
||||
- 共享契约和 `platform-agent` 会按任务依赖与 `completed` 状态计算当前可执行任务,作为 v1 的最小编排选择器;每轮 `Orchestrator` 的 activeTaskIds、carriedTaskIds、repairRoutes 和 dependencyWaves 由 `platform-agent` 纯编排内核产出,`apps/ai-game-creator-shell` 只负责写入 `.agent/passes/pass-N/` 和执行本地工具;`Evaluator` 会在 `.agent/findings.md` 写出 `## Repair Routes` JSON,下一轮编排优先采用该结构化 taskIds,解析不到时才退回关键词路由;返工路由会按任务图自动扩展下游影响任务,例如美术资产变化会继续触发程序预览和运营包装重算。
|
||||
- `game.generate_draft` 使用 OpenAI-compatible LLM 配置生成结构化 JSON 草案,发布 App 的配置项来自 Tauri 应用配置目录中的 `game-creator.config.json`:`llm.apiKey`、`llm.baseUrl`、`llm.model`、`llm.apiKind`、`llm.stream`、`llm.requestTimeoutMs`、`llm.maxRetries`、`llm.retryBackoffMs`;默认 API kind 为 `openai_responses`,可设 `llm.apiKind=openai_chat` 切回旧 Chat Completions 兼容网关,或 `llm.apiKind=anthropic` 走 Anthropic Messages;`llm.stream=true` 时 Planner 和 Generator 使用流式请求;缺少配置或模型返回非法 JSON 时直接失败,不静默回退固定模板。
|
||||
- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.<taskId>` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。
|
||||
- 主窗口“配置”面板和聊天 `/config` 命令读写 Tauri 应用配置目录中的 `game-creator.config.json`,覆盖 LLM API Key、base URL、模型、API 类型、流式请求、超时、重试和画板 External API 配置;主聊天 Agent、Planner、Orchestrator、Generator、Evaluator 和 16 个角色 agent 都可在 `agentLlm` 中单独覆盖 API Key、base URL、模型、API 类型和流式请求,空项继承全局 LLM 配置;主聊天 Agent 使用 `agentLlm.chat`,默认生成链路仍只让 Planner / Generator 调 LLM,配置了 `agentLlm.<taskId>` 的角色 agent 会改用自己的 provider 生成 brief,未配置的角色 agent 继续使用本地 brief;生成游戏、主聊天或平台美术时如果返回 LLM / editorApi 缺配置错误,主窗口自动打开同一个运行时配置弹窗;API Key 输入框使用密码字段并关闭自动填充,数值项在 UI 层夹住下限,Rust 写配置时也拒绝过低超时,保存时只写运行时配置文件,不写仓库模板、本地项目、trace 或 manifest。
|
||||
- 聊天输入 `/llm-status` 会触发只读 `llm.config_check`,确认全局 LLM 以及各 agent resolved 后的 base_url、model、API 类型和 API Key 是否已从客户端配置读取;状态消息不会显示或保存 API Key;当前生成链路只要求 Planner / Generator 就绪。`/llm-routes` 复用同一检查结果,但输出按 agent 展开的路由清单和缺口摘要,用于确认哪些 agent 解析后走全局路由、哪些 agent 走单独 provider。
|
||||
- `game.generate_draft` 的 LLM JSON 必须包含 `handoffs` 数组,覆盖 `design`、`balance`、`art`、`audio`、`code`、`publishing` 6 个专业组;每组必须给出 role、summary、outputs 和 next,缺组或交接内容不完整会判定为模型输出无效并进入返工。
|
||||
- `game.generate_draft` 的真实生成路径使用最小 Planner / Orchestrator / 组内角色 agent / Generator / Evaluator loop:Planner 写 `.agent/spec.md`;每轮 Orchestrator 先写 `.agent/passes/pass-N/agenda.md` 和 `.agent/passes/pass-N/task-graph.json`,首轮全量调度 16 个角色任务,返工轮按 `.agent/findings.md` 生成结构化 `repairRoutes`,重跑命中问题的角色任务及其下游依赖任务,其余角色 brief 从上一轮 carry-over;`task-graph.json` 记录 activeTaskIds、carriedTaskIds、repairFocus、repairRoutes 和按依赖排序的 dependencyWaves;每个角色 brief 必须读取自己的私有记忆 `memory/agents/<group>/<role>.md` 和项目黑板 `memory/blackboard.md`,写入 `.agent/passes/pass-N/groups/<group>/*.md`,再汇总为 `.agent/passes/pass-N/groups/*.md`;Generator 必须读取用户需求、记忆、`.agent/spec.md`、本轮 `agenda.md`、`task-graph.json`、`.agent/findings.md` 和 6 组汇总 brief 后返回结构化 JSON;每轮会把 Generator 草案拆成 6 组交接快照,写入 `.agent/passes/pass-N/`;Evaluator 做质量评审并写 `.agent/findings.md`,通过后才进入 `game.static_smoke` 静态自检和预览试玩。
|
||||
@@ -271,7 +271,7 @@ game-project/
|
||||
- `game.generate_draft` 写入最终产物后会复用白名单受限命令 `game.static_smoke` 做一次生成后自检,至少检查 `game/index.html` 包含 canvas、canvas 渲染上下文、绘制调用、主循环、非空输入监听、明确目标、失败或胜利状态和重开路径,且不使用远程资源、`eval`、`new Function`、`localStorage`、`fetch`、`WebSocket` 或 `ServiceWorker`,也不得包含固定星核传送门模板词、纯按钮计分模板或 `TODO` / `待实现` / `这里省略` 等未完成实现;画板资源占位引用允许出现在 asset id 或说明中,并把该工具调用写入 `.agent/run.latest.json` 与 `.agent/logs/command.log`;自检失败则本次命令失败,不继续启动预览。
|
||||
- `ArtifactWriter` step 使用 `file.write.local_artifacts` 工具调用记录最终写入的 `memory/`、`memory/agents/`、`game/`、`assets/`、`exports/` 和 `.agent/manifest.json` 路径;写入完成后 `nextStep` 指向 `game.static_smoke`。
|
||||
- `preview.start` / `preview.stop` 会追加 `.agent/logs/preview.log`,并在 `.agent/run.latest.json` 已存在时追加 `Preview` step 和 `preview.*` toolCall,记录本地 HTTP 预览 URL 与停止事件;单全局本地预览被新项目替换时,会 best-effort 把旧项目 manifest、preview log 和 trace 记录为 stopped,避免旧项目残留 running;本地 HTTP server 的 `/` 映射到 `game/index.html`,只允许读取 canonical 后仍位于项目真实 `game/` 或真实 `assets/` 下的文件,拒绝 `memory/`、`.agent/`、`exports/`、`..`、一级 `game` / `assets` 符号链接目录和内部符号链接越界,并为常见图片、音频、视频和 Web 资源返回对应 MIME;静态 `HEAD` 返回真实 `Content-Length` 但不返回 body,确保浏览器和媒体资源探测可用;上传和画板回流资产可被生成游戏引用但不会暴露记忆或 trace;没有 run trace 的手动预览启动不阻断。
|
||||
- 聊天输入会生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents/<group>/<role>.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read <path>` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。
|
||||
- 普通聊天文本进入主聊天 Agent;主聊天 Agent 读取短期记忆、长期记忆、项目黑板、最近项目对话和本地资产摘要作为背景,只做自然语言交互、澄清、建议和 slash 命令引导,不写项目、不运行工具、不伪装已经生成产物,也不直接触发 `game.generate_draft`。用户显式输入 `/generate <创作想法>` 或 `/draft <创作想法>` 时才生成待确认的 `game.generate_draft` 内置命令;用户确认后,正式用户聊天会实时展示 Planner LLM、Orchestrator、6 组角色 brief、Generator LLM、Evaluator 质量评审、ArtifactWriter 和 `game.static_smoke` 的进度,再把 LLM 返回的结构化草案写入短期记忆 `memory/session.md`、长期记忆 `memory/project.md`、项目黑板 `memory/blackboard.md`、角色私有记忆 `memory/agents/<group>/<role>.md`、设计草案 `game/game_design.md`、数值配置 `game/balance.json`、美术清单 `assets/manifest.art.json`、音乐音效清单 `assets/manifest.audio.json`、发布包装草案 `exports/README.md` 和可运行 `game/index.html`。生成完成后,普通聊天消息会自动展示最近一次 Agent loop 的 Run、LLM 对话、轮次、工具调用、active / carry-over 任务、返工焦点、编排轮次、最近步骤、画板同步建议命令和本地产物快照;单 agent 最近证据中的输入 / 输出路径可一键填入 `/read <path>` 草稿,画板同步建议可一键填入 `/sync-canvas-project ` 草稿,再由用户补齐参数并走原确认流;完整证据仍由 `/trace` 读取同一份 `.agent/run.latest.json`。
|
||||
- `game.generate_draft` 的 `game/index.html` 必须是可试玩原型,至少包含输入、主循环、目标、失败或胜利状态和重开路径;不能只输出按钮计分或纯展示页。
|
||||
- `game.generate_draft` 会校验 LLM 输出:`balance`、美术清单和音乐清单必须是 JSON object,`gameHtml` 必须是自包含 HTML、包含 `canvas` 与 `requestAnimationFrame`,不得加载远程脚本或资源,不得使用 `eval` / `new Function` / `localStorage` / `fetch` / `WebSocket` / `ServiceWorker`,不得把包含 `<` / `>` 的用户输入原样写入 HTML。
|
||||
- 同一项目内多次 `game.generate_draft` 不覆盖记忆文件,而是继续追加短期对话记录、长期创作目标记录、项目黑板摘要和角色私有摘要,保留用户迭代历史。
|
||||
|
||||
Reference in New Issue
Block a user