Merge remote-tracking branch 'refs/remotes/origin/master' into feat/gptimage2to2.5
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m12s
Project CI / Backend tests (pull_request) Successful in 9m12s
Project CI / Native shell tests (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 7m1s
Project CI / Repository checks (pull_request) Successful in 5m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m4s
Project CI / AI game creator shell Rust crates (pull_request) Successful in 3m2s
Project CI / AI game creator shell Rust smoke (pull_request) Successful in 3m55s
Project CI / AI game creator shell Rust lane 2/2 (pull_request) Successful in 8m46s
Project CI / AI game creator shell Rust lane 1/2 (pull_request) Successful in 10m12s
Project CI / Backend tests (pull_request) Successful in 9m12s
Project CI / Native shell tests (pull_request) Successful in 10m32s
Project CI / Frontend tests (pull_request) Successful in 7m1s
Project CI / Repository checks (pull_request) Successful in 5m43s
Project CI / AI game creator shell web tests (pull_request) Successful in 6m4s
# Conflicts: # docs/project-memory/shared-memory/decision-log.md
This commit is contained in:
@@ -203,6 +203,14 @@ _Avoid_: mock 先行堆积、前后端各自发散、先做排行榜 UI
|
||||
|
||||
## 项目开发对话(DirectProject)
|
||||
|
||||
**DirectProject 专属聊天模块**:
|
||||
AGC 普通项目聊天的独立容器,拥有 DirectProject 的聊天状态、运行态订阅、历史读取、发送队列、附件和中止交互,并把聊天投影交给专属表现层渲染;它不承接 Supervisor、Design Agent 或 Planning V2 的运行态。
|
||||
_Avoid_: 把 DirectProject 作为项目总控聊天的一个布尔分支、把四种 Agent 会话抽象成同一事实源
|
||||
|
||||
**项目工作台布局**:
|
||||
承载本地项目的资源工作区、项目级工具和独立聊天产品路径的外层界面;布局拥有跨面板的账户/钱包入口,聊天模块只负责项目对话,不嵌套账户展示。
|
||||
_Avoid_: 把钱包入口塞进聊天设置、让聊天组件拥有工作台级账户状态
|
||||
|
||||
**项目对话历史**:
|
||||
AGC 本地项目内 Codex 原始对话条目的持久集合,是聊天展示、工具卡片和线程恢复注入的唯一持久事实源。
|
||||
_Avoid_: 会话缓存、展示态历史、按 UI 需要另存的对话副本
|
||||
|
||||
@@ -81,14 +81,12 @@ test('模板批量导入走 multipart,不预设 JSON Content-Type', async () =
|
||||
},
|
||||
],
|
||||
};
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
const fetchMock = vi.fn().mockImplementation(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ ok: true, data: imported }), {
|
||||
status: 200,
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const form = new FormData();
|
||||
|
||||
@@ -107,10 +107,6 @@ const appInvokeSources = readSourceFiles(
|
||||
new URL('../src/', import.meta.url),
|
||||
new Set(['.ts', '.tsx']),
|
||||
);
|
||||
const appEntrypointSource = fs.readFileSync(
|
||||
new URL('../src/main.tsx', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const tauriHandlerSource = fs.readFileSync(
|
||||
new URL('../src-tauri/src/main.rs', import.meta.url),
|
||||
'utf8',
|
||||
@@ -135,6 +131,45 @@ const rustSharedContractSource = fs.readFileSync(
|
||||
);
|
||||
const allowedUncalledTauriCommands = [
|
||||
'append_direct_project_conversation_message',
|
||||
// Supervisor 调试窗口、开发者面板、专业 Agent 对话与旧命令聊天的前端调用方已随
|
||||
// Supervisor 前端链路整体删除;命令本身仍注册在 Rust 侧并由 native Runtime、CLI
|
||||
// swarm 与 Rust 测试使用,保留 present,仅不再出现在 App 前端源码里。
|
||||
'answer_game_creator_agent_runtime_user_input',
|
||||
'cancel_game_creator_agent_runtime_task',
|
||||
'chat_with_game_creator_role_agent',
|
||||
'chat_with_game_creator_role_agent_stream',
|
||||
'check_game_creator_llm_config',
|
||||
'confirm_game_creator_agent_runtime_task',
|
||||
'diff_local_project_checkpoint',
|
||||
'get_game_creation_agent_capabilities',
|
||||
'get_limited_local_commands',
|
||||
'list_local_project_export_packages',
|
||||
'read_game_creator_agent_runtime',
|
||||
'read_local_agent_memory',
|
||||
'read_local_game_memory',
|
||||
'reject_game_creator_agent_runtime_task',
|
||||
'retry_game_creator_agent_runtime_task',
|
||||
'schedule_game_creator_agent_ready_tasks',
|
||||
'start_game_creator_agent_runtime_task',
|
||||
'steer_game_creator_agent_runtime_task',
|
||||
'write_local_agent_memory',
|
||||
'write_local_game_memory',
|
||||
'write_local_project_file',
|
||||
// Agent 运行时会话 / 目标 / 协作命令由 native 侧与 CLI swarm 驱动,前端没有调用方。
|
||||
'archive_game_creator_agent_session',
|
||||
'clear_game_creator_agent_goal',
|
||||
'compact_game_creator_agent_runtime_context',
|
||||
'confirm_retry_game_creator_agent_runtime_task',
|
||||
'create_game_creator_agent_session',
|
||||
'edit_game_creator_agent_goal',
|
||||
'fork_game_creator_agent_session',
|
||||
'list_game_creator_agent_sessions',
|
||||
'pause_game_creator_agent_goal',
|
||||
'read_game_creator_agent_goal',
|
||||
'resume_game_creator_agent_goal',
|
||||
'set_active_game_creator_agent_session',
|
||||
'start_game_creator_agent_goal',
|
||||
'start_game_creator_supervisor_runtime_task',
|
||||
// TODO: Remove the retired binding command after the legacy runtime path is removed.
|
||||
'bind_components',
|
||||
'chat_with_game_creator_agent',
|
||||
@@ -164,6 +199,26 @@ const allowedUncalledTauriCommands = [
|
||||
'call_agc_plugin',
|
||||
'read_agc_plugin_panel',
|
||||
'set_agc_plugin_enabled',
|
||||
// 下面这些命令的调用方只有随 Project Supervisor 前端链路一起删除的旧命令聊天入口;
|
||||
// 现在 App 前端、工作台与策划聊天都没有接线(检查点 / 恢复 / 索引 / 导出包 /
|
||||
// 画板同步 / 素材登记 / 权限策略 / 本地草案 / 平台美术),Rust 侧只剩注册与实现,
|
||||
// `*_at` helper 仍由 Rust 用例覆盖。接回新入口还是删除属于 native 能力取舍,先按
|
||||
// native-only 登记,避免孤儿检查一直报错。
|
||||
// 预览不在本清单:`activate_local_game_preview` 已按 ADR 回接到 App 的「运行」入口。
|
||||
'build_local_project_index',
|
||||
'control_agent_run',
|
||||
'create_local_project_checkpoint',
|
||||
'export_local_project_package',
|
||||
'generate_local_game_draft',
|
||||
'generate_platform_art_asset',
|
||||
'import_canvas_asset',
|
||||
'import_canvas_export',
|
||||
'open_canvas_project',
|
||||
'register_local_asset',
|
||||
'restore_local_project_checkpoint',
|
||||
'run_limited_local_command',
|
||||
'sync_canvas_project_assets',
|
||||
'write_project_permission_policy',
|
||||
];
|
||||
const sourceExtensions = new Set([
|
||||
'.json',
|
||||
@@ -1470,13 +1525,7 @@ const eventCapability = JSON.parse(
|
||||
);
|
||||
const eventCapabilityWindows = new Set(eventCapability.windows ?? []);
|
||||
const eventCapabilityPermissions = new Set(eventCapability.permissions ?? []);
|
||||
for (const windowLabel of [
|
||||
'client',
|
||||
'developer',
|
||||
'main',
|
||||
'launcher',
|
||||
'supervisor-chat',
|
||||
]) {
|
||||
for (const windowLabel of ['client', 'main', 'launcher']) {
|
||||
if (!eventCapabilityWindows.has(windowLabel)) {
|
||||
throw new Error(
|
||||
`AI game creator shell event capability missing window: ${windowLabel}`,
|
||||
@@ -1859,20 +1908,6 @@ if (
|
||||
);
|
||||
}
|
||||
|
||||
for (const snippet of [
|
||||
'import.meta.env.DEV',
|
||||
'supervisorChatMode',
|
||||
'supervisorChatOnly',
|
||||
'open_project_supervisor_chat_window',
|
||||
'index.html?supervisor-chat&projectPath=',
|
||||
]) {
|
||||
if (!`${appEntrypointSource}\n${tauriRustSource}`.includes(snippet)) {
|
||||
throw new Error(
|
||||
`AI game creator shell developer window guardrail drifted: ${snippet}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (tauriHandlerSource.includes('open_developer_window(app.handle())?')) {
|
||||
throw new Error(
|
||||
'AI game creator normal startup must not automatically open the developer window',
|
||||
@@ -1905,31 +1940,17 @@ for (const snippet of [
|
||||
'官方账号服务(固定)',
|
||||
'runtime_config.save',
|
||||
"'/run:运行自检,启动本地 HTTP 预览并载入客户端运行视图'",
|
||||
"'activate_local_game_preview'",
|
||||
'已切换到客户端运行视图',
|
||||
'async function executeRunLocal',
|
||||
'function needsInitializedChatProject',
|
||||
'function resolvePendingCommandProjectPath',
|
||||
'resolveChatProjectPath(localProject) ?? draftProjectPath',
|
||||
'`permission.cancel ${command.id} missing-project`',
|
||||
"'/remember [short|long|blackboard] 内容:追加短期、长期或黑板记忆'",
|
||||
"'/memory-set [short|long|blackboard] 内容:覆盖保存对应记忆'",
|
||||
'function parseRememberInput',
|
||||
"'/trace 或 /loop:查看最近一次 Agent loop trace'",
|
||||
'async function executeAgentTraceChat',
|
||||
"relativePath: '.agent/logs/command.log'",
|
||||
"'permission.pending'",
|
||||
"'permission.confirm'",
|
||||
"'permission.cancel'",
|
||||
"'command.auto'",
|
||||
"'agent.run_status'",
|
||||
'function summarizeAgentRunTrace',
|
||||
'工具调用:${agentRunTrace.toolCallCount}/${agentRunTrace.maxToolCalls}',
|
||||
'agentRunTrace.error ?',
|
||||
'className="trace-error"',
|
||||
'agentRunTrace.taskGraph.repairRoutes.map',
|
||||
"in: ${step.inputPaths.join(', ') || 'none'}",
|
||||
"out: ${step.outputPaths.join(', ') || 'none'}",
|
||||
]) {
|
||||
if (!appSource.includes(snippet)) {
|
||||
throw new Error(
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "developer",
|
||||
"description": "开发窗口允许打开本地素材选择对话框。",
|
||||
"windows": ["developer"],
|
||||
"permissions": ["dialog:allow-open"]
|
||||
}
|
||||
@@ -2,6 +2,6 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "events",
|
||||
"description": "允许客户端窗口订阅并取消订阅 Rust Runtime 事件。",
|
||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||
"windows": ["client", "main", "launcher"],
|
||||
"permissions": ["core:event:allow-listen", "core:event:allow-unlisten"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "window-chrome",
|
||||
"description": "自绘标题栏允许执行当前窗口的基础控制和拖拽。",
|
||||
"windows": ["client", "developer", "main", "launcher", "supervisor-chat"],
|
||||
"windows": ["client", "main", "launcher"],
|
||||
"permissions": [
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
|
||||
@@ -13,7 +13,7 @@ mod codex_app_server;
|
||||
mod codex_cli;
|
||||
mod codex_provider_proxy;
|
||||
mod design_runtime;
|
||||
mod design_tools;
|
||||
pub(crate) mod design_tools;
|
||||
mod direct_codex_attachments;
|
||||
mod direct_codex_audit;
|
||||
mod direct_codex_user_item;
|
||||
|
||||
@@ -1180,7 +1180,6 @@ fn direct_codex_thread_delta_event(
|
||||
) -> DirectThreadEvent {
|
||||
DirectThreadEvent::item_delta(item_id, kind, direct_thread_delta_text(root, delta))
|
||||
}
|
||||
|
||||
/// 通知 → 回合事件的唯一分类函数:运行态读取器与单测共用这一份。
|
||||
///
|
||||
/// 读取器只负责"必须有 turnId 才处理"的前置条件与节流(活动 / 正文),分类不在这里之外
|
||||
@@ -3358,8 +3357,22 @@ impl CodexAppServerConnection {
|
||||
codex_app_server_text_prompt(&request)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
};
|
||||
let mut input =
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?;
|
||||
let mut input = if self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject {
|
||||
if let Some(item) = direct_user_item {
|
||||
let canonical: DirectCodexUserItem = serde_json::from_value(item.clone())
|
||||
.map_err(|error| platform_llm::LlmError::InvalidRequest(error.to_string()))?;
|
||||
direct_codex_user_item_to_codex_turn_input(
|
||||
&self.inner.workspace_path,
|
||||
&canonical,
|
||||
self.inner._skill_roots.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(platform_llm::LlmError::InvalidRequest)?
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
}
|
||||
} else {
|
||||
codex_app_server_turn_input(&request, &prompt, &self.inner.workspace_path).await?
|
||||
};
|
||||
if thread_created && self.inner.workspace_mode == CodexAppServerWorkspaceMode::DirectProject
|
||||
{
|
||||
if let Some(client_turn_id) = direct_client_turn_id {
|
||||
|
||||
@@ -4,10 +4,12 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::BTreeMap;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Manager;
|
||||
|
||||
const DESIGN_WORKSPACE_ROOT: &str = "design_artifacts";
|
||||
const DESIGN_REFERENCES_ROOT: &str = "references";
|
||||
const SEARCH_HIT_LIMIT: usize = 200;
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
@@ -547,6 +549,123 @@ pub(crate) fn read_design_workspace_file_at(root: &Path, path: &str) -> Result<S
|
||||
fs::read_to_string(&target).map_err(|error| format!("读取失败:{error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn import_design_workspace_file(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
file_name: String,
|
||||
bytes: Vec<u8>,
|
||||
) -> Result<String, String> {
|
||||
let root = PathBuf::from(project_path.trim());
|
||||
let relative_path = import_design_workspace_file_at(&root, &file_name, &bytes)?;
|
||||
let _ = app.emit(
|
||||
"design-agent-update",
|
||||
serde_json::json!({
|
||||
"projectPath": root.to_string_lossy(),
|
||||
"clientTurnId": "",
|
||||
"kind": "workspace",
|
||||
"messageId": null,
|
||||
"text": null,
|
||||
"view": null,
|
||||
}),
|
||||
);
|
||||
Ok(relative_path)
|
||||
}
|
||||
|
||||
fn import_design_workspace_file_at(
|
||||
root: &Path,
|
||||
file_name: &str,
|
||||
bytes: &[u8],
|
||||
) -> Result<String, String> {
|
||||
enforce_project_permission_policy(root, "conversation.write")?;
|
||||
read_existing_manifest_for_project(root)?;
|
||||
let _lock = acquire_game_creator_agent_runtime_project_write_lock_with_wait(
|
||||
root,
|
||||
"conversation.write",
|
||||
)?;
|
||||
|
||||
if file_name.contains(['/', '\\']) {
|
||||
return Err("附件文件名必须是单个文件名,不能包含目录".to_string());
|
||||
}
|
||||
let normalized_name =
|
||||
normalize_relative_path(file_name).map_err(|error| format!("附件文件名无效:{error}"))?;
|
||||
if normalized_name != file_name {
|
||||
return Err("附件文件名无效".to_string());
|
||||
}
|
||||
|
||||
let (_, references) = resolve_design_workspace_path(root, DESIGN_REFERENCES_ROOT)?;
|
||||
crate::ensure_game_creator_private_directory_tree(&references, "策划参考附件目录")?;
|
||||
crate::prepare_game_creator_private_path_for_read(&references, true, "策划参考附件目录")?;
|
||||
|
||||
let mut sequence = 1_u64;
|
||||
loop {
|
||||
let candidate_name = design_reference_file_name(&normalized_name, sequence);
|
||||
let relative_path = format!("{DESIGN_REFERENCES_ROOT}/{candidate_name}");
|
||||
let (_, target) = resolve_design_workspace_path(root, &relative_path)?;
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create_new(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.custom_flags(libc::O_NOFOLLOW).mode(0o600);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::fs::OpenOptionsExt;
|
||||
options.custom_flags(crate::PROJECT_FILE_FLAG_OPEN_REPARSE_POINT);
|
||||
}
|
||||
let mut file = match options.open(&target) {
|
||||
Ok(file) => file,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
sequence = sequence
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| "无法为同名附件分配新序号".to_string())?;
|
||||
continue;
|
||||
}
|
||||
Err(error) => {
|
||||
return Err(format!(
|
||||
"创建策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) =
|
||||
crate::harden_new_game_creator_private_path(&target, false, "策划参考附件")
|
||||
{
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(error);
|
||||
}
|
||||
let write_result = file.write_all(bytes).and_then(|_| file.sync_all());
|
||||
drop(file);
|
||||
if let Err(error) = write_result {
|
||||
let _ = fs::remove_file(&target);
|
||||
return Err(format!(
|
||||
"写入策划参考附件失败:{}: {error}",
|
||||
target.display()
|
||||
));
|
||||
}
|
||||
return Ok(relative_path);
|
||||
}
|
||||
}
|
||||
|
||||
fn design_reference_file_name(file_name: &str, sequence: u64) -> String {
|
||||
if sequence == 1 {
|
||||
return file_name.to_string();
|
||||
}
|
||||
let path = Path::new(file_name);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or(file_name);
|
||||
match path.extension().and_then(|value| value.to_str()) {
|
||||
Some(extension) if !extension.is_empty() => {
|
||||
format!("{stem} ({sequence}).{extension}")
|
||||
}
|
||||
_ => format!("{stem} ({sequence})"),
|
||||
}
|
||||
}
|
||||
|
||||
fn load_design_catalog(root: &Path) -> Result<Vec<DesignCatalogItem>, String> {
|
||||
let catalog_path = root.join("resources/catalog.json");
|
||||
let data: DesignCatalogFile = serde_json::from_str(
|
||||
@@ -756,6 +875,13 @@ mod tests {
|
||||
tempfile::tempdir().expect("tempdir")
|
||||
}
|
||||
|
||||
fn initialized_test_root() -> tempfile::TempDir {
|
||||
let temp = test_root();
|
||||
init_local_game_project_at(temp.path(), "design-import-test", "策划附件导入测试")
|
||||
.expect("init project");
|
||||
temp
|
||||
}
|
||||
|
||||
fn pack_root() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("design-agent")
|
||||
}
|
||||
@@ -868,6 +994,138 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_reference_is_visible_to_workspace_list_and_read() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let manifest_before = read_existing_manifest_for_project(root).expect("read manifest");
|
||||
let revision_before =
|
||||
read_game_creator_agent_runtime_project_revision(root).expect("read revision");
|
||||
|
||||
let relative =
|
||||
import_design_workspace_file_at(root, "玩法构想.txt", "横版解谜\n".as_bytes())
|
||||
.expect("import text reference");
|
||||
|
||||
assert_eq!(relative, "references/玩法构想.txt");
|
||||
assert!(list_design_workspace_files(root)
|
||||
.expect("list workspace")
|
||||
.iter()
|
||||
.any(|entry| entry.path == relative && entry.kind == "file"));
|
||||
assert_eq!(
|
||||
read_design_workspace_file_at(root, &relative).expect("read imported reference"),
|
||||
"横版解谜\n"
|
||||
);
|
||||
assert_eq!(
|
||||
read_existing_manifest_for_project(root).expect("read manifest after import"),
|
||||
manifest_before
|
||||
);
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(root)
|
||||
.expect("read revision after import"),
|
||||
revision_before
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_keeps_existing_names_and_accepts_empty_and_binary_bytes() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
|
||||
let first = import_design_workspace_file_at(root, "brief.md", b"first")
|
||||
.expect("import first reference");
|
||||
let second = import_design_workspace_file_at(root, "brief.md", b"second")
|
||||
.expect("import repeated reference");
|
||||
let empty = import_design_workspace_file_at(root, "empty.bin", b"")
|
||||
.expect("import empty reference");
|
||||
let binary_bytes = [0_u8, 0xff, 0x10, 0x80];
|
||||
let binary = import_design_workspace_file_at(root, "bytes.bin", &binary_bytes)
|
||||
.expect("import binary reference");
|
||||
|
||||
assert_eq!(first, "references/brief.md");
|
||||
assert_eq!(second, "references/brief (2).md");
|
||||
assert_eq!(empty, "references/empty.bin");
|
||||
assert_eq!(binary, "references/bytes.bin");
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&first)).expect("read first"),
|
||||
b"first"
|
||||
);
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&second)).expect("read second"),
|
||||
b"second"
|
||||
);
|
||||
assert!(fs::read(root.join("design_artifacts").join(&empty))
|
||||
.expect("read empty")
|
||||
.is_empty());
|
||||
assert_eq!(
|
||||
fs::read(root.join("design_artifacts").join(&binary)).expect("read binary"),
|
||||
binary_bytes
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_unsafe_names_and_denied_project_permission() {
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
for file_name in [
|
||||
"../outside.txt",
|
||||
"nested/file.txt",
|
||||
r"nested\file.txt",
|
||||
"C:stream",
|
||||
] {
|
||||
let error = import_design_workspace_file_at(root, file_name, b"blocked")
|
||||
.expect_err("reject unsafe file name");
|
||||
assert!(error.contains("文件名"), "unexpected error: {error}");
|
||||
}
|
||||
assert!(!root.join("outside.txt").exists());
|
||||
|
||||
let mut policy = ProjectPermissionPolicy::default();
|
||||
policy
|
||||
.denied_commands
|
||||
.push("conversation.write".to_string());
|
||||
write_project_permission_policy_at(root, policy).expect("deny conversation write");
|
||||
let error = import_design_workspace_file_at(root, "denied.txt", b"blocked")
|
||||
.expect_err("respect project permission policy");
|
||||
assert!(error.contains("conversation.write"));
|
||||
assert!(!root.join("design_artifacts/references/denied.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn import_rejects_linked_references_directory() {
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
symlink(outside.path(), root.join("design_artifacts/references"))
|
||||
.expect("link references directory");
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
fn import_rejects_windows_linked_references_directory_when_supported() {
|
||||
use std::os::windows::fs::symlink_dir;
|
||||
|
||||
let temp = initialized_test_root();
|
||||
let root = temp.path();
|
||||
let outside = tempfile::tempdir().expect("outside tempdir");
|
||||
fs::create_dir_all(root.join("design_artifacts")).expect("create workspace");
|
||||
if symlink_dir(outside.path(), root.join("design_artifacts/references")).is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let error = import_design_workspace_file_at(root, "escape.txt", b"blocked")
|
||||
.expect_err("reject linked references directory");
|
||||
assert!(error.contains("符号链接") || error.contains("reparse point"));
|
||||
assert!(!outside.path().join("escape.txt").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phase_context_injects_current_skill_only() {
|
||||
let resources = DesignResources::new(pack_root()).expect("pack");
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
//! 有项目路径或导入状态时输出路径映射;否则保持首页元数据文案。不灌正文。
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENTS: usize = 8;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS: usize = 160;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS: usize = 96;
|
||||
pub(crate) const MAX_DIRECT_CODEX_ATTACHMENT_LOCAL_PATH_CHARS: usize = 512;
|
||||
|
||||
const HOME_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.homeHeader");
|
||||
const PROJECT_ATTACHMENT_HEADER: &str = prompt_text!("projectContext.attachments.projectHeader");
|
||||
|
||||
@@ -5,11 +5,11 @@ mod validation;
|
||||
mod wire;
|
||||
|
||||
pub(crate) use model::{
|
||||
DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem,
|
||||
DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRole, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
pub(crate) use validation::validate_direct_codex_user_item;
|
||||
pub(crate) use wire::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
direct_codex_user_item_to_wire_input,
|
||||
direct_codex_user_item_to_codex_turn_input, direct_codex_user_item_to_prompt,
|
||||
direct_codex_user_item_to_response_item, direct_codex_user_item_to_wire_input,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ use ts_rs::TS;
|
||||
/// DirectProject 本轮 user input 的唯一结构化入口。
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserItem {
|
||||
#[serde(rename = "message")]
|
||||
Message(DirectCodexUserMessageItem),
|
||||
@@ -12,7 +12,7 @@ pub(crate) enum DirectCodexUserItem {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserMessageItem {
|
||||
pub(crate) role: DirectCodexUserRole,
|
||||
pub(crate) content: Vec<DirectCodexUserContentPart>,
|
||||
@@ -21,26 +21,43 @@ pub(crate) struct DirectCodexUserMessageItem {
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserRole {
|
||||
User,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectCodexUserContentPart {
|
||||
#[serde(rename = "input_text")]
|
||||
InputText { text: String },
|
||||
#[serde(rename = "agc_resource_reference")]
|
||||
AgcResourceReference { resource_id: String },
|
||||
#[serde(rename = "agc_skill_reference")]
|
||||
AgcSkillReference { name: String },
|
||||
#[serde(rename = "agc_runtime_region_reference")]
|
||||
AgcRuntimeRegionReference(DirectCodexUserRuntimeRegionPart),
|
||||
/// Uploaded project attachment kept inline in canonical content.
|
||||
#[serde(rename = "agc_attachment_reference")]
|
||||
AgcAttachmentReference(DirectCodexUserAttachmentReferencePart),
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserAttachmentReferencePart {
|
||||
pub(crate) name: String,
|
||||
pub(crate) media_type: String,
|
||||
#[ts(type = "number")]
|
||||
pub(crate) size: u64,
|
||||
pub(crate) local_path: String,
|
||||
pub(crate) status: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectCodexUserRuntimeRegionPart {
|
||||
pub(crate) label: String,
|
||||
#[serde(default)]
|
||||
|
||||
+249
-14
@@ -4,15 +4,20 @@ use super::model::{
|
||||
};
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, GameCreationAppManifest,
|
||||
MAX_DIRECT_CODEX_ATTACHMENTS, MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS,
|
||||
MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS,
|
||||
};
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const MAX_DIRECT_CODEX_REFERENCES: usize = 32;
|
||||
// Skill 引用也是非文本 part,但没有走 reference_count:它每一条都会触发一次
|
||||
// `root/<name>/SKILL.md` 文件探测并往 turn input 里加一项,所以单独设上限。
|
||||
pub(crate) const MAX_DIRECT_CODEX_SKILL_REFERENCES: usize = 32;
|
||||
|
||||
pub(crate) fn validate_direct_codex_user_item(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<GameCreationAppManifest, String> {
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
if !matches!(message.role, DirectCodexUserRole::User) {
|
||||
return Err("DirectProject 只接受 user message item".to_string());
|
||||
@@ -20,38 +25,98 @@ pub(crate) fn validate_direct_codex_user_item(
|
||||
if message.id.trim().is_empty() {
|
||||
return Err("DirectProject user item 缺少稳定 id".to_string());
|
||||
}
|
||||
if message.content.is_empty() {
|
||||
// 有效输入只判一整条 content:单个纯空白 `input_text` 是合法 part —— 编辑器里的段落
|
||||
// 分隔、软换行与 chip 后的分隔空格就是这样落进 canonical content 的,前端不为它过滤。
|
||||
if !content_has_meaningful_input(&message.content) {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
let mut reference_count = 0usize;
|
||||
let mut has_effective_content = false;
|
||||
let mut attachment_count = 0usize;
|
||||
let mut skill_count = 0usize;
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
if !text.trim().is_empty() {
|
||||
has_effective_content = true;
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::InputText { .. } => {}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_resource_id_and_manifest(&manifest, resource_id)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
skill_count = skill_count.saturating_add(1);
|
||||
if skill_count > MAX_DIRECT_CODEX_SKILL_REFERENCES {
|
||||
return Err(format!(
|
||||
"一次最多引用 {MAX_DIRECT_CODEX_SKILL_REFERENCES} 个 Skill"
|
||||
));
|
||||
}
|
||||
let name = name.trim();
|
||||
if name.is_empty()
|
||||
|| name.chars().count() > 120
|
||||
|| matches!(name, "." | "..")
|
||||
|| name.chars().any(|character| {
|
||||
character.is_control()
|
||||
|| character.is_whitespace()
|
||||
|| matches!(character, '/' | '\\' | ':' | '$')
|
||||
})
|
||||
{
|
||||
return Err("引用的 Skill 名称无效,请移除后重新选择".to_string());
|
||||
}
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
reference_count = reference_count.saturating_add(1);
|
||||
validate_runtime_region_reference(&manifest, reference)?;
|
||||
has_effective_content = true;
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
attachment_count = attachment_count.saturating_add(1);
|
||||
if attachment_count > MAX_DIRECT_CODEX_ATTACHMENTS {
|
||||
return Err(format!(
|
||||
"一次最多携带 {MAX_DIRECT_CODEX_ATTACHMENTS} 个附件"
|
||||
));
|
||||
}
|
||||
if reference.name.trim().is_empty() {
|
||||
return Err("附件缺少文件名".to_string());
|
||||
}
|
||||
let name = reference.name.trim();
|
||||
if name.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS
|
||||
|| name.chars().any(char::is_control)
|
||||
{
|
||||
return Err("附件文件名无效或过长".to_string());
|
||||
}
|
||||
let media_type = reference.media_type.trim();
|
||||
if media_type.is_empty()
|
||||
|| media_type.chars().count() > MAX_DIRECT_CODEX_ATTACHMENT_MEDIA_TYPE_CHARS
|
||||
|| media_type.chars().any(|character| {
|
||||
!(character.is_ascii_alphanumeric()
|
||||
|| matches!(character, '/' | '+' | '-' | '.' | '_'))
|
||||
})
|
||||
{
|
||||
return Err("附件媒体类型无效或过长".to_string());
|
||||
}
|
||||
let status = reference.status.trim();
|
||||
if status == "imported" && reference.local_path.trim().is_empty() {
|
||||
return Err("已导入附件缺少项目路径".to_string());
|
||||
}
|
||||
if !reference.local_path.trim().is_empty() {
|
||||
sanitize_attachment_local_path(&reference.local_path)
|
||||
.ok_or_else(|| "附件项目路径无效".to_string())?;
|
||||
}
|
||||
if !matches!(status, "imported" | "failed") {
|
||||
return Err("附件状态无效".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if reference_count > MAX_DIRECT_CODEX_REFERENCES {
|
||||
return Err(format!("一次最多引用 {MAX_DIRECT_CODEX_REFERENCES} 个素材"));
|
||||
}
|
||||
if !has_effective_content {
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
Ok(())
|
||||
Ok(manifest)
|
||||
}
|
||||
|
||||
/// 整条 content 是否还有有效输入:任何一段非空白文本、或任何一个非文本 part 都算。
|
||||
pub(crate) fn content_has_meaningful_input(content: &[DirectCodexUserContentPart]) -> bool {
|
||||
content.iter().any(|part| match part {
|
||||
DirectCodexUserContentPart::InputText { text } => !text.trim().is_empty(),
|
||||
_ => true,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn validate_resource_id_and_manifest(
|
||||
@@ -92,3 +157,173 @@ fn validate_runtime_region_reference(
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
content_has_meaningful_input, validate_direct_codex_user_item,
|
||||
MAX_DIRECT_CODEX_SKILL_REFERENCES,
|
||||
};
|
||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserContentPart;
|
||||
use serde_json::json;
|
||||
|
||||
fn input_text(text: &str) -> DirectCodexUserContentPart {
|
||||
DirectCodexUserContentPart::InputText {
|
||||
text: text.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_all_blank_content_counts_as_empty_input() {
|
||||
// 空数组与「整条只有空白」是同一种空输入。
|
||||
assert!(!content_has_meaningful_input(&[]));
|
||||
assert!(!content_has_meaningful_input(&[input_text(" \n ")]));
|
||||
assert!(!content_has_meaningful_input(&[
|
||||
input_text("\n"),
|
||||
input_text(" "),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn whitespace_parts_are_valid_next_to_meaningful_input() {
|
||||
// 段落分隔 / 软换行 / chip 后的分隔空格都是合法的单个 part。
|
||||
assert!(content_has_meaningful_input(&[
|
||||
input_text("\n"),
|
||||
input_text("看"),
|
||||
]));
|
||||
assert!(content_has_meaningful_input(&[
|
||||
input_text("看"),
|
||||
input_text("\n\n"),
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_text_parts_always_count_as_input() {
|
||||
assert!(content_has_meaningful_input(&[
|
||||
DirectCodexUserContentPart::AgcResourceReference {
|
||||
resource_id: "asset-hero".to_string(),
|
||||
},
|
||||
]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_attachment_count_is_bounded_independently() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let content = (0..=crate::agent::MAX_DIRECT_CODEX_ATTACHMENTS)
|
||||
.map(|index| {
|
||||
json!({
|
||||
"type": "agc_attachment_reference",
|
||||
"name": format!("attachment-{index}.txt"),
|
||||
"mediaType": "text/plain",
|
||||
"size": 1,
|
||||
"localPath": "",
|
||||
"status": "failed"
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("too many inline attachments must be rejected");
|
||||
assert!(error.contains("最多携带"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imported_attachment_requires_a_project_path() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "agc_attachment_reference",
|
||||
"name": "attachment.txt",
|
||||
"mediaType": "text/plain",
|
||||
"size": 1,
|
||||
"localPath": "",
|
||||
"status": "imported"
|
||||
}],
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("imported attachment without a project path must fail");
|
||||
assert!(error.contains("缺少项目路径"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inline_skill_reference_count_is_bounded_independently() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let content = (0..=MAX_DIRECT_CODEX_SKILL_REFERENCES)
|
||||
.map(|index| json!({ "type": "agc_skill_reference", "name": format!("skill-{index}") }))
|
||||
.collect::<Vec<_>>();
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": content,
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("too many skill references must be rejected");
|
||||
assert!(error.contains("最多引用"), "{error}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachment_name_and_media_type_are_bounded_and_well_formed() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "validation-test", "校验测试")
|
||||
.expect("init project");
|
||||
let long_name = "a".repeat(crate::agent::MAX_DIRECT_CODEX_ATTACHMENT_NAME_CHARS + 1);
|
||||
let cases = [
|
||||
(
|
||||
json!({
|
||||
"name": "bad\nname.txt",
|
||||
"mediaType": "text/plain"
|
||||
}),
|
||||
"文件名",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"name": "ok.txt",
|
||||
"mediaType": "text/plain\nsecret"
|
||||
}),
|
||||
"媒体类型",
|
||||
),
|
||||
(
|
||||
json!({
|
||||
"name": long_name,
|
||||
"mediaType": "text/plain"
|
||||
}),
|
||||
"文件名",
|
||||
),
|
||||
];
|
||||
for (metadata, expected) in cases {
|
||||
let mut value = metadata;
|
||||
value["type"] = json!("agc_attachment_reference");
|
||||
value["size"] = json!(1);
|
||||
value["localPath"] = json!("");
|
||||
value["status"] = json!("failed");
|
||||
let item = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [value],
|
||||
"id": "turn-1:user"
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = validate_direct_codex_user_item(root.path(), &item)
|
||||
.expect_err("invalid attachment metadata must fail");
|
||||
assert!(error.contains(expected), "{error}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
use super::model::{DirectCodexUserContentPart, DirectCodexUserItem, DirectCodexUserMessageItem};
|
||||
use super::model::{
|
||||
DirectCodexUserAttachmentReferencePart, DirectCodexUserContentPart, DirectCodexUserItem,
|
||||
DirectCodexUserMessageItem, DirectCodexUserRuntimeRegionPart,
|
||||
};
|
||||
use super::validation::validate_direct_codex_user_item;
|
||||
use crate::agent::{read_manifest_for_project, sanitize_attachment_local_path};
|
||||
use crate::agent::{
|
||||
read_manifest_for_project, sanitize_attachment_local_path, sanitize_attachment_media_type,
|
||||
sanitize_attachment_name, GameCreationAppManifest,
|
||||
};
|
||||
use crate::ui_editor::persistence::{
|
||||
generate_ui_design_code_at, GenerateUiDesignCodeInput, UI_DESIGN_DOC_ASSET_KIND,
|
||||
UI_DESIGN_DOC_MEDIA_TYPE,
|
||||
@@ -55,54 +61,90 @@ fn direct_codex_user_item_to_response_content(
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn resource_reference_summary(
|
||||
manifest: &GameCreationAppManifest,
|
||||
resource_id: &str,
|
||||
) -> Result<String, String> {
|
||||
let resource_id = resource_id.trim();
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id)
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
Ok(format!(
|
||||
"[素材引用 resourceId={resource_id};项目路径={path}]"
|
||||
))
|
||||
}
|
||||
|
||||
fn runtime_region_summary(reference: &DirectCodexUserRuntimeRegionPart) -> String {
|
||||
let resources = reference
|
||||
.resource_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||
if let Some(run_id) = reference.run_id.as_deref() {
|
||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||
}
|
||||
if let Some(role) = reference.element_role.as_deref() {
|
||||
summary.push_str(&format!("角色={} ", role.trim()));
|
||||
}
|
||||
if let Some(text) = reference.text.as_deref() {
|
||||
summary.push_str(&format!("文本={} ", text.trim()));
|
||||
}
|
||||
if !resources.is_empty() {
|
||||
summary.push_str(&format!("关联素材={resources}"));
|
||||
}
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
|
||||
/// 附件引用的安全摘要。
|
||||
///
|
||||
/// turn 输入与 history/prompt 投影共用这一份清洗:文件名取 basename 并去控制字符、
|
||||
/// media type 与项目路径同样过白名单,避免两条路径对同一个引用给出不同摘要。
|
||||
fn attachment_reference_summary(reference: &DirectCodexUserAttachmentReferencePart) -> String {
|
||||
let name = sanitize_attachment_name(&reference.name);
|
||||
let media_type = sanitize_attachment_media_type(&reference.media_type);
|
||||
let mut summary = format!(
|
||||
"[附件:名称={name};类型={media_type};大小={} 字节",
|
||||
reference.size
|
||||
);
|
||||
if let Some(local_path) = sanitize_attachment_local_path(&reference.local_path) {
|
||||
summary.push_str(&format!(";项目路径={local_path}"));
|
||||
}
|
||||
summary.push_str(&format!(";状态={}", reference.status.trim()));
|
||||
summary.push(']');
|
||||
summary
|
||||
}
|
||||
|
||||
/// 将 canonical user item 转为 app-server `turn/start.input` 可接受的文本数组。
|
||||
/// AGC 私有 part 只在这里投影为安全摘要,canonical item 本身不被修改。
|
||||
pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
) -> Result<Value, String> {
|
||||
validate_direct_codex_user_item(root, item)?;
|
||||
let manifest = read_manifest_for_project(root)?;
|
||||
// validate 已经读过清单并返回它,不要再读一次(seed task 变更也会被重复触发)。
|
||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
let text = match part {
|
||||
DirectCodexUserContentPart::InputText { text } => text.clone(),
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
let asset = manifest
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.id == resource_id.trim())
|
||||
.ok_or_else(|| "引用的素材已不存在,请移除后重新选择".to_string())?;
|
||||
let path = sanitize_attachment_local_path(&asset.local_path)
|
||||
.ok_or_else(|| "引用的素材路径无效,请移除后重新选择".to_string())?;
|
||||
format!(
|
||||
"[素材引用 resourceId={};项目路径={path}]",
|
||||
resource_id.trim()
|
||||
)
|
||||
resource_reference_summary(&manifest, resource_id)?
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
format!("${}", name.trim())
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
let resources = reference
|
||||
.resource_ids
|
||||
.iter()
|
||||
.map(|id| id.trim())
|
||||
.collect::<Vec<_>>()
|
||||
.join(",");
|
||||
let mut summary = format!("[运行画面区域:名称={} ", reference.label.trim());
|
||||
if let Some(run_id) = reference.run_id.as_deref() {
|
||||
summary.push_str(&format!("运行标识={} ", run_id.trim()));
|
||||
}
|
||||
if let Some(role) = reference.element_role.as_deref() {
|
||||
summary.push_str(&format!("角色={} ", role.trim()));
|
||||
}
|
||||
if let Some(text) = reference.text.as_deref() {
|
||||
summary.push_str(&format!("文本={} ", text.trim()));
|
||||
}
|
||||
if !resources.is_empty() {
|
||||
summary.push_str(&format!("关联素材={resources}"));
|
||||
}
|
||||
summary.push(']');
|
||||
summary
|
||||
runtime_region_summary(reference)
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
attachment_reference_summary(reference)
|
||||
}
|
||||
};
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
@@ -110,6 +152,55 @@ pub(crate) fn direct_codex_user_item_to_wire_input(
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_codex_turn_input(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
skill_roots: &[std::path::PathBuf],
|
||||
) -> Result<Value, String> {
|
||||
let manifest = validate_direct_codex_user_item(root, item)?;
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut input = Vec::with_capacity(message.content.len());
|
||||
for part in &message.content {
|
||||
match part {
|
||||
DirectCodexUserContentPart::InputText { text } => {
|
||||
input.push(serde_json::json!({ "type": "text", "text": text }));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcResourceReference { resource_id } => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": resource_reference_summary(&manifest, resource_id)?,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcSkillReference { name } => {
|
||||
let name = name.trim();
|
||||
let path = skill_roots
|
||||
.iter()
|
||||
.map(|root| root.join(name).join("SKILL.md"))
|
||||
.find(|path| path.is_file())
|
||||
.ok_or_else(|| "引用的 Skill 当前不可用,请重新选择".to_string())?;
|
||||
input.push(serde_json::json!({
|
||||
"type": "skill",
|
||||
"name": name,
|
||||
"path": path,
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcRuntimeRegionReference(reference) => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": runtime_region_summary(reference),
|
||||
}));
|
||||
}
|
||||
DirectCodexUserContentPart::AgcAttachmentReference(reference) => {
|
||||
input.push(serde_json::json!({
|
||||
"type": "text",
|
||||
"text": attachment_reference_summary(reference),
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(Value::Array(input))
|
||||
}
|
||||
|
||||
pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
root: &Path,
|
||||
item: &DirectCodexUserItem,
|
||||
@@ -118,17 +209,10 @@ pub(crate) fn direct_codex_user_item_to_prompt(
|
||||
let DirectCodexUserItem::Message(message) = item;
|
||||
let mut prompt = wire
|
||||
.as_array()
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())
|
||||
.and_then(|parts| {
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
part.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| "DirectProject user item wire part 缺少 text".to_string())
|
||||
})
|
||||
.collect::<Result<String, String>>()
|
||||
})?;
|
||||
.ok_or_else(|| "DirectProject user item wire input 不是数组".to_string())?
|
||||
.iter()
|
||||
.filter_map(|part| part.get("text").and_then(Value::as_str))
|
||||
.collect::<String>();
|
||||
if let Some(code_context) = render_ui_design_code_context(root, message)? {
|
||||
prompt.push('\n');
|
||||
prompt.push_str(&code_context);
|
||||
@@ -199,8 +283,9 @@ fn render_ui_design_code_context(
|
||||
mod tests {
|
||||
use super::{
|
||||
direct_codex_user_item_to_prompt, direct_codex_user_item_to_response_item,
|
||||
validate_direct_codex_user_item,
|
||||
direct_codex_user_item_to_wire_input, validate_direct_codex_user_item,
|
||||
};
|
||||
use crate::agent::direct_codex_user_item::model::DirectCodexUserItem;
|
||||
use crate::ui_editor::persistence::UI_DESIGN_DOC_MEDIA_TYPE;
|
||||
use serde_json::json;
|
||||
use shared_contracts::game_creation_app::{
|
||||
@@ -283,6 +368,83 @@ mod tests {
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(
|
||||
prompt.contains(&format!(
|
||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||
)),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||
"{prompt}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_projection_never_writes_generated_ui_design_code() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item = user_item_with_resource_reference(&asset_id);
|
||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||
let generated_root =
|
||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||
let generated_files = std::fs::read_dir(generated_root)
|
||||
.expect("read ui directory")
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("generated-")
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
generated_files, 0,
|
||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||
let project = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
project.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standard_response_item_passes_through_without_agc_private_parts() {
|
||||
let item = json!({
|
||||
@@ -297,6 +459,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn response_item_projection_uses_input_text_not_turn_input_text() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{"type": "input_text", "text": "你好"}]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
assert_eq!(projected["content"][0]["type"], "input_text");
|
||||
assert_ne!(projected["content"][0]["type"], "text");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_projection_preserves_empty_parts_line_breaks_and_trailing_whitespace() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
@@ -445,79 +624,97 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_doc_reference_appends_generated_code_context() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(
|
||||
prompt.contains(&format!(
|
||||
"[素材引用 resourceId={asset_id};项目路径=ui/design.json]"
|
||||
)),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(
|
||||
prompt.contains("请先阅读生成的带有文档的代码片段: ui/generated-"),
|
||||
"{prompt}"
|
||||
);
|
||||
fn attachment_parts_remain_in_canonical_order_when_projected() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "先看"},
|
||||
{"type": "agc_attachment_reference", "name": "notes.txt", "mediaType": "text/plain", "size": 4, "localPath": "assets/notes.txt", "status": "imported"}
|
||||
]
|
||||
});
|
||||
let projected = direct_codex_user_item_to_response_item(root.path(), &item)
|
||||
.expect("user response item should project");
|
||||
let content = projected["content"].as_array().expect("content array");
|
||||
assert_eq!(content.len(), 2);
|
||||
assert!(content[0]["text"].as_str().unwrap().contains("先看"));
|
||||
assert!(content[1]["text"].as_str().unwrap().contains("notes.txt"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_projection_never_writes_generated_ui_design_code() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(true);
|
||||
let item = user_item_with_resource_reference(&asset_id);
|
||||
direct_codex_user_item_to_response_item(project.path(), &item).expect("history projection");
|
||||
let generated_root =
|
||||
crate::resolve_local_project_path(project.path(), "ui").expect("resolve ui directory");
|
||||
let generated_files = std::fs::read_dir(generated_root)
|
||||
.expect("read ui directory")
|
||||
.filter_map(|entry| entry.ok())
|
||||
.filter(|entry| {
|
||||
entry
|
||||
.file_name()
|
||||
.to_string_lossy()
|
||||
.starts_with("generated-")
|
||||
})
|
||||
.count();
|
||||
assert_eq!(
|
||||
generated_files, 0,
|
||||
"历史回读只做纯投影,不得生成 UI 设计代码"
|
||||
);
|
||||
fn attachment_metadata_is_sanitized_before_prompt_projection() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item = json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [{
|
||||
"type": "agc_attachment_reference",
|
||||
"name": "C:\\tmp\\notes.md",
|
||||
"mediaType": "text/plain",
|
||||
"size": 4,
|
||||
"localPath": "assets\\.\\notes.txt",
|
||||
"status": "imported"
|
||||
}]
|
||||
});
|
||||
let wire = super::direct_codex_user_item_to_wire_input(
|
||||
root.path(),
|
||||
&serde_json::from_value(item).expect("deserialize user item"),
|
||||
)
|
||||
.expect("attachment metadata should project");
|
||||
let text = wire[0]["text"].as_str().expect("wire text");
|
||||
assert!(text.contains("名称=notes.md"), "{text}");
|
||||
assert!(text.contains("类型=text/plain"), "{text}");
|
||||
assert!(text.contains("项目路径=assets/notes.txt"), "{text}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ui_design_generation_failure_keeps_reference_and_reports_error() {
|
||||
let (project, asset_id) = ui_design_doc_fixture(false);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
fn whitespace_only_text_parts_survive_validation() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "先看"},
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let wire = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||
.expect("whitespace-only part next to real text must pass");
|
||||
let parts = wire.as_array().expect("wire input array");
|
||||
assert_eq!(parts.len(), 3);
|
||||
assert_eq!(parts[1]["text"].as_str(), Some("\n"));
|
||||
assert_eq!(parts[2]["text"].as_str(), Some(" "));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn other_asset_kind_reference_does_not_generate_ui_design_code() {
|
||||
let project = prompt_context_project();
|
||||
let asset_id = register_fixture_asset(
|
||||
project.path(),
|
||||
"assets/hero.png",
|
||||
GameCreationAppAssetKind::Character,
|
||||
"image/png",
|
||||
);
|
||||
let item: super::DirectCodexUserItem =
|
||||
serde_json::from_value(user_item_with_resource_reference(&asset_id))
|
||||
.expect("canonical user item");
|
||||
let prompt =
|
||||
direct_codex_user_item_to_prompt(project.path(), &item).expect("prompt projection");
|
||||
assert!(prompt.contains("素材引用 resourceId="), "{prompt}");
|
||||
assert!(
|
||||
!prompt.contains("请先阅读生成的带有文档的代码片段"),
|
||||
"{prompt}"
|
||||
);
|
||||
assert!(!prompt.contains("生成代码遇到错误"), "{prompt}");
|
||||
fn all_blank_content_is_rejected() {
|
||||
let root = tempfile::tempdir().expect("temp project");
|
||||
crate::init_local_game_project_at(root.path(), "wire-test", "wire 投影测试")
|
||||
.expect("init project");
|
||||
let item: DirectCodexUserItem = serde_json::from_value(json!({
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"id": "turn-1:user",
|
||||
"content": [
|
||||
{"type": "input_text", "text": "\n"},
|
||||
{"type": "input_text", "text": " "}
|
||||
]
|
||||
}))
|
||||
.expect("deserialize user item");
|
||||
let error = direct_codex_user_item_to_wire_input(root.path(), &item)
|
||||
.expect_err("all-blank content must fail closed");
|
||||
assert!(error.contains("不能为空"), "{error}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,11 +30,9 @@ pub(crate) fn normalize_direct_client_turn_id(
|
||||
#[tauri::command]
|
||||
pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
project_path: String,
|
||||
prompt: String,
|
||||
mut user_item: DirectCodexUserItem,
|
||||
user_item: DirectCodexUserItem,
|
||||
creation_type: Option<String>,
|
||||
client_turn_id: Option<String>,
|
||||
attachments: Option<Vec<DirectCodexTurnAttachment>>,
|
||||
) -> Result<String, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
let turn_id = normalize_direct_client_turn_id(client_turn_id.as_deref())?;
|
||||
@@ -43,74 +41,33 @@ pub(crate) async fn chat_with_game_creator_direct_codex(
|
||||
redact_agent_runtime_error(root, &format!("恢复上一轮陶泥儿整包事务失败:{error}"), 500)
|
||||
})?;
|
||||
let turn_emitter = DirectGameCreatorTurnUpdateEmitter::new(root, turn_id.clone());
|
||||
let mut audit = DirectCodexTurnAudit::start(
|
||||
root,
|
||||
&turn_id,
|
||||
&prompt,
|
||||
attachments.as_deref().unwrap_or_default(),
|
||||
);
|
||||
let attachments = attachments.unwrap_or_default();
|
||||
if !attachments.is_empty() {
|
||||
let attachment_context = match render_direct_codex_user_prompt("", &attachments) {
|
||||
Ok(context) => context,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
let DirectCodexUserItem::Message(message) = &mut user_item;
|
||||
message.content.push(DirectCodexUserContentPart::InputText {
|
||||
text: attachment_context,
|
||||
});
|
||||
}
|
||||
if let Err(error) = validate_direct_codex_user_item(root, &user_item) {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
let user_prompt = match direct_codex_user_item_to_prompt(root, &user_item) {
|
||||
Ok(prompt) => prompt,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
validate_direct_codex_user_item(root, &user_item)?;
|
||||
let user_prompt = direct_codex_user_item_to_prompt(root, &user_item)?;
|
||||
if user_prompt.trim().is_empty() {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err("聊天内容不能为空".to_string());
|
||||
}
|
||||
let canonical_user_item =
|
||||
// 创建类型来自结构化用户入口;实际工程和可信脚手架由宿主复核。
|
||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref()).await {
|
||||
match crate::environment_check::prepare_new_web_project_at(root, creation_type.as_deref())
|
||||
.await
|
||||
{
|
||||
Ok(_) => Some(serde_json::to_value(user_item).map_err(|error| error.to_string())?),
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(redact_agent_runtime_error(root, &error, 1800));
|
||||
}
|
||||
Err(error) => return Err(redact_agent_runtime_error(root, &error, 1800)),
|
||||
};
|
||||
let reply = match run_direct_game_creator_turn_at_with_creation_type_and_emitter(
|
||||
root,
|
||||
&user_prompt,
|
||||
creation_type.as_deref(),
|
||||
Some(&turn_emitter),
|
||||
Some(&mut audit),
|
||||
// DirectProject 的完整回合权威已经落在 project.jsonl;不再创建平行审计日志。
|
||||
None,
|
||||
canonical_user_item,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(reply) => reply,
|
||||
Err(error) => {
|
||||
audit.finish(false);
|
||||
audit.flush().await;
|
||||
return Err(error);
|
||||
}
|
||||
Err(error) => return Err(error),
|
||||
};
|
||||
audit.finish(true);
|
||||
audit.flush().await;
|
||||
turn_emitter.emit("completed", Some("none"), Some(reply.clone()), None);
|
||||
Ok(reply)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! DirectProject 聊天事件的线上模型与投影。
|
||||
//!
|
||||
//! 前端消费的类型由 ts-rs 导出到 `src/features/project-workspace/generated/`,
|
||||
//! 前端消费的类型由 ts-rs 导出到 `src/view/project-development/chat/generated/`,
|
||||
//! 与 Rust 定义同源:加一个字段不会只改一边。
|
||||
//!
|
||||
//! 本模块只做三件事:挑字段、脱敏、截断。工具卡片的 `kind`、标题、折叠摘要、可见性与
|
||||
@@ -29,7 +29,7 @@ const DIRECT_THREAD_PATH_MAX_CHARS: usize = 300;
|
||||
/// 一条文件变更。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectThreadFileChange {
|
||||
pub(crate) path: String,
|
||||
/// `add` | `update` | `delete`
|
||||
@@ -45,7 +45,7 @@ pub(crate) struct DirectThreadFileChange {
|
||||
/// 而 Tauri 的 JSON 通道传过来的是 `number`,因此统一标 `#[ts(as = "f64")]` 对齐。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(tag = "itemType", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectThreadItem {
|
||||
#[serde(rename = "message")]
|
||||
Message {
|
||||
@@ -179,7 +179,7 @@ impl DirectThreadItem {
|
||||
/// 增量正文属于哪类条目。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectThreadDeltaKind {
|
||||
/// assistant 正文。
|
||||
Message,
|
||||
@@ -190,7 +190,7 @@ pub(crate) enum DirectThreadDeltaKind {
|
||||
/// 审批 / 提问请求与解决:本轮只透传,不并入聊天状态。
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectThreadRequestKind {
|
||||
#[serde(rename = "approval.requested")]
|
||||
ApprovalRequested,
|
||||
@@ -235,7 +235,7 @@ impl DirectThreadRequestKind {
|
||||
/// (旧事件、没有开口用户条目、取消时拿不到 clientTurnId),此时前端不得补造。
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(tag = "type", rename_all_fields = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) enum DirectThreadEvent {
|
||||
#[serde(rename = "turn.started")]
|
||||
TurnStarted {
|
||||
@@ -398,7 +398,7 @@ impl DirectThreadEvent {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectThreadSubscriptionBootstrap {
|
||||
pub(crate) subscription_id: String,
|
||||
/// 首屏历史锚点:`project.jsonl` 里最后一条原始 item id。
|
||||
@@ -410,14 +410,14 @@ pub(crate) struct DirectThreadSubscriptionBootstrap {
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectThreadConsumeResult {
|
||||
pub(crate) events: Vec<DirectThreadEvent>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize, TS)]
|
||||
#[serde(rename_all = "camelCase", deny_unknown_fields)]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/features/project-workspace/generated/"))]
|
||||
#[ts(export, export_to = concat!(env!("CARGO_MANIFEST_DIR"), "/../src/view/project-development/chat/generated/"))]
|
||||
pub(crate) struct DirectThreadHistorySlice {
|
||||
/// 脱敏条目,顺序即文件顺序;与运行态事件里的条目同形。
|
||||
pub(crate) items: Vec<DirectThreadItem>,
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
//!
|
||||
//! Every caller supplies a safe public summary and a private detail. This
|
||||
//! module is the only persistence boundary for the latter: it redacts project
|
||||
//! paths and credentials before writing a bounded diagnostic sidecar.
|
||||
//! paths and credentials before writing a bounded diagnostic sidecar, and
|
||||
//! projects the same bounded diagnosis into the AppData application log.
|
||||
|
||||
use super::{redact_agent_runtime_error, write_agent_runtime_json_sidecar_with_max_bytes};
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -14,6 +15,20 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
pub(crate) const AGENT_RUNTIME_ERROR_SCHEMA_VERSION: &str = "agent-runtime-error.v1";
|
||||
pub(crate) const AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS: usize = 8 * 1024;
|
||||
|
||||
/// 应用日志里 detail / metadata 的字符预算。
|
||||
///
|
||||
/// `application.log` 的每一行在落盘前还会被 `sanitize_diagnostic_message` 截到 2 KiB,
|
||||
/// 这里的预算留出身份字段与中文摘要的位置,保证被截掉的是诊断正文的尾部,而不是
|
||||
/// `eventId`、`code` 或 `detailRef`。
|
||||
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS: usize = 1_200;
|
||||
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS: usize = 200;
|
||||
|
||||
/// 详情行里 public summary 的字符预算。
|
||||
///
|
||||
/// `summary` 由调用方给,`direct_tool_bridge` 传的是工具错误原文;这里与 sidecar 侧的摘要
|
||||
/// 预算同口径(320 字符)截断,并再脱敏一次,避免摘要把整条详情行占满。
|
||||
pub(crate) const AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS: usize = 320;
|
||||
|
||||
static ERROR_EVENT_SEQUENCE: AtomicU64 = AtomicU64::new(1);
|
||||
|
||||
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq)]
|
||||
@@ -71,6 +86,27 @@ pub(crate) fn persist_agent_runtime_error(
|
||||
"detail": safe_detail,
|
||||
"metadata": metadata,
|
||||
});
|
||||
// 统一错误事件的项目内 sidecar 只在项目目录可见:用户提交错误报告时上传的是 AppData
|
||||
// 应用日志,诊断包拿不到 detail。这里先把同一份已脱敏诊断留进应用日志,再落项目文件,
|
||||
// 于是 sidecar 写失败也仍然留下可提交的诊断。
|
||||
let app_log_lines = agent_runtime_error_app_log_lines(
|
||||
root,
|
||||
&event_id,
|
||||
client_turn_id,
|
||||
source,
|
||||
stage,
|
||||
code,
|
||||
retryable,
|
||||
public_text,
|
||||
recovery_hint,
|
||||
&detail_ref,
|
||||
&safe_detail,
|
||||
elapsed_ms,
|
||||
&metadata,
|
||||
);
|
||||
for line in app_log_lines {
|
||||
app_log!("{line}");
|
||||
}
|
||||
write_agent_runtime_json_sidecar_with_max_bytes(
|
||||
root,
|
||||
&detail_ref,
|
||||
@@ -96,6 +132,78 @@ pub(crate) fn persist_agent_runtime_error(
|
||||
})
|
||||
}
|
||||
|
||||
/// 把统一错误事件投影成 AppData `diagnostics/application.log` 里的两行。
|
||||
///
|
||||
/// 传进来的 `detail` 是 sidecar 用的脱敏文本,`metadata` 则从未脱敏过。两行落到 `app_log!`
|
||||
/// 时都会先按应用日志预算(1200 / 200 字符)再脱敏、再截断:`app_log!` 还会把同一行写到
|
||||
/// stderr,那里没有 `sanitize_diagnostic_message` 兜底,所以每个调用方给的外来文本
|
||||
/// (`summary` 按 320 字符预算)都在这里过一遍脱敏。
|
||||
///
|
||||
/// 已经脱敏过的 `detail` 也照走同一遍流水线,不按「调用方已脱敏」走短路:截断会把
|
||||
/// `[redacted-secret]` 这类标记切开,而且这里是 `pub(crate)` 边界,不假设未来调用方一定先脱敏。
|
||||
///
|
||||
/// 拆成「身份行 + 详情行」是因为整行只要出现凭据标记就会被
|
||||
/// [`crate::sanitize_diagnostic_message`] 整体替换成脱敏占位。因此身份行**只放程序生成或
|
||||
/// 调用方常量字段**(eventId / source / stage / code / retryable / clientTurnId / elapsedMs /
|
||||
/// detailRef),`summary`、`hint` 这些自由文本全部放详情行:自由文本里一个裸词
|
||||
/// (例如 `credential`)就能让整行被替换,放错了就会把事件定位信息一起吃掉。
|
||||
///
|
||||
/// 单行口径在这里落地:`app_log!` 同时把这行写到 stderr,那里没有人替我们压行,
|
||||
/// 所以每个字段(含 `source` / `stage` / `code` / `detailRef` 这些调用方给的标识)都先
|
||||
/// 过一遍 [`single_line_log_field`],不假设调用方一定给单行文本。
|
||||
pub(crate) fn agent_runtime_error_app_log_lines(
|
||||
root: &Path,
|
||||
event_id: &str,
|
||||
client_turn_id: Option<&str>,
|
||||
source: &str,
|
||||
stage: &str,
|
||||
code: &str,
|
||||
retryable: bool,
|
||||
public_text: &str,
|
||||
recovery_hint: &str,
|
||||
detail_ref: &str,
|
||||
detail: &str,
|
||||
elapsed_ms: Option<u64>,
|
||||
metadata: &Value,
|
||||
) -> [String; 2] {
|
||||
let event_id = single_line_log_field(event_id);
|
||||
let client_turn_id = single_line_log_field(client_turn_id.unwrap_or("none"));
|
||||
let source = single_line_log_field(source);
|
||||
let stage = single_line_log_field(stage);
|
||||
let code = single_line_log_field(code);
|
||||
let detail_ref = single_line_log_field(detail_ref);
|
||||
let public_text = single_line_log_field(&redact_agent_runtime_error(
|
||||
root,
|
||||
public_text,
|
||||
AGENT_RUNTIME_ERROR_APP_LOG_SUMMARY_CHARS,
|
||||
));
|
||||
let elapsed_ms = elapsed_ms
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "none".to_string());
|
||||
let identity = format!(
|
||||
"agent.runtime.error eventId={event_id} source={source} stage={stage} code={code} retryable={retryable} clientTurnId={client_turn_id} elapsedMs={elapsed_ms} detailRef={detail_ref}"
|
||||
);
|
||||
let detail = redact_agent_runtime_error(root, detail, AGENT_RUNTIME_ERROR_APP_LOG_DETAIL_CHARS);
|
||||
let metadata = redact_agent_runtime_error(
|
||||
root,
|
||||
&metadata.to_string(),
|
||||
AGENT_RUNTIME_ERROR_APP_LOG_METADATA_CHARS,
|
||||
);
|
||||
let detail_line = format!(
|
||||
"agent.runtime.error.detail eventId={event_id} hint={} summary={} detail={} metadata={}",
|
||||
single_line_log_field(recovery_hint),
|
||||
single_line_log_field(&public_text),
|
||||
single_line_log_field(&detail),
|
||||
single_line_log_field(&metadata),
|
||||
);
|
||||
[identity, detail_line]
|
||||
}
|
||||
|
||||
/// 应用日志是逐行读取的:落到日志里的自由文本必须先压平换行。
|
||||
fn single_line_log_field(value: &str) -> String {
|
||||
value.replace(['\r', '\n'], " ")
|
||||
}
|
||||
|
||||
pub(crate) fn classify_direct_codex_error(error: &str) -> &'static str {
|
||||
let normalized = error.to_ascii_lowercase();
|
||||
if normalized.contains("等待 turn/completed 超时") {
|
||||
@@ -153,6 +261,91 @@ mod tests {
|
||||
assert!(!text.contains("token=secret"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn error_event_app_log_lines_keep_identity_and_redact_detail() {
|
||||
let parent = tempfile::tempdir().expect("temp root");
|
||||
let root = parent.path().join("project");
|
||||
crate::project::init_local_game_project_at(&root, "runtime-error", "错误事件")
|
||||
.expect("init project");
|
||||
// 生产路径传进来的是已经脱敏的 safe_detail(8 KiB 口径),这里按同一口径造输入;
|
||||
// metadata 在生产里从未脱敏,仍按原文传。
|
||||
let safe_detail = redact_agent_runtime_error(
|
||||
&root,
|
||||
"C:\\Users\\private\\project https://provider.example/a?token=secret\n第二行诊断",
|
||||
AGENT_RUNTIME_ERROR_MAX_DETAIL_CHARS,
|
||||
);
|
||||
let lines = agent_runtime_error_app_log_lines(
|
||||
&root,
|
||||
"error-1-1",
|
||||
Some("turn-123"),
|
||||
"direct-codex",
|
||||
"code-generation",
|
||||
"turn-idle-timeout",
|
||||
true,
|
||||
"本轮没有收到完成事件\n附带换行",
|
||||
"查看诊断后重试",
|
||||
".agent/runtime/errors/error-1-1.json",
|
||||
&safe_detail,
|
||||
Some(1200),
|
||||
&serde_json::json!({"authorization": "Bearer secret"}),
|
||||
);
|
||||
for line in &lines {
|
||||
assert!(!line.contains('\n'), "{line}");
|
||||
}
|
||||
// 落盘边界按真实口径核验:应用日志逐行脱敏、逐行截断,两行不会先拼在一起。
|
||||
let persisted = lines
|
||||
.iter()
|
||||
.map(|line| crate::sanitize_diagnostic_message(line, None))
|
||||
.collect::<Vec<_>>();
|
||||
for line in &persisted {
|
||||
assert!(!line.contains("token=secret"), "{line}");
|
||||
assert!(line.chars().count() <= 2_048, "{line}");
|
||||
}
|
||||
let identity = &persisted[0];
|
||||
assert!(identity.contains("eventId=error-1-1"), "{identity}");
|
||||
assert!(identity.contains("code=turn-idle-timeout"), "{identity}");
|
||||
assert!(
|
||||
identity.contains("detailRef=.agent/runtime/errors/error-1-1.json"),
|
||||
"{identity}"
|
||||
);
|
||||
// 身份行只放程序生成或调用方常量字段:自由文本放错行会让「整行命中标记」把它吃掉。
|
||||
assert!(!identity.contains("summary="), "{identity}");
|
||||
let detail = &persisted[1];
|
||||
assert!(
|
||||
detail.contains("summary=本轮没有收到完成事件 附带换行"),
|
||||
"{detail}"
|
||||
);
|
||||
assert!(
|
||||
detail.contains("detail=<absolute-path> <redacted-url> 第二行诊断"),
|
||||
"{detail}"
|
||||
);
|
||||
assert!(!detail.contains("Bearer secret"), "{detail}");
|
||||
|
||||
// 自由文本里出现裸标记词时,被整行替换的只能是详情行:身份行仍可定位事件。
|
||||
let marked = agent_runtime_error_app_log_lines(
|
||||
&root,
|
||||
"error-3-1",
|
||||
None,
|
||||
"agc-tools",
|
||||
"tool-execution",
|
||||
"tool-error",
|
||||
true,
|
||||
"credential rotation failed",
|
||||
"查看项目错误诊断后处理",
|
||||
".agent/runtime/errors/error-3-1.json",
|
||||
&safe_detail,
|
||||
None,
|
||||
&serde_json::json!({"tool": "agc_tools"}),
|
||||
);
|
||||
let identity = crate::sanitize_diagnostic_message(&marked[0], None);
|
||||
assert!(identity.contains("eventId=error-3-1"), "{identity}");
|
||||
assert!(identity.contains("code=tool-error"), "{identity}");
|
||||
assert_eq!(
|
||||
crate::sanitize_diagnostic_message(&marked[1], None),
|
||||
"<sensitive diagnostic details redacted>"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn timeout_and_tool_errors_have_distinct_codes() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::borrow::Cow;
|
||||
use std::collections::BTreeSet;
|
||||
@@ -143,6 +143,13 @@ struct AgcSkillManifestEntry {
|
||||
sha256: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct AgcSkillCatalogEntry {
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: String,
|
||||
}
|
||||
|
||||
fn is_safe_skill_relative_path(value: &str) -> bool {
|
||||
let path = Path::new(value);
|
||||
!value.is_empty()
|
||||
@@ -256,6 +263,21 @@ pub(crate) fn agc_skill_pack_fingerprint() -> Result<String, String> {
|
||||
Ok(format!("{:x}", Sha256::digest(canonical_manifest.as_ref())))
|
||||
}
|
||||
|
||||
/// 返回当前客户端随 AGC 一起启用的内置 Skill 候选。
|
||||
///
|
||||
/// 前端不得复制审核清单;Skill 名称和描述统一从经过校验的资源 manifest 派生。
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_agc_skill_catalog() -> Result<Vec<AgcSkillCatalogEntry>, String> {
|
||||
Ok(validated_skill_pack_manifest()?
|
||||
.skills
|
||||
.into_iter()
|
||||
.map(|entry| AgcSkillCatalogEntry {
|
||||
name: entry.name,
|
||||
description: entry.purpose,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) fn render_agc_skill_pack_index() -> Result<String, String> {
|
||||
let manifest = validated_skill_pack_manifest()?;
|
||||
let mut lines = vec![format!(
|
||||
@@ -350,6 +372,19 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_catalog_is_derived_from_the_validated_manifest() {
|
||||
let catalog = list_agc_skill_catalog().expect("skill catalog");
|
||||
assert_eq!(catalog.len(), AGC_SKILL_PACK_EXPECTED_NAMES.len());
|
||||
for expected_name in AGC_SKILL_PACK_EXPECTED_NAMES {
|
||||
let entry = catalog
|
||||
.iter()
|
||||
.find(|entry| entry.name == expected_name)
|
||||
.expect("expected bundled skill");
|
||||
assert!(!entry.description.trim().is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_content_digest_is_stable_across_lf_and_crlf() {
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
|
||||
@@ -887,37 +887,6 @@ pub(crate) async fn pick_local_project_directory(
|
||||
Ok(Some(path.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn pick_local_file(app: tauri::AppHandle) -> Result<Option<String>, String> {
|
||||
let (sender, receiver) = tokio::sync::oneshot::channel();
|
||||
let mut dialog = app.dialog().file().set_title("选择本地文件");
|
||||
if let Some(window) = app.get_webview_window("client") {
|
||||
dialog = dialog.set_parent(&window);
|
||||
}
|
||||
dialog.pick_file(move |path| {
|
||||
let _ = sender.send(path);
|
||||
});
|
||||
let Some(path) = receiver
|
||||
.await
|
||||
.map_err(|_| "本地文件选择器意外关闭".to_string())?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
let path = path
|
||||
.into_path()
|
||||
.map_err(|error| format!("读取本地文件失败:{error}"))?;
|
||||
#[cfg(windows)]
|
||||
crate::register_game_creator_user_selected_path(&path, false);
|
||||
if let Err(error) =
|
||||
crate::prepare_game_creator_user_selected_path_for_read(&path, false, "用户选择文件")
|
||||
{
|
||||
#[cfg(windows)]
|
||||
crate::revoke_game_creator_user_selected_path(&path);
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Some(path.to_string_lossy().into_owned()))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_local_project_directory(
|
||||
app: tauri::AppHandle,
|
||||
@@ -5688,18 +5657,6 @@ pub(crate) fn write_local_project_file(
|
||||
write_local_project_file_at(root, relative_path.trim(), &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_local_project_file(
|
||||
project_path: String,
|
||||
relative_path: String,
|
||||
) -> Result<LocalProjectFileMutationResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "file.delete")?;
|
||||
let _lock = acquire_project_write_lock(root, "file.delete")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
delete_local_project_file_at(root, relative_path.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn read_local_game_memory(
|
||||
project_path: String,
|
||||
@@ -5748,18 +5705,6 @@ pub(crate) fn write_local_game_memory(
|
||||
write_local_game_memory_at(root, scope.trim(), &content)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn delete_local_game_memory(
|
||||
project_path: String,
|
||||
scope: String,
|
||||
) -> Result<LocalGameMemoryResult, String> {
|
||||
let root = Path::new(project_path.trim());
|
||||
enforce_project_permission_policy(root, "memory.delete")?;
|
||||
let _lock = acquire_project_write_lock(root, "memory.delete")?;
|
||||
advance_agent_runtime_project_revision_locked(root)?;
|
||||
delete_local_game_memory_at(root, scope.trim())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn list_game_creator_agent_sessions(
|
||||
project_path: String,
|
||||
|
||||
@@ -157,6 +157,7 @@ mod tool_plan_handoff;
|
||||
mod user_input;
|
||||
mod windows;
|
||||
|
||||
use agent::design_tools::*;
|
||||
use agent::*;
|
||||
use agent_native_tools::*;
|
||||
use asset_generation_tasks::*;
|
||||
@@ -2577,10 +2578,10 @@ fn main() {
|
||||
rename_local_game_project,
|
||||
suggest_automatic_project_name,
|
||||
polish_local_project_prompt,
|
||||
pick_local_file,
|
||||
pick_client_extension_file,
|
||||
pick_client_extension_directory,
|
||||
list_client_extensions,
|
||||
list_agc_skill_catalog,
|
||||
import_client_extension,
|
||||
set_client_extension_enabled,
|
||||
rename_client_extension,
|
||||
@@ -2613,6 +2614,7 @@ fn main() {
|
||||
debug_fast_forward_design_session,
|
||||
continue_design_agent_session,
|
||||
decide_design_phase,
|
||||
import_design_workspace_file,
|
||||
list_design_workspace,
|
||||
read_design_workspace_file,
|
||||
start_game_creator_agent_runtime_task,
|
||||
@@ -2694,12 +2696,10 @@ fn main() {
|
||||
read_local_project_media_preview,
|
||||
cancel_local_project_resource_preview_scope,
|
||||
write_local_project_file,
|
||||
delete_local_project_file,
|
||||
read_local_game_memory,
|
||||
read_local_agent_memory,
|
||||
write_local_agent_memory,
|
||||
write_local_game_memory,
|
||||
delete_local_game_memory,
|
||||
list_game_creator_agent_sessions,
|
||||
create_game_creator_agent_session,
|
||||
fork_game_creator_agent_session,
|
||||
@@ -2724,7 +2724,6 @@ fn main() {
|
||||
write_project_permission_policy,
|
||||
open_game_creator_workspace_window,
|
||||
open_game_creator_launcher_window,
|
||||
open_project_supervisor_chat_window,
|
||||
start_local_game_preview,
|
||||
activate_local_game_preview,
|
||||
stop_local_game_preview,
|
||||
|
||||
@@ -451,18 +451,6 @@ fn dispatch_static_delegate_plain_repair(
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supervisor_chat_window_carries_encoded_project_path() {
|
||||
assert_eq!(
|
||||
supervisor_chat_window_url("/tmp/AI Game 项目").to_string(),
|
||||
"index.html?supervisor-chat&projectPath=%2Ftmp%2FAI%20Game%20%E9%A1%B9%E7%9B%AE"
|
||||
);
|
||||
assert_eq!(
|
||||
supervisor_chat_window_url("/tmp/a&b?#%+c").to_string(),
|
||||
"index.html?supervisor-chat&projectPath=%2Ftmp%2Fa%26b%3F%23%25%2Bc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_supervisor_runtime_id_is_normalized_and_collected() {
|
||||
let root = unique_project_path();
|
||||
|
||||
@@ -2777,7 +2777,7 @@ fn agent_run_history_prunes_to_latest_hundred_traces() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn developer_project_file_and_memory_mutations_advance_project_revision() {
|
||||
fn developer_project_file_and_memory_writes_advance_project_revision() {
|
||||
let root = unique_project_path();
|
||||
init_local_game_project_at(&root, "project-1", "开发面板 revision").expect("project init");
|
||||
let project_path = root.to_string_lossy().into_owned();
|
||||
@@ -2800,16 +2800,11 @@ fn developer_project_file_and_memory_mutations_advance_project_revision() {
|
||||
"项目记忆".to_string(),
|
||||
)
|
||||
.expect("write project memory through command");
|
||||
delete_local_game_memory(project_path.clone(), "long".to_string())
|
||||
.expect("delete project memory through command");
|
||||
delete_local_project_file(project_path, "game/revision.txt".to_string())
|
||||
.expect("delete project file through command");
|
||||
|
||||
assert_eq!(
|
||||
read_game_creator_agent_runtime_project_revision(&root)
|
||||
.expect("read developer mutation revision")
|
||||
.revision,
|
||||
5
|
||||
3
|
||||
);
|
||||
fs::remove_dir_all(root).ok();
|
||||
}
|
||||
|
||||
@@ -119,13 +119,6 @@ pub(crate) fn launcher_window_url() -> tauri::WebviewUrl {
|
||||
tauri::WebviewUrl::App(PathBuf::from("index.html?launcher"))
|
||||
}
|
||||
|
||||
pub(crate) fn supervisor_chat_window_url(project_path: &str) -> tauri::WebviewUrl {
|
||||
tauri::WebviewUrl::App(PathBuf::from(format!(
|
||||
"index.html?supervisor-chat&projectPath={}",
|
||||
percent_encode_query_value(project_path)
|
||||
)))
|
||||
}
|
||||
|
||||
pub(crate) fn validate_workspace_window_project_path(project_path: &str) -> Result<&str, String> {
|
||||
let project_path = project_path.trim();
|
||||
if project_path.is_empty() {
|
||||
@@ -193,52 +186,3 @@ pub(crate) fn open_game_creator_launcher_window(
|
||||
window.close().map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn open_project_supervisor_chat_window(
|
||||
app: tauri::AppHandle,
|
||||
project_path: String,
|
||||
) -> Result<(), String> {
|
||||
let project_path = validate_workspace_window_project_path(&project_path)?;
|
||||
#[cfg(not(debug_assertions))]
|
||||
{
|
||||
let _ = app;
|
||||
let _ = project_path;
|
||||
return Err("项目总控对话窗口仅在开发构建中可用".to_string());
|
||||
}
|
||||
#[cfg(debug_assertions)]
|
||||
{
|
||||
if let Some(existing) = app.get_webview_window("supervisor-chat") {
|
||||
let mut current_url = existing.url().map_err(|error| error.to_string())?;
|
||||
let current_project_path = current_url
|
||||
.query_pairs()
|
||||
.find_map(|(key, value)| (key == "projectPath").then(|| value.into_owned()));
|
||||
if current_project_path.as_deref() != Some(project_path) {
|
||||
current_url.set_query(Some(&format!(
|
||||
"supervisor-chat&projectPath={}",
|
||||
percent_encode_query_value(project_path)
|
||||
)));
|
||||
current_url.set_fragment(None);
|
||||
existing
|
||||
.navigate(current_url)
|
||||
.map_err(|error| error.to_string())?;
|
||||
}
|
||||
existing.show().map_err(|error| error.to_string())?;
|
||||
existing.unminimize().map_err(|error| error.to_string())?;
|
||||
existing.set_focus().map_err(|error| error.to_string())?;
|
||||
return Ok(());
|
||||
}
|
||||
tauri::WebviewWindowBuilder::new(
|
||||
&app,
|
||||
"supervisor-chat",
|
||||
supervisor_chat_window_url(project_path),
|
||||
)
|
||||
.title("项目总控 Agent 对话")
|
||||
.decorations(false)
|
||||
.inner_size(820.0, 720.0)
|
||||
.min_inner_size(560.0, 480.0)
|
||||
.build()
|
||||
.map_err(|error| error.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
+473
-10323
File diff suppressed because it is too large
Load Diff
@@ -9,15 +9,11 @@ export function createLocalProjectId(): string {
|
||||
return `local-project-${crypto.randomUUID()}`;
|
||||
}
|
||||
|
||||
export const AGENT_RUN_HISTORY_MAX_COUNT = 100;
|
||||
export const AGENT_RUN_HISTORY_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const AGENT_RUN_HISTORY_VISIBLE_STEP = 20;
|
||||
export const CONVERSATION_INITIAL_VISIBLE_COUNT = 20;
|
||||
export const CONVERSATION_VISIBLE_STEP = 20;
|
||||
/** 一次翻页操作最多连拉几页:见 ADR「分页锚点取原始条目 id」。 */
|
||||
export const DIRECT_HISTORY_MAX_PAGES_PER_ACTION = 5;
|
||||
export const AGENT_CHAT_SCROLL_BOTTOM_THRESHOLD = 48;
|
||||
export const PROJECT_SUPERVISOR_AGENT_ID = 'project-supervisor';
|
||||
export const launcherNotifications: Array<{
|
||||
label: string;
|
||||
detail: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user