补充Agent私有记忆写入

新增单Agent对话面板私有记忆追加入口

补充Tauri本地Agent记忆写入命令和策略测试

同步AI游戏创作App技术方案和共享决策记录
This commit is contained in:
AIGameCreator App
2026-07-03 02:58:53 +08:00
parent 41b23add83
commit ae245e11df
5 changed files with 239 additions and 6 deletions
@@ -1590,6 +1590,18 @@ fn read_local_agent_memory(
read_local_agent_memory_at(root, task_id.trim())
}
#[tauri::command]
fn write_local_agent_memory(
project_path: String,
task_id: String,
content: String,
) -> Result<LocalAgentMemoryResult, String> {
let root = Path::new(project_path.trim());
enforce_project_permission_policy(root, "memory.write")?;
let _lock = acquire_project_write_lock(root, "memory.write")?;
write_local_agent_memory_at(root, task_id.trim(), &content)
}
#[tauri::command]
fn write_local_game_memory(
project_path: String,
@@ -7044,6 +7056,27 @@ fn read_local_agent_memory_at(
}
}
fn write_local_agent_memory_at(
root: &Path,
task_id: &str,
content: &str,
) -> Result<LocalAgentMemoryResult, String> {
let relative_path = agent_role_memory_relative_path_for_task(task_id)?;
let path = resolve_local_project_path(root, &relative_path)?;
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
.map_err(|error| format!("创建 Agent 记忆目录失败:{}: {error}", parent.display()))?;
}
fs::write(&path, content)
.map_err(|error| format!("写入 Agent 记忆失败:{}: {error}", path.display()))?;
Ok(LocalAgentMemoryResult {
task_id: task_id.to_string(),
path: path.to_string_lossy().into_owned(),
content: content.to_string(),
exists: true,
})
}
fn write_local_game_memory_at(
root: &Path,
scope: &str,
@@ -8689,6 +8722,7 @@ fn main() {
delete_local_project_file,
read_local_game_memory,
read_local_agent_memory,
write_local_agent_memory,
write_local_game_memory,
delete_local_game_memory,
read_local_conversation,
@@ -11648,6 +11682,30 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_agent_memory_writes_private_memory_by_task_id() {
let root = unique_project_path();
let written = write_local_agent_memory_at(
&root,
"design-director",
"# 策划 Director 私有记忆\n- 保留轻量像素风\n",
)
.expect("write agent memory");
assert_eq!(written.task_id, "design-director");
assert!(written.path.ends_with("memory/agents/design/director.md"));
assert!(written.exists);
assert_eq!(
written.content,
"# 策划 Director 私有记忆\n- 保留轻量像素风\n"
);
let read = read_local_agent_memory_at(&root, "design-director").expect("read agent memory");
assert_eq!(read.content, "# 策划 Director 私有记忆\n- 保留轻量像素风\n");
fs::remove_dir_all(root).ok();
}
#[test]
fn local_memory_reads_respect_project_policy() {
let root = unique_project_path();
@@ -11677,6 +11735,32 @@ mod tests {
fs::remove_dir_all(root).ok();
}
#[test]
fn local_agent_memory_writes_respect_project_policy() {
let root = unique_project_path();
init_local_game_project_at(&root, "project-1", "像素动作原型").expect("project init");
write_project_permission_policy_at(
&root,
ProjectPermissionPolicy {
denied_commands: vec!["memory.write".to_string()],
confirm_commands: Vec::new(),
},
)
.expect("write policy");
let error = write_local_agent_memory(
root.to_string_lossy().into_owned(),
"design-director".to_string(),
"denied memory".to_string(),
)
.expect_err("agent memory write denied");
assert!(error.contains("项目权限策略拒绝执行:memory.write"));
assert!(!root.join("memory/agents/design/director.md").exists());
fs::remove_dir_all(root).ok();
}
#[test]
fn local_game_memory_rejects_unknown_scope() {
let root = unique_project_path();
+121 -1
View File
@@ -2867,6 +2867,8 @@ function isProjectPolicyConfirmableCommandId(value: string) {
'file.list',
'file.read',
'memory.read',
'memory.write',
'memory.delete',
'asset.register',
'asset.list',
'task.list',
@@ -4111,6 +4113,14 @@ export function App() {
]);
}
}
if (pending.commandId === 'memory.write') {
setMemoryStatus((current) =>
current === '等待确认' ? '已取消保存项目记忆' : current,
);
setAgentMemoryStatus((current) =>
current === '等待确认' ? '已取消保存 Agent 私有记忆' : current,
);
}
if (
pending.commandId === 'file.list' ||
pending.commandId === 'file.read' ||
@@ -4852,6 +4862,100 @@ export function App() {
}
}
async function saveSelectedAgentPrivateMemory(
agent: AgentStatusCard,
content: string,
skipPolicyConfirm = false,
) {
if (!agent || !content || agentConversationSavingRef.current) {
return;
}
const invoke = resolveTauriInvoke();
const nextProjectPath = resolveChatProjectPath(localProject);
if (!invoke) {
setAgentMemoryStatus('需要在 Tauri App 内运行');
return;
}
if (!nextProjectPath) {
setAgentMemoryStatus('请先初始化本地项目');
return;
}
const saveVersion = agentConversationLoadVersionRef.current;
try {
if (!skipPolicyConfirm) {
const policyView = await invoke<ProjectPermissionPolicyView>(
'read_project_permission_policy',
{ projectPath: nextProjectPath },
);
if (policyView.policy.deniedCommands.includes('memory.write')) {
const message = '项目权限策略拒绝执行:memory.write';
markProjectPolicyDenied('memory.write', message);
setAgentMemoryStatus(message);
setCommandLog((current) => [
...current,
'permission.deny memory.write',
]);
setMessages((current) => [
...current,
{ role: 'assistant', text: message },
]);
return;
}
if (policyView.policy.confirmCommands.includes('memory.write')) {
requestProjectPolicyConfirmation(
'memory.write',
nextProjectPath,
`写入 ${agent.title} Agent 私有记忆`,
() => void saveSelectedAgentPrivateMemory(agent, content, true),
);
setAgentMemoryStatus('等待确认');
setMessages((current) => [
...current,
{ role: 'assistant', text: '准备保存 Agent 私有记忆。' },
]);
return;
}
}
} catch (error) {
setAgentMemoryStatus(
error instanceof Error ? error.message : String(error),
);
return;
}
agentConversationSavingRef.current = true;
setAgentConversationSaving(true);
setAgentConversationInput('');
setAgentMemoryStatus('正在保存');
try {
const result = await invoke<LocalAgentMemoryResult>(
'write_local_agent_memory',
{
projectPath: nextProjectPath,
taskId: agent.id,
content: appendMemoryContent(agentMemoryContent, content),
},
);
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentMemoryContent(result.content);
setAgentMemoryStatus(`已追加私有记忆:${result.path}`);
setAgentConversationStatus(`已写入 ${agent.title} 私有记忆。`);
setCommandLog((current) => [...current, 'memory.agent.write']);
} catch (error) {
if (agentConversationLoadVersionRef.current !== saveVersion) {
return;
}
setAgentConversationInput(content);
setAgentMemoryStatus(
error instanceof Error ? error.message : String(error),
);
} finally {
agentConversationSavingRef.current = false;
setAgentConversationSaving(false);
}
}
function handleAgentConversationSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
const agent = selectedAgent;
@@ -4862,6 +4966,15 @@ export function App() {
void saveAgentConversationMessage(agent, content);
}
function handleAgentPrivateMemorySubmit() {
const agent = selectedAgent;
const content = agentConversationInput.trim();
if (!agent || !content || agentConversationSavingRef.current) {
return;
}
void saveSelectedAgentPrivateMemory(agent, content);
}
function showChatHelp() {
setCommandLog((current) => [...current, 'help.show']);
setMessages((current) => [
@@ -5070,7 +5183,7 @@ export function App() {
...current,
{
role: 'assistant',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
text: '当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
},
]);
return;
@@ -10362,6 +10475,13 @@ export function App() {
<button type="submit" disabled={agentConversationSaving}>
</button>
<button
type="button"
disabled={agentConversationSaving}
onClick={handleAgentPrivateMemorySubmit}
>
</button>
</form>
<p className="status-line">{agentConversationStatus}</p>
</section>
@@ -4519,6 +4519,7 @@ describe('AI 游戏创作 App 界面边界', () => {
updatedAt: number;
}> = [];
let agentConversationReadCount = 0;
let agentMemoryContent = '# 策划 Director 私有记忆\n- 保留轻量像素风\n';
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
if (command === 'append_local_permission_log') {
@@ -4549,7 +4550,16 @@ describe('AI 游戏创作 App 界面边界', () => {
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: '# 策划 Director 私有记忆\n- 保留轻量像素风\n',
content: agentMemoryContent,
exists: true,
};
}
if (command === 'write_local_agent_memory') {
agentMemoryContent = String(args?.content ?? '');
return {
taskId: args?.taskId,
path: '/tmp/authorized-game/memory/agents/design/director.md',
content: agentMemoryContent,
exists: true,
};
}
@@ -4733,6 +4743,24 @@ describe('AI 游戏创作 App 界面边界', () => {
agentId: null,
},
});
fireEvent.change(screen.getByLabelText('Agent 对话内容'), {
target: { value: '稳定结论:锅铲音效要跟随连击节奏' },
});
fireEvent.click(
within(agentDialog).getByRole('button', { name: '记入记忆' }),
);
expect(
await screen.findByText('已写入 拆解创作方向 私有记忆。'),
).not.toBeNull();
expect(screen.getByLabelText('Agent 私有记忆').textContent).toContain(
'锅铲音效要跟随连击节奏',
);
expect(invoke).toHaveBeenCalledWith('write_local_agent_memory', {
projectPath: '/tmp/authorized-game',
taskId: 'design-director',
content:
'# 策划 Director 私有记忆\n- 保留轻量像素风\n- 稳定结论:锅铲音效要跟随连击节奏\n',
});
agentMessages.push({
schemaVersion: 'game-creator-conversation.v1',
role: 'assistant',
@@ -10080,7 +10108,7 @@ describe('AI 游戏创作 App 界面边界', () => {
expect(
await screen.findByText(
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
'当前仅支持确认 project.index、project.status、project.checkpoint、project.diff、project.restore、file.list、file.read、memory.read、memory.write、memory.delete、asset.register、asset.list、task.list、agent.run_status、agent.kill、agent.retry、agent.resume、agent.audit、agent.trace_read、preview.status、preview.start、preview.open、preview.stop、canvas.project_sync、canvas.asset_import、canvas.asset_generate、canvas.export_import、conversation.read 和 conversation.write。',
),
).not.toBeNull();
expect(screen.queryByText('project.policy_write')).toBeNull();
@@ -3858,6 +3858,7 @@
- 2026-07-03 调整:主窗口 header 常驻项目摘要只从当前已加载的 manifest / trace 派生任务完成数、ready 数、资产来源分布和最近命令结果;未选择工作区时不显示,不为了摘要额外触发 Tauri 读取或写入,也不把任务、文件、run history 或预览开发面板搬进普通用户窗口。
- 2026-07-03 调整:`/llm-status` 读取到的 agent 级 LLM 配置状态可回填到主窗口 Agent 状态列表和单 Agent 对话头部,显示 provider 类型、模型、流式开关和 API Key 是否已读取;密钥本体仍不能进入聊天、状态列表、manifest、trace 或本地项目文件。
- 2026-07-03 调整:开发窗口日志面板提供 `.agent/logs/command.log``.agent/logs/preview.log``.agent/logs/agent.log` 的只读查看入口,复用 `file.read` 授权策略;普通用户窗口仍只通过聊天 `/read` 和摘要消息查看需要的日志,不新增日志面板。
- 2026-07-03 调整:单 Agent 对话面板允许用户把当前输入手动追加到该 agent 的 `memory/agents/<group>/<role>.md` 私有记忆;写入复用 `memory.write` 项目策略、项目锁和 Tauri 本地目录能力,不把普通对话流水自动混入私有记忆。
- 2026-06-25 调整:本地 HTTP 预览静态 `HEAD` 必须返回与 `GET` 相同的真实 `Content-Length`,但不返回 body;浏览器、图片、音频和视频探测不能拿到 `Content-Length: 0` 的假响应。
- 2026-06-25 调整:普通用户通过聊天输入 `/run` 触发待确认 `game.run_local`,确认后只能复用白名单 `game.static_smoke` 自检当前 `game/index.html`,通过后启动 `127.0.0.1` 本地 HTTP 预览。独立执行 `game.static_smoke` 时如果已有 `.agent/run.latest.json`,必须追加 `Playtest / game.static_smoke` trace step,避免“运行了代码但编排 trace 不可见”。
- 2026-06-25 调整:普通用户通过聊天输入 `/trace` 触发只读 `agent.trace_read`,读取 `.agent/run.latest.json` 并在聊天里摘要 loop 轮次、stopReason、nextStep、active / carry-over 任务、repairRoutes、agent 建议命令和最近 step。trace 面板仍只在开发窗口展示,普通用户窗口不新增面板。
File diff suppressed because one or more lines are too long